@@ -102,15 +102,33 @@ private def mkDiffString (ds : Array (Diff.Action × String)) : String :=
102102 | (.skip , s) => s
103103 rangeStrs.foldl (· ++ ·) ""
104104
105+ /-- The granularity at which to display an inline diff for a suggested edit. -/
106+ inductive DiffGranularity where
107+ /-- Automatically select diff granularity based on edit distance. -/
108+ | auto
109+ /-- Character-level diff. -/
110+ | char
111+ /-- Diff using whitespace-separated tokens. -/
112+ | word
113+ /--
114+ "Monolithic" diff: shows a deletion of the entire existing source, followed by an insertion of the
115+ entire suggestion.
116+ -/
117+ | all
118+
105119/--
106120A code action suggestion associated with a hint in a message.
107121
108- Refer to `TryThis.Suggestion`; this extends that structure with a `span?` field, allowing a single
109- hint to suggest modifications at different locations. If `span?` is not specified, then the syntax
110- reference provided to `MessageData.hint` will be used.
122+ Refer to `TryThis.Suggestion`. This extends that structure with the following fields:
123+ * `span?`: the span at which this suggestion should apply. This allows a single hint to suggest
124+ modifications at different locations. If `span?` is not specified, then the syntax reference
125+ provided to `MessageData.hint` will be used.
126+ * `diffGranularity`: the granularity at which the diff for this suggestion should be rendered in the
127+ Infoview. See `DiffMode` for the possible granularities. This is `.auto` by default.
111128 -/
112129structure Suggestion extends toTryThisSuggestion : TryThis.Suggestion where
113130 span? : Option Syntax := none
131+ diffGranularity : DiffGranularity := .auto
114132
115133instance : Coe TryThis.SuggestionText Suggestion where
116134 coe t := { suggestion := t }
@@ -119,34 +137,159 @@ instance : ToMessageData Suggestion where
119137 toMessageData s := toMessageData s.toTryThisSuggestion
120138
121139/--
122- Produces a diff that splits either on characters, tokens, or not at all, depending on the edit
123- distance between the arguments .
140+ Produces a diff that splits either on characters, tokens, or not at all, depending on the selected
141+ `diffMode` .
124142
125143Guarantees that all actions in the output will be maximally grouped; that is, instead of returning
126144`#[(.insert, "a"), (.insert, "b")]`, it will return `#[(.insert, "ab")]`.
127145-/
128- partial def readableDiff (s s' : String) : Array (Diff.Action × String) :=
129- -- TODO: add additional diff granularities
130- let minLength := min s.length s'.length
131- -- The coefficient on this value can be tuned:
132- let maxCharDiffDistance := minLength
133-
134- let charDiff := Diff.diff (splitChars s) (splitChars s')
135- -- Note: this is just a rough heuristic, since the diff has no notion of substitution
136- let approxEditDistance := charDiff.filter (·.1 != .skip) |>.size
137- let preStrDiff := joinEdits charDiff
138- -- Given that `Diff.diff` returns a minimal diff, any length-≤3 diff can only have edits at the
139- -- front and back, or at a single interior point. This will always be fairly readable (and
140- -- splitting by a larger unit would likely only be worse). Otherwise, we should only use the
141- -- per-character diff if the edit distance isn't so large that it will be hard to read:
142- if preStrDiff.size ≤ 3 || approxEditDistance ≤ maxCharDiffDistance then
143- preStrDiff.map fun (act, cs) => (act, String.mk cs.toList)
144- else
145- #[(.delete, s), (.insert, s')]
146+ partial def readableDiff (s s' : String) (granularity : DiffGranularity := .auto) : Array (Diff.Action × String) :=
147+ match granularity with
148+ | .char => charDiff
149+ | .word => wordDiff
150+ | .all => maxDiff
151+ | .auto =>
152+ let minLength := min s.length s'.length
153+ -- The coefficients on these values can be tuned:
154+ let maxCharDiffDistance := minLength / 5
155+ let maxWordDiffDistance := minLength / 2 + (max s.length s'.length) / 2
156+
157+ let charDiffRaw := Diff.diff (splitChars s) (splitChars s')
158+ -- Note: this is just a rough heuristic, since the diff has no notion of substitution
159+ let approxEditDistance := charDiffRaw.filter (·.1 != .skip) |>.size
160+ let charArrDiff := joinEdits charDiffRaw
161+
162+ -- Given that `Diff.diff` returns a minimal diff, any length-≤3 diff can only have edits at the
163+ -- front and back, or at a single interior point. This will always be fairly readable (and
164+ -- splitting by a larger unit would likely only be worse)
165+ if charArrDiff.size ≤ 3 || approxEditDistance ≤ maxCharDiffDistance then
166+ charArrDiff.map fun (act, cs) => (act, String.mk cs.toList)
167+ else if approxEditDistance ≤ maxWordDiffDistance then
168+ wordDiff
169+ else
170+ maxDiff
146171where
172+ /-
173+ Note on whitespace insertion:
174+ Because we display diffs fully inline, we must trade off between accurately rendering changes to
175+ whitespace and accurately previewing what will be inserted. We err on the side of the latter.
176+ Within a "run" of deletions or insertions, we maintain the whitespace from the deleted/inserted
177+ text and mark it as a deletion/insertion. After an unchanged word or a substitution (i.e., a
178+ deletion and insertion without an intervening unchanged word), we show a whitespace diff iff the
179+ old whitespace did not contain a line break (as rendering a deleted newline still visually
180+ suggests a line break in the new output); otherwise, we use the whitespace from the new version
181+ but mark it as unchanged, since there was also whitespace here originally too. Within a
182+ substitution, we omit whitespace entirely. After an insertion, we show the new whitespace and mark
183+ it as an insertion. After a deletion, we render the old whitespace as a deletion unless it
184+ contains a newline, for the same reason mentioned previously.
185+ -/
186+ wordDiff := Id.run do
187+ let (words, wss) := splitWords s
188+ let (words', wss') := splitWords s'
189+ let diff := Diff.diff words words'
190+ let mut withWs := #[]
191+ let mut (wssIdx, wss'Idx) := (0 , 0 )
192+ let mut inSubst := false
193+ for h : diffIdx in [:diff.size] do
194+ let (a₁, s₁) := diff[diffIdx]
195+ withWs := withWs.push (a₁, s₁)
196+ if let some (a₂, s₂) := diff[diffIdx + 1 ]? then
197+ match a₁, a₂ with
198+ | .skip, .delete =>
199+ -- Unchanged word: show whitespace diff unless this is followed by a deleted terminal
200+ -- substring of the old, in which case show the old whitespace (since there is no new)
201+ let ws := wss[wssIdx]!
202+ let wsDiff := if let some ws' := wss'[wss'Idx]? then
203+ mkWhitespaceDiff ws ws'
204+ else
205+ #[(.delete, ws)]
206+ withWs := withWs ++ wsDiff
207+ wssIdx := wssIdx + 1
208+ wss'Idx := wss'Idx + 1
209+ | .skip, .skip | .skip, .insert =>
210+ -- Unchanged word: inverse of the above case: new has whitespace here, and old does too so
211+ -- long as we haven't reached an appended terminal new portion
212+ let ws' := wss'[wss'Idx]!
213+ let wsDiff := if let some ws := wss[wssIdx]? then
214+ mkWhitespaceDiff ws ws'
215+ else
216+ #[(.insert, ws')]
217+ withWs := withWs ++ wsDiff
218+ wssIdx := wssIdx + 1
219+ wss'Idx := wss'Idx + 1
220+ | .insert, .insert =>
221+ -- Insertion separator: include whitespace, and mark it as inserted
222+ let ws := wss'[wss'Idx]!
223+ withWs := withWs.push (.insert, ws)
224+ wss'Idx := wss'Idx + 1
225+ | .insert, .skip =>
226+ -- End of insertion: if this was a substitution, new and old have whitespace here; if it
227+ -- wasn't, only new has whitespace here
228+ let ws' := wss'[wss'Idx]!
229+ let wsDiff := if inSubst then
230+ mkWhitespaceDiff wss[wssIdx]! ws'
231+ else
232+ #[(.insert, ws')]
233+ withWs := withWs ++ wsDiff
234+ wss'Idx := wss'Idx + 1
235+ if inSubst then wssIdx := wssIdx + 1
236+ inSubst := false
237+ | .delete, .delete =>
238+ -- Deletion separator: include and mark as deleted
239+ let ws := wss[wssIdx]!
240+ withWs := withWs.push (.delete, ws)
241+ wssIdx := wssIdx + 1
242+ | .delete, .skip =>
243+ -- End of deletion: include the deletion's whitespace as deleted iff it is not a newline
244+ -- (see earlier note); in principle, we should never have a substitution ending with a
245+ -- deletion (`diff` should prefer `a̵b̲` to `b̲a̵`), but we handle this in case `diff` changes
246+ let ws := wss[wssIdx]!
247+ unless inSubst || ws.contains '\n ' do
248+ withWs := withWs.push (.delete, ws)
249+ wssIdx := wssIdx + 1
250+ if inSubst then wss'Idx := wss'Idx + 1
251+ inSubst := false
252+ | .insert, .delete | .delete, .insert =>
253+ -- "Substitution point": don't include any whitespace, since we're switching this word
254+ inSubst := true
255+ withWs
256+ |> joinEdits
257+ |>.map fun (act, ss) => (act, ss.foldl (· ++ ·) "" )
258+
259+ charDiff :=
260+ Diff.diff (splitChars s) (splitChars s') |> joinCharDiff
261+
262+ /-- Given a `Char` diff, produces an equivalent `String` diff, joining actions of the same kind. -/
263+ joinCharDiff (d : Array (Diff.Action × Char)) :=
264+ joinEdits d |>.map fun (act, cs) => (act, String.mk cs.toList)
265+
266+ maxDiff :=
267+ #[(.delete, s), (.insert, s')]
268+
269+ mkWhitespaceDiff (oldWs newWs : String) :=
270+ if !oldWs.contains '\n ' then
271+ Diff.diff oldWs.data.toArray newWs.data.toArray |> joinCharDiff
272+ else
273+ #[(.skip, newWs)]
274+
147275 splitChars (s : String) : Array Char :=
148276 s.toList.toArray
149277
278+ splitWords (s : String) : Array String × Array String :=
279+ splitWordsAux s 0 0 #[] #[]
280+
281+ splitWordsAux (s : String) (b : String.Pos) (i : String.Pos) (r ws : Array String) : Array String × Array String :=
282+ if h : s.atEnd i then
283+ (r.push (s.extract b i), ws)
284+ else
285+ have := Nat.sub_lt_sub_left (Nat.gt_of_not_le (mt decide_eq_true h)) (String.lt_next s _)
286+ if (s.get i).isWhitespace then
287+ let skipped := (Substring.mk s i s.endPos).takeWhile (·.isWhitespace)
288+ let i' := skipped.stopPos
289+ splitWordsAux s i' i' (r.push (s.extract b i)) (ws.push (s.extract i i'))
290+ else
291+ splitWordsAux s b (s.next i) r ws
292+
150293 joinEdits {α} (ds : Array (Diff.Action × α)) : Array (Diff.Action × Array α) :=
151294 ds.foldl (init := #[]) fun acc (act, c) =>
152295 if h : acc.isEmpty then
@@ -178,7 +321,7 @@ def mkSuggestionsMessage (suggestions : Array Suggestion)
178321 let suggestionText := suggestionArr[0 ]!.2 .1
179322 let map ← getFileMap
180323 let rangeContents := Substring.mk map.source range.start range.stop |>.toString
181- let edits := readableDiff rangeContents suggestionText
324+ let edits := readableDiff rangeContents suggestionText suggestion.diffGranularity
182325 let diffJson := mkDiffJson edits
183326 let json := json% {
184327 diff: $diffJson,
0 commit comments