Skip to content

Commit 2fe6d8a

Browse files
authored
feat: add word-level hint suggestion diffs (#8574)
This PR adds an additional diff mode to the error-message hint suggestion widget that displays diffs per word rather than per character.
1 parent b1a306c commit 2fe6d8a

File tree

4 files changed

+324
-27
lines changed

4 files changed

+324
-27
lines changed

src/Lean/Meta/Hint.lean

Lines changed: 167 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -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
/--
106120
A 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
-/
112129
structure Suggestion extends toTryThisSuggestion : TryThis.Suggestion where
113130
span? : Option Syntax := none
131+
diffGranularity : DiffGranularity := .auto
114132

115133
instance : 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
125143
Guarantees 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
146171
where
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,

tests/lean/run/hintSuggestionMessage.lean

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ error: Invalid
194194
195195
Hint: Use one of these instead
196196
• first second thir̵d̵ ̵f̵o̵u̵r̵t̵h̵ ̵f̵i̵f̵t̵h̵
197-
f̵i̵r̵st̵ ̵s̵econd t̵h̵i̵r̵d̵ ̵fourth fifth
197+
f̵i̵r̵s̵t̵ ̵second t̵h̵i̵r̵d̵ ̵fourth fifth
198198
• f̵i̵r̵s̵t̵ ̵s̵e̵c̵o̵n̵d̵ ̵t̵h̵i̵r̵d̵ ̵f̵o̵u̵r̵t̵h̵ ̵f̵i̵f̵t̵h̵f̲i̲ ̲s̲e̲ ̲t̲h̲ ̲f̲o̲ ̲f̲i̲
199199
• f̵i̵r̵s̵t̵ ̵s̵e̵c̵o̵n̵d̵ ̵t̵h̵i̵r̵d̵ ̵f̵o̵u̵r̵t̵h̵ ̵f̵i̵f̵t̵h̵s̲e̲c̲o̲n̲d̲
200200
-/
@@ -211,7 +211,7 @@ error: Invalid
211211
212212
Hint: Try one of these
213213
• let x := 4̵2̵
214-
̵3̲1̲;̲ ̵2̵ ̵*̵ ̵x
214+
̵ ̵ ̵2̵ ̵*̵3̲1̲;̲ x
215215
• l̵e̵t̵ ̵x̵ ̵:̵=̵ ̵4̵2̵
216216
̵ ̵ ̵2̵ ̵*̵ ̵x̵l̲e̲t̲ ̲(̲a̲,̲ ̲b̲,̲ ̲c̲)̲ ̲:̲=̲ ̲(̲1̲,̲ ̲2̲,̲ ̲3̲)̲
217217
̲ ̲ ̲b̲
@@ -222,3 +222,55 @@ Hint: Try one of these
222222
#check suggest_replacement%
223223
let x := 42
224224
2 * x
225+
226+
/-! ## Forced diff granularities -/
227+
228+
namespace DiffGranularity
229+
scoped syntax "select " "granularity " term : command
230+
231+
open Elab in
232+
elab_rules : command
233+
| `(command| select granularity $t) => do
234+
let tp := mkConst ``DiffGranularity
235+
let g ← unsafe Command.runTermElabM (fun _ =>
236+
Term.elabTerm t tp >>= monadLift ∘ evalExpr DiffGranularity tp)
237+
let hint ← Command.liftCoreM <| MessageData.hint m!"Hint" #[{
238+
suggestion :=
239+
if g matches .all then
240+
-- Ensure we wouldn't have used `.all` by default
241+
"selected granularity .all"
242+
else
243+
"many granularity words not matching the source"
244+
diffGranularity := g
245+
}]
246+
logInfo hint
247+
248+
/--
249+
info:
250+
251+
Hint: Hint
252+
s̵e̵m̲a̲n̲y̲ ̲g̲r̲a̲n̲u̲le̵c̵a̲r̲i̲ty̲ w̲o̲r̲d̲s̲ ̲n̲o̲t̲ ̲m̲a̲t̲c̲h̲i̲n̲gr̵a̵n̵u̵l̵a̵r̵i̵ ̲ty̵h̲e̲ .̵c̵h̵a̵s̲o̲u̲rc̲e̲
253+
-/
254+
#guard_msgs (whitespace := exact) in
255+
select granularity .char
256+
257+
/--
258+
info:
259+
260+
Hint: Hint
261+
s̵e̵l̵e̵c̵t̵m̲a̲n̲y̲ granularity .̵w̵o̵r̵d̵w̲o̲r̲d̲s̲ ̲n̲o̲t̲ ̲m̲a̲t̲c̲h̲i̲n̲g̲ ̲t̲h̲e̲ ̲s̲o̲u̲r̲c̲e̲
262+
-/
263+
#guard_msgs (whitespace := exact) in
264+
select granularity .word
265+
266+
/--
267+
info:
268+
269+
Hint: Hint
270+
s̵e̵l̵e̵c̵t̵ ̵g̵r̵a̵n̵u̵l̵a̵r̵i̵t̵y̵ ̵.̵a̵l̵l̵s̲e̲l̲e̲c̲t̲e̲d̲ ̲g̲r̲a̲n̲u̲l̲a̲r̲i̲t̲y̲ ̲.̲a̲l̲l̲
271+
-/
272+
#guard_msgs (whitespace := exact) in
273+
select granularity .all
274+
275+
276+
end DiffGranularity

0 commit comments

Comments
 (0)