Skip to content

Commit 8c7d6c9

Browse files
committed
fix: improve hint diffs
1 parent a5565ba commit 8c7d6c9

File tree

6 files changed

+250
-184
lines changed

6 files changed

+250
-184
lines changed

src/Lean/Message.lean

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -349,16 +349,14 @@ def andList (xs : List MessageData) : MessageData :=
349349
Produces a labeled note that can be appended to an error message.
350350
-/
351351
def note (note : MessageData) : MessageData :=
352-
-- Note: we do not use the built-in string coercion because it can prevent proper line breaks
353-
-- FIXME: doing this doesn't end the enclosing group, so line feeds in `note` may become spaces
354-
"\n" ++ "\n" ++ "Note: " ++ note
352+
Format.line ++ Format.line ++ "Note: " ++ note
355353

356354
/--
357355
Produces a labeled hint without an associated code action (non-monadic variant of
358356
`MessageData.hint`).
359357
-/
360358
def hint' (hint : MessageData) : MessageData :=
361-
.ofFormat "\n\nHint: " ++ hint
359+
Format.line ++ Format.line ++ "Hint: " ++ hint
362360

363361
instance : Coe (List MessageData) MessageData := ⟨ofList⟩
364362
instance : Coe (List Expr) MessageData := ⟨fun es => ofList <| es.map ofExpr⟩

src/Lean/Meta/Hint.lean

Lines changed: 97 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import Lean.Data.Lsp.Utf16
1111
import Lean.Message
1212
import Lean.Meta.TryThis
1313
import Lean.Util.Diff
14+
import Lean.Util.EditDistance
1415
import Lean.Widget.Types
1516
import Lean.PrettyPrinter
1617

@@ -66,48 +67,43 @@ export default function ({ diff, range, suggestion }) {
6667
e('span', defStyle, comp.text)
6768
)
6869
const fullDiff = e('span',
69-
{ onClick, title: 'Apply suggestion', className: 'link pointer dim font-code', },
70+
{ onClick,
71+
title: 'Apply suggestion',
72+
className: 'link pointer dim font-code',
73+
style: { display: 'inline-block', verticalAlign: 'text-top' } },
7074
spans)
7175
return fullDiff
7276
}"
7377

7478
/--
7579
Converts an array of diff actions into corresponding JSON interpretable by `tryThisDiffWidget`.
7680
-/
77-
private def mkDiffJson (ds : Array (Diff.Action × Char)) :=
78-
-- Avoid cluttering the DOM by grouping "runs" of the same action
79-
let unified : List (Diff.Action × List Char) := ds.foldr (init := []) fun
80-
| (act, c), [] => [(act, [c])]
81-
| (act, c), (act', cs) :: acc =>
82-
if act == act' then
83-
(act, c :: cs) :: acc
84-
else
85-
(act, [c]) :: (act', cs) :: acc
86-
toJson <| unified.map fun
87-
| (.insert, s) => json% { type: "insertion", text: $(String.mk s) }
88-
| (.delete, s) => json% { type: "deletion", text: $(String.mk s) }
89-
| (.skip , s) => json% { type: "unchanged", text: $(String.mk s) }
81+
private def mkDiffJson (ds : Array (Diff.Action × String)) :=
82+
toJson <| ds.map fun
83+
| (.insert, s) => json% { type: "insertion", text: $s }
84+
| (.delete, s) => json% { type: "deletion", text: $s }
85+
| (.skip , s) => json% { type: "unchanged", text: $s }
9086

9187
/--
9288
Converts an array of diff actions into a Unicode string that visually depicts the diff.
9389
9490
Note that this function does not return the string that results from applying the diff to some
95-
input; rather, it returns a string representation of the actions that the diff itself comprises, such as `b̵a̵c̲h̲e̲e̲rs̲`.
96-
91+
input; rather, it returns a string representation of the actions that the diff itself comprises,
92+
such as `b̵a̵c̲h̲e̲e̲rs̲`.
9793
-/
98-
private def mkDiffString (ds : Array (Diff.Action × Char)) : String :=
94+
private def mkDiffString (ds : Array (Diff.Action × String)) : String :=
9995
let rangeStrs := ds.map fun
100-
| (.insert, s) => String.mk [s, '\u0332'] -- U+0332 Combining Low Line
101-
| (.delete, s) => String.mk [s, '\u0335'] -- U+0335 Combining Short Stroke Overlay
102-
| (.skip , s) => String.mk [s]
96+
| (.insert, s) => String.mk (s.data.flatMap ([·, '\u0332'])) -- U+0332 Combining Low Line
97+
| (.delete, s) => String.mk (s.data.flatMap ([·, '\u0335'])) -- U+0335 Combining Short Stroke Overlay
98+
| (.skip , s) => s
10399
rangeStrs.foldl (· ++ ·) ""
104100

105101
/--
106102
A code action suggestion associated with a hint in a message.
107103
108104
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 `ref`
110-
for the containing `Suggestions` value is used.
105+
hint to suggest modifications at different locations. If `span?` is not specified, then the syntax
106+
reference provided to `MessageData.hint` will be used.
111107
-/
112108
structure Suggestion extends TryThis.Suggestion where
113109
span? : Option Syntax := none
@@ -119,40 +115,83 @@ instance : ToMessageData Suggestion where
119115
toMessageData s := toMessageData s.toSuggestion
120116

121117
/--
122-
A collection of code action suggestions to be included in a hint in a diagnostic message.
118+
Produces a diff that splits either on characters, tokens, or not at all, depending on the edit
119+
distance between the arguments.
123120
124-
Contains the following fields:
125-
* `ref`: the syntax location for the code action suggestions. Will be overridden by the `span?`
126-
field on any suggestions that specify it.
127-
* `suggestions`: the suggestions to display.
128-
* `codeActionPrefix?`: if specified, text to display in place of "Try this: " in the code action
129-
label
121+
Guarantees that all actions in the output will be maximally grouped; that is, instead of returning
122+
`#[(.insert, "a"), (.insert, "b")]`, it will return `#[(.insert, "ab")]`.
130123
-/
131-
structure Suggestions where
132-
ref : Syntax
133-
suggestions : Array Suggestion
134-
codeActionPrefix? : Option String := none
124+
def readableDiff (s s' : String) : Array (Diff.Action × String) :=
125+
let minLength := min s.length s'.length
126+
-- Given that `Diff.diff` returns a minimal diff, any length-≤3 diff can only have edits at the
127+
-- front and back, or at a single interior point. This will always be fairly readable, so we can
128+
-- return it without further analysis:
129+
if charDiff.size ≤ 3 then
130+
charDiff
131+
else
132+
-- The coefficients on these two constants can be tuned:
133+
let charDiffDistance := minLength / 5
134+
let wordDiffDistance := minLength
135+
if let some editDistance := EditDistance.levenshtein s s' wordDiffDistance then
136+
if editDistance < charDiffDistance then
137+
charDiff
138+
else
139+
wordDiff
140+
else
141+
maxDiff
142+
where
143+
splitChars (s : String) :=
144+
s.toList.toArray
145+
146+
splitWords (s : String) :=
147+
s.splitOn " " |>.intersperse " " |>.toArray
148+
149+
charDiff :=
150+
Diff.diff (splitChars s) (splitChars s')
151+
|> joinEdits
152+
|>.map fun (act, cs) => (act, String.mk cs.toList)
153+
154+
wordDiff :=
155+
Diff.diff (splitWords s) (splitWords s')
156+
|> joinEdits
157+
|>.map fun (act, ss) => (act, ss.foldl (· ++ ·) "")
158+
159+
maxDiff :=
160+
#[(.delete, s), (.insert, s')]
161+
162+
joinEdits {α} (ds : Array (Diff.Action × α)) : Array (Diff.Action × Array α) :=
163+
ds.foldl (init := #[]) fun acc (act, c) =>
164+
if h : acc.isEmpty then
165+
#[(act, #[c])]
166+
else
167+
have : acc.size - 1 < acc.size := Nat.sub_one_lt <| mt Array.size_eq_zero_iff.mp <|
168+
Array.isEmpty_eq_false_iff.mp (Bool.of_not_eq_true h)
169+
let (act', cs) := acc[acc.size - 1]
170+
if act == act' then
171+
acc.set (acc.size - 1) (act, cs.push c)
172+
else
173+
acc.push (act, #[c])
135174

136175
/--
137176
Creates message data corresponding to a `HintSuggestions` collection and adds the corresponding info
138177
leaf.
139178
-/
140-
def Suggestions.toHintMessage (suggestions : Suggestions) : CoreM MessageData := do
141-
let { ref, codeActionPrefix?, suggestions } := suggestions
179+
def mkSuggestionsMessage (suggestions : Array Suggestion)
180+
(ref : Syntax)
181+
(codeActionPrefix? : Option String) : CoreM MessageData := do
142182
let mut msg := m!""
143183
for suggestion in suggestions do
144184
if let some range := (suggestion.span?.getD ref).getRange? then
145-
let { info, suggestions := suggestionArr, range := lspRange } ← processSuggestions ref range
146-
#[suggestion.toSuggestion] codeActionPrefix?
185+
let { info, suggestions := suggestionArr, range := lspRange } ←
186+
processSuggestions ref range #[suggestion.toSuggestion] codeActionPrefix?
147187
pushInfoLeaf info
148188
let suggestionText := suggestionArr[0]!.2.1
149189
let map ← getFileMap
150190
let rangeContents := Substring.mk map.source range.start range.stop |>.toString
151-
let split (s : String) := s.toList.toArray
152-
let edits := Diff.diff (split rangeContents) (split suggestionText)
153-
let diff := mkDiffJson edits
191+
let edits := readableDiff rangeContents suggestionText
192+
let diffJson := mkDiffJson edits
154193
let json := json% {
155-
diff: $diff,
194+
diff: $diffJson,
156195
suggestion: $suggestionText,
157196
range: $lspRange
158197
}
@@ -167,15 +206,28 @@ def Suggestions.toHintMessage (suggestions : Suggestions) : CoreM MessageData :=
167206
let suggestionMsg := if suggestions.size == 1 then
168207
MessageData.ofFormat "\n" ++ m!"{widgetMsg}"
169208
else
170-
MessageData.ofFormat "\n" ++ m!"• {widgetMsg}"
209+
MessageData.ofFormat "\n" ++ MessageData.nest 2 m!"• {widgetMsg}"
171210
msg := msg ++ MessageData.nestD suggestionMsg
172211
return msg
173212

174213
/--
175-
Creates a hint message from `hint` with associated code action(s) given by `suggestions`.
214+
Creates a hint message with associated code action suggestions.
176215
177216
To provide a hint without an associated code action, use `MessageData.hint'`.
217+
218+
The arguments are as follows:
219+
* `hint`: the main message of the hint, which precedes its code action suggestions.
220+
* `suggestions`: the suggestions to display.
221+
* `ref?`: if specified, the syntax location for the code action suggestions; otherwise, default to
222+
the syntax reference in the monadic state. Will be overridden by the `span?` field on any
223+
suggestions that specify it.
224+
* `codeActionPrefix?`: if specified, text to display in place of "Try this: " in the code action
225+
label
178226
-/
179-
def _root_.Lean.MessageData.hint (hint : MessageData) (suggestions : Suggestions)
227+
def _root_.Lean.MessageData.hint (hint : MessageData)
228+
(suggestions : Array Suggestion) (ref? : Option Syntax := none)
229+
(codeActionPrefix? : Option String := none)
180230
: CoreM MessageData := do
181-
return .tagged `hint (.ofFormat "\n\nHint: " ++ hint ++ (← suggestions.toHintMessage))
231+
let ref := ref?.getD (← getRef)
232+
let suggs ← mkSuggestionsMessage suggestions ref codeActionPrefix?
233+
return .tagged `hint (m!"\n\nHint: " ++ hint ++ suggs)

src/Lean/Util.lean

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Lean.Util.CollectFVars
88
import Lean.Util.CollectLevelParams
99
import Lean.Util.CollectMVars
1010
import Lean.Util.CollectLevelMVars
11+
import Lean.Util.EditDistance
1112
import Lean.Util.FindMVar
1213
import Lean.Util.FindLevelMVar
1314
import Lean.Util.MonadCache

src/Lean/Util/Diff.lean

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ inductive Action where
2020
| delete
2121
/-- Leave the item in the source -/
2222
| skip
23-
deriving Repr, BEq, Hashable, Repr
23+
deriving Repr, BEq, Hashable, Inhabited
2424

2525
instance : ToString Action where
2626
toString

src/Lean/Util/EditDistance.lean

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/-
2+
Copyright (c) 2025 Lean FRO, LLC. All rights reserved.
3+
Released under Apache 2.0 license as described in the file LICENSE.
4+
Authors: David Thrane Christiansen
5+
-/
6+
7+
set_option linter.missingDocs true
8+
9+
namespace Lean.EditDistance
10+
11+
/--
12+
Compute the Levenshtein distance between two strings, up to some cutoff.
13+
14+
If the return value is `none`, then the distance is certainly greater than the cutoff value, but a
15+
returned `some` does not necessarily indicate that the edit distance is less than or equal to the
16+
cutoff.
17+
-/
18+
def levenshtein (str1 str2 : String) (cutoff : Nat) : Option Nat := Id.run do
19+
let len1 := str1.length
20+
let len2 := str2.length
21+
22+
-- The lower bound on the Levenshtein distance is the difference in lengths
23+
if max len1 len2 - min len1 len2 > cutoff then return none
24+
25+
let mut v0 := Array.replicate (len2 + 1) 0
26+
let mut v1 := v0
27+
28+
for i in [0:v0.size] do
29+
v0 := v0.set! i i
30+
let mut iter1 := str1.iter
31+
for i in [0:str1.length] do
32+
v1 := v1.set! 0 (i+1)
33+
let mut iter2 := str2.iter
34+
for j in [0:len2] do
35+
let deletionCost := v0[j+1]! + 1
36+
let insertionCost := v1[j]! + 1
37+
let substCost :=
38+
if iter1.curr == iter2.curr then v0[j]!
39+
else v0[j]! + 1
40+
let cost := min (min deletionCost insertionCost) substCost
41+
v1 := v1.set! (j+1) cost
42+
iter2 := iter2.next
43+
iter1 := iter1.next
44+
-- Terminate early if it's impossible that the result is below the cutoff
45+
if v1.all (· > cutoff) then return none
46+
v0 := v1
47+
some v0[len2]!

0 commit comments

Comments
 (0)