This PR adds an additional diff mode to the error-message hint suggestion widget that displays diffs per word rather than per character.
366 lines
15 KiB
Text
366 lines
15 KiB
Text
/-
|
||
Copyright (c) 2025 Lean FRO, LLC. All rights reserved.
|
||
Released under Apache 2.0 license as described in the file LICENSE.
|
||
Authors: Joseph Rotella
|
||
-/
|
||
|
||
prelude
|
||
|
||
import Lean.CoreM
|
||
import Lean.Data.Lsp.Utf16
|
||
import Lean.Message
|
||
import Lean.Meta.TryThis
|
||
import Lean.Util.Diff
|
||
import Lean.Widget.Types
|
||
import Lean.PrettyPrinter
|
||
|
||
namespace Lean.Meta.Hint
|
||
|
||
open Elab Tactic PrettyPrinter TryThis
|
||
|
||
/--
|
||
A widget for rendering code action suggestions in error messages. Generally, this widget should not
|
||
be used directly; instead, use `MessageData.hint`. Note that this widget is intended only for use
|
||
within message data; it may not display line breaks properly if rendered as a panel widget.
|
||
|
||
The props to this widget are of the following form:
|
||
```json
|
||
{
|
||
"diff": [
|
||
{"type": "unchanged", "text": "h"},
|
||
{"type": "deletion", "text": "ello"},
|
||
{"type": "insertion", "text": "i"}
|
||
],
|
||
"suggestion": "hi",
|
||
"range": {
|
||
"start": {"line": 100, "character": 0},
|
||
"end": {"line": 100, "character": 5}
|
||
}
|
||
}
|
||
```
|
||
|
||
Note: we cannot add the `builtin_widget_module` attribute here because that would require importing
|
||
`Lean.Widget.UserWidget`, which in turn imports much of `Lean.Elab` -- the module where we want to
|
||
be able to use this widget. Instead, we register the attribute post-hoc when we declare the regular
|
||
"Try This" widget in `Lean.Meta.Tactic.TryThis`.
|
||
-/
|
||
def tryThisDiffWidget : Widget.Module where
|
||
javascript := "
|
||
import * as React from 'react';
|
||
import { EditorContext, EnvPosContext } from '@leanprover/infoview';
|
||
const e = React.createElement;
|
||
export default function ({ diff, range, suggestion }) {
|
||
const pos = React.useContext(EnvPosContext)
|
||
const editorConnection = React.useContext(EditorContext)
|
||
const insStyle = { className: 'information' }
|
||
const delStyle = {
|
||
style: { color: 'var(--vscode-errorForeground)', textDecoration: 'line-through' }
|
||
}
|
||
const defStyle = {
|
||
style: { color: 'var(--vscode-textLink-foreground)' }
|
||
}
|
||
function onClick() {
|
||
editorConnection.api.applyEdit({
|
||
changes: { [pos.uri]: [{ range, newText: suggestion }] }
|
||
})
|
||
}
|
||
|
||
const spans = diff.map (comp =>
|
||
comp.type === 'deletion' ? e('span', delStyle, comp.text) :
|
||
comp.type === 'insertion' ? e('span', insStyle, comp.text) :
|
||
e('span', defStyle, comp.text)
|
||
)
|
||
const fullDiff = e('span',
|
||
{ onClick,
|
||
title: 'Apply suggestion',
|
||
className: 'link pointer dim font-code',
|
||
style: { display: 'inline-block', verticalAlign: 'text-top' } },
|
||
spans)
|
||
return fullDiff
|
||
}"
|
||
|
||
/--
|
||
Converts an array of diff actions into corresponding JSON interpretable by `tryThisDiffWidget`.
|
||
-/
|
||
private def mkDiffJson (ds : Array (Diff.Action × String)) :=
|
||
toJson <| ds.map fun
|
||
| (.insert, s) => json% { type: "insertion", text: $s }
|
||
| (.delete, s) => json% { type: "deletion", text: $s }
|
||
| (.skip , s) => json% { type: "unchanged", text: $s }
|
||
|
||
/--
|
||
Converts an array of diff actions into a Unicode string that visually depicts the diff.
|
||
|
||
Note that this function does not return the string that results from applying the diff to some
|
||
input; rather, it returns a string representation of the actions that the diff itself comprises,
|
||
such as `b̵a̵c̲h̲e̲e̲rs̲`.
|
||
-/
|
||
private def mkDiffString (ds : Array (Diff.Action × String)) : String :=
|
||
let rangeStrs := ds.map fun
|
||
| (.insert, s) => String.mk (s.data.flatMap ([·, '\u0332'])) -- U+0332 Combining Low Line
|
||
| (.delete, s) => String.mk (s.data.flatMap ([·, '\u0335'])) -- U+0335 Combining Short Stroke Overlay
|
||
| (.skip , s) => s
|
||
rangeStrs.foldl (· ++ ·) ""
|
||
|
||
/-- The granularity at which to display an inline diff for a suggested edit. -/
|
||
inductive DiffGranularity where
|
||
/-- Automatically select diff granularity based on edit distance. -/
|
||
| auto
|
||
/-- Character-level diff. -/
|
||
| char
|
||
/-- Diff using whitespace-separated tokens. -/
|
||
| word
|
||
/--
|
||
"Monolithic" diff: shows a deletion of the entire existing source, followed by an insertion of the
|
||
entire suggestion.
|
||
-/
|
||
| all
|
||
|
||
/--
|
||
A code action suggestion associated with a hint in a message.
|
||
|
||
Refer to `TryThis.Suggestion`. This extends that structure with the following fields:
|
||
* `span?`: the span at which this suggestion should apply. This allows a single hint to suggest
|
||
modifications at different locations. If `span?` is not specified, then the syntax reference
|
||
provided to `MessageData.hint` will be used.
|
||
* `diffGranularity`: the granularity at which the diff for this suggestion should be rendered in the
|
||
Infoview. See `DiffMode` for the possible granularities. This is `.auto` by default.
|
||
-/
|
||
structure Suggestion extends toTryThisSuggestion : TryThis.Suggestion where
|
||
span? : Option Syntax := none
|
||
diffGranularity : DiffGranularity := .auto
|
||
|
||
instance : Coe TryThis.SuggestionText Suggestion where
|
||
coe t := { suggestion := t }
|
||
|
||
instance : ToMessageData Suggestion where
|
||
toMessageData s := toMessageData s.toTryThisSuggestion
|
||
|
||
/--
|
||
Produces a diff that splits either on characters, tokens, or not at all, depending on the selected
|
||
`diffMode`.
|
||
|
||
Guarantees that all actions in the output will be maximally grouped; that is, instead of returning
|
||
`#[(.insert, "a"), (.insert, "b")]`, it will return `#[(.insert, "ab")]`.
|
||
-/
|
||
partial def readableDiff (s s' : String) (granularity : DiffGranularity := .auto) : Array (Diff.Action × String) :=
|
||
match granularity with
|
||
| .char => charDiff
|
||
| .word => wordDiff
|
||
| .all => maxDiff
|
||
| .auto =>
|
||
let minLength := min s.length s'.length
|
||
-- The coefficients on these values can be tuned:
|
||
let maxCharDiffDistance := minLength / 5
|
||
let maxWordDiffDistance := minLength / 2 + (max s.length s'.length) / 2
|
||
|
||
let charDiffRaw := Diff.diff (splitChars s) (splitChars s')
|
||
-- Note: this is just a rough heuristic, since the diff has no notion of substitution
|
||
let approxEditDistance := charDiffRaw.filter (·.1 != .skip) |>.size
|
||
let charArrDiff := joinEdits charDiffRaw
|
||
|
||
-- Given that `Diff.diff` returns a minimal diff, any length-≤3 diff can only have edits at the
|
||
-- front and back, or at a single interior point. This will always be fairly readable (and
|
||
-- splitting by a larger unit would likely only be worse)
|
||
if charArrDiff.size ≤ 3 || approxEditDistance ≤ maxCharDiffDistance then
|
||
charArrDiff.map fun (act, cs) => (act, String.mk cs.toList)
|
||
else if approxEditDistance ≤ maxWordDiffDistance then
|
||
wordDiff
|
||
else
|
||
maxDiff
|
||
where
|
||
/-
|
||
Note on whitespace insertion:
|
||
Because we display diffs fully inline, we must trade off between accurately rendering changes to
|
||
whitespace and accurately previewing what will be inserted. We err on the side of the latter.
|
||
Within a "run" of deletions or insertions, we maintain the whitespace from the deleted/inserted
|
||
text and mark it as a deletion/insertion. After an unchanged word or a substitution (i.e., a
|
||
deletion and insertion without an intervening unchanged word), we show a whitespace diff iff the
|
||
old whitespace did not contain a line break (as rendering a deleted newline still visually
|
||
suggests a line break in the new output); otherwise, we use the whitespace from the new version
|
||
but mark it as unchanged, since there was also whitespace here originally too. Within a
|
||
substitution, we omit whitespace entirely. After an insertion, we show the new whitespace and mark
|
||
it as an insertion. After a deletion, we render the old whitespace as a deletion unless it
|
||
contains a newline, for the same reason mentioned previously.
|
||
-/
|
||
wordDiff := Id.run do
|
||
let (words, wss) := splitWords s
|
||
let (words', wss') := splitWords s'
|
||
let diff := Diff.diff words words'
|
||
let mut withWs := #[]
|
||
let mut (wssIdx, wss'Idx) := (0, 0)
|
||
let mut inSubst := false
|
||
for h : diffIdx in [:diff.size] do
|
||
let (a₁, s₁) := diff[diffIdx]
|
||
withWs := withWs.push (a₁, s₁)
|
||
if let some (a₂, s₂) := diff[diffIdx + 1]? then
|
||
match a₁, a₂ with
|
||
| .skip, .delete =>
|
||
-- Unchanged word: show whitespace diff unless this is followed by a deleted terminal
|
||
-- substring of the old, in which case show the old whitespace (since there is no new)
|
||
let ws := wss[wssIdx]!
|
||
let wsDiff := if let some ws' := wss'[wss'Idx]? then
|
||
mkWhitespaceDiff ws ws'
|
||
else
|
||
#[(.delete, ws)]
|
||
withWs := withWs ++ wsDiff
|
||
wssIdx := wssIdx + 1
|
||
wss'Idx := wss'Idx + 1
|
||
| .skip, .skip | .skip, .insert =>
|
||
-- Unchanged word: inverse of the above case: new has whitespace here, and old does too so
|
||
-- long as we haven't reached an appended terminal new portion
|
||
let ws' := wss'[wss'Idx]!
|
||
let wsDiff := if let some ws := wss[wssIdx]? then
|
||
mkWhitespaceDiff ws ws'
|
||
else
|
||
#[(.insert, ws')]
|
||
withWs := withWs ++ wsDiff
|
||
wssIdx := wssIdx + 1
|
||
wss'Idx := wss'Idx + 1
|
||
| .insert, .insert =>
|
||
-- Insertion separator: include whitespace, and mark it as inserted
|
||
let ws := wss'[wss'Idx]!
|
||
withWs := withWs.push (.insert, ws)
|
||
wss'Idx := wss'Idx + 1
|
||
| .insert, .skip =>
|
||
-- End of insertion: if this was a substitution, new and old have whitespace here; if it
|
||
-- wasn't, only new has whitespace here
|
||
let ws' := wss'[wss'Idx]!
|
||
let wsDiff := if inSubst then
|
||
mkWhitespaceDiff wss[wssIdx]! ws'
|
||
else
|
||
#[(.insert, ws')]
|
||
withWs := withWs ++ wsDiff
|
||
wss'Idx := wss'Idx + 1
|
||
if inSubst then wssIdx := wssIdx + 1
|
||
inSubst := false
|
||
| .delete, .delete =>
|
||
-- Deletion separator: include and mark as deleted
|
||
let ws := wss[wssIdx]!
|
||
withWs := withWs.push (.delete, ws)
|
||
wssIdx := wssIdx + 1
|
||
| .delete, .skip =>
|
||
-- End of deletion: include the deletion's whitespace as deleted iff it is not a newline
|
||
-- (see earlier note); in principle, we should never have a substitution ending with a
|
||
-- deletion (`diff` should prefer `a̵b̲` to `b̲a̵`), but we handle this in case `diff` changes
|
||
let ws := wss[wssIdx]!
|
||
unless inSubst || ws.contains '\n' do
|
||
withWs := withWs.push (.delete, ws)
|
||
wssIdx := wssIdx + 1
|
||
if inSubst then wss'Idx := wss'Idx + 1
|
||
inSubst := false
|
||
| .insert, .delete | .delete, .insert =>
|
||
-- "Substitution point": don't include any whitespace, since we're switching this word
|
||
inSubst := true
|
||
withWs
|
||
|> joinEdits
|
||
|>.map fun (act, ss) => (act, ss.foldl (· ++ ·) "")
|
||
|
||
charDiff :=
|
||
Diff.diff (splitChars s) (splitChars s') |> joinCharDiff
|
||
|
||
/-- Given a `Char` diff, produces an equivalent `String` diff, joining actions of the same kind. -/
|
||
joinCharDiff (d : Array (Diff.Action × Char)) :=
|
||
joinEdits d |>.map fun (act, cs) => (act, String.mk cs.toList)
|
||
|
||
maxDiff :=
|
||
#[(.delete, s), (.insert, s')]
|
||
|
||
mkWhitespaceDiff (oldWs newWs : String) :=
|
||
if !oldWs.contains '\n' then
|
||
Diff.diff oldWs.data.toArray newWs.data.toArray |> joinCharDiff
|
||
else
|
||
#[(.skip, newWs)]
|
||
|
||
splitChars (s : String) : Array Char :=
|
||
s.toList.toArray
|
||
|
||
splitWords (s : String) : Array String × Array String :=
|
||
splitWordsAux s 0 0 #[] #[]
|
||
|
||
splitWordsAux (s : String) (b : String.Pos) (i : String.Pos) (r ws : Array String) : Array String × Array String :=
|
||
if h : s.atEnd i then
|
||
(r.push (s.extract b i), ws)
|
||
else
|
||
have := Nat.sub_lt_sub_left (Nat.gt_of_not_le (mt decide_eq_true h)) (String.lt_next s _)
|
||
if (s.get i).isWhitespace then
|
||
let skipped := (Substring.mk s i s.endPos).takeWhile (·.isWhitespace)
|
||
let i' := skipped.stopPos
|
||
splitWordsAux s i' i' (r.push (s.extract b i)) (ws.push (s.extract i i'))
|
||
else
|
||
splitWordsAux s b (s.next i) r ws
|
||
|
||
joinEdits {α} (ds : Array (Diff.Action × α)) : Array (Diff.Action × Array α) :=
|
||
ds.foldl (init := #[]) fun acc (act, c) =>
|
||
if h : acc.isEmpty then
|
||
#[(act, #[c])]
|
||
else
|
||
have : acc.size - 1 < acc.size := Nat.sub_one_lt <| mt Array.size_eq_zero_iff.mp <|
|
||
Array.isEmpty_eq_false_iff.mp (Bool.of_not_eq_true h)
|
||
let (act', cs) := acc[acc.size - 1]
|
||
if act == act' then
|
||
acc.set (acc.size - 1) (act, cs.push c)
|
||
else
|
||
acc.push (act, #[c])
|
||
|
||
/--
|
||
Creates message data corresponding to a `HintSuggestions` collection and adds the corresponding info
|
||
leaf.
|
||
-/
|
||
def mkSuggestionsMessage (suggestions : Array Suggestion)
|
||
(ref : Syntax)
|
||
(codeActionPrefix? : Option String) : CoreM MessageData := do
|
||
let mut msg := m!""
|
||
for suggestion in suggestions do
|
||
if let some range := (suggestion.span?.getD ref).getRange? then
|
||
let { info, suggestions := suggestionArr, range := lspRange } ←
|
||
processSuggestions ref range #[suggestion.toTryThisSuggestion] codeActionPrefix?
|
||
pushInfoLeaf info
|
||
-- The following access is safe because
|
||
-- `suggestionsArr = #[suggestion.toTryThisSuggestion].map ...` (see `processSuggestions`)
|
||
let suggestionText := suggestionArr[0]!.2.1
|
||
let map ← getFileMap
|
||
let rangeContents := Substring.mk map.source range.start range.stop |>.toString
|
||
let edits := readableDiff rangeContents suggestionText suggestion.diffGranularity
|
||
let diffJson := mkDiffJson edits
|
||
let json := json% {
|
||
diff: $diffJson,
|
||
suggestion: $suggestionText,
|
||
range: $lspRange
|
||
}
|
||
let preInfo := suggestion.preInfo?.getD ""
|
||
let postInfo := suggestion.postInfo?.getD ""
|
||
let widget := MessageData.ofWidget {
|
||
id := ``tryThisDiffWidget
|
||
javascriptHash := tryThisDiffWidget.javascriptHash
|
||
props := return json
|
||
} (suggestion.messageData?.getD (mkDiffString edits))
|
||
let widgetMsg := m!"{preInfo}{widget}{postInfo}"
|
||
let suggestionMsg := if suggestions.size == 1 then
|
||
m!"\n{widgetMsg}"
|
||
else
|
||
m!"\n" ++ MessageData.nest 2 m!"• {widgetMsg}"
|
||
msg := msg ++ MessageData.nestD suggestionMsg
|
||
return msg
|
||
|
||
/--
|
||
Creates a hint message with associated code action suggestions.
|
||
|
||
To provide a hint without an associated code action, use `MessageData.hint'`.
|
||
|
||
The arguments are as follows:
|
||
* `hint`: the main message of the hint, which precedes its code action suggestions.
|
||
* `suggestions`: the suggestions to display.
|
||
* `ref?`: if specified, the syntax location for the code action suggestions; otherwise, default to
|
||
the syntax reference in the monadic state. Will be overridden by the `span?` field on any
|
||
suggestions that specify it.
|
||
* `codeActionPrefix?`: if specified, text to display in place of "Try this: " in the code action
|
||
label
|
||
-/
|
||
def _root_.Lean.MessageData.hint (hint : MessageData)
|
||
(suggestions : Array Suggestion) (ref? : Option Syntax := none)
|
||
(codeActionPrefix? : Option String := none)
|
||
: CoreM MessageData := do
|
||
let ref := ref?.getD (← getRef)
|
||
let suggs ← mkSuggestionsMessage suggestions ref codeActionPrefix?
|
||
return .tagged `hint (m!"\n\nHint: " ++ hint ++ suggs)
|