Skip to content

Commit 0b62957

Browse files
authored
fix: CodeAction converters (#777)
This PR ensures completeness of `patchConverters` - all conversion functions that touch Lean-specific data are now patched. Fixes an issue with snippet edits [reported on Zulip](https://leanprover.zulipchat.com/#narrow/channel/270676-lean4/topic/Snippets.20in.20code.20actions.3F). Also adds a `prebuild` step that ensures we don't bump `vscode-languageclient` without first fixing up the converters.
1 parent 01a7fc9 commit 0b62957

2 files changed

Lines changed: 90 additions & 42 deletions

File tree

vscode-lean4/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1843,6 +1843,7 @@
18431843
"main": "./dist/extension",
18441844
"scripts": {
18451845
"vscode:prepublish": "webpack --env production",
1846+
"prebuild": "node -e \"if (require('vscode-languageclient/package.json').version !== '9.0.1') { console.error('To update vscode-languageclient, fix converters.ts and then require the new version here.'); process.exit(1) }\"",
18461847
"build": "webpack --env production",
18471848
"build:dev": "webpack --env development",
18481849
"watch": "webpack --env development --watch",
@@ -1862,7 +1863,7 @@
18621863
"markdown-it": "^14.1.0",
18631864
"markdown-it-anchor": "^9.0.1",
18641865
"semver": "^7.6.0",
1865-
"vscode-languageclient": "^9.0.1",
1866+
"vscode-languageclient": "9.0.1",
18661867
"zod": "^3.22.4"
18671868
},
18681869
"devDependencies": {

vscode-lean4/src/utils/converters.ts

Lines changed: 88 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,20 @@ export function setDependencyBuildMode(
9595
export const p2cConverter = createP2CConverter(undefined, true, true)
9696
export const c2pConverter = createC2PConverter(undefined)
9797

98+
/** Patch the given converters to support Lean-specific extensions to LSP datatypes.
99+
*
100+
* Patches need to be updated when bumping vscode-languageclient. */
98101
export function patchConverters(p2cConverter: Protocol2CodeConverter, c2pConverter: Code2ProtocolConverter) {
102+
// Although converters are objects,
103+
// their methods refer to other methods by closure-captured local binding rather than via `this`,
104+
// so it doesn't suffice to patch a method: we must also patch any callers.
105+
// For example, we patch `asDiagnostics` to invoke our version of `asDiagnostic`.
106+
99107
// eslint-disable-next-line @typescript-eslint/unbound-method
100108
const oldP2cAsDiagnostic = p2cConverter.asDiagnostic
101-
p2cConverter.asDiagnostic = function (protDiag: LeanDiagnostic): code.Diagnostic {
109+
p2cConverter.asDiagnostic = function (
110+
protDiag: LeanDiagnostic,
111+
): code.Diagnostic & { fullRange?: code.Range; isSilent?: boolean; leanTags?: LeanTag[] } {
102112
if (!protDiag.message) {
103113
// Fixes: Notification handler 'textDocument/publishDiagnostics' failed with message: message must be set
104114
protDiag.message = ' '
@@ -109,9 +119,6 @@ export function patchConverters(p2cConverter: Protocol2CodeConverter, c2pConvert
109119
diag.isSilent = protDiag.isSilent
110120
return diag
111121
}
112-
// The original definition refers to `asDiagnostic` as a local function
113-
// rather than as a member of `Protocol2CodeConverter`,
114-
// so we need to overwrite it, too.
115122
p2cConverter.asDiagnostics = async (diags, token) => async.map(diags, d => p2cConverter.asDiagnostic(d), token)
116123

117124
// eslint-disable-next-line @typescript-eslint/unbound-method
@@ -126,20 +133,18 @@ export function patchConverters(p2cConverter: Protocol2CodeConverter, c2pConvert
126133
return protDiag
127134
}
128135
c2pConverter.asDiagnostics = async (diags, token) => async.map(diags, d => c2pConverter.asDiagnostic(d), token)
136+
c2pConverter.asDiagnosticsSync = diags => diags.map(d => c2pConverter.asDiagnostic(d))
129137

130138
// eslint-disable-next-line @typescript-eslint/unbound-method
131139
const oldC2pAsOpenTextDocumentParams = c2pConverter.asOpenTextDocumentParams
132-
c2pConverter.asOpenTextDocumentParams = doc => {
140+
c2pConverter.asOpenTextDocumentParams = function (doc) {
133141
const params = oldC2pAsOpenTextDocumentParams.apply(this, [doc])
134142
return setDependencyBuildMode(params, 'never')
135143
}
136144

137145
// eslint-disable-next-line @typescript-eslint/unbound-method
138146
const oldP2CAsWorkspaceEdit = p2cConverter.asWorkspaceEdit
139-
p2cConverter.asWorkspaceEdit = async function (
140-
item: ls.WorkspaceEdit | null | undefined,
141-
token?: code.CancellationToken,
142-
) {
147+
p2cConverter.asWorkspaceEdit = async function (item, token) {
143148
if (item === undefined || item === null) return undefined
144149
if (item.documentChanges) {
145150
// 1. Preprocess `documentChanges` by filtering out snippet edits
@@ -151,52 +156,94 @@ export function patchConverters(p2cConverter: Protocol2CodeConverter, c2pConvert
151156
// so users cannot rely on it;
152157
// but a mix of both doesn't seem to work in VSCode anyway as of 1.84.2.
153158
const snippetChanges: [code.Uri, code.SnippetTextEdit[]][] = []
154-
const documentChanges = await async.map(
155-
item.documentChanges,
156-
change => {
157-
if (!ls.TextDocumentEdit.is(change)) return true
158-
const uri = code.Uri.parse(change.textDocument.uri)
159-
const snippetEdits: code.SnippetTextEdit[] = []
160-
const edits = change.edits.filter(edit => {
161-
if (!SnippetTextEdit.is(edit)) return true
162-
const range = p2cConverter.asRange(edit.range)
163-
snippetEdits.push(
164-
new code.SnippetTextEdit(range, new code.SnippetString(edit.leanExtSnippet.value)),
165-
)
166-
return false
167-
})
168-
snippetChanges.push([uri, snippetEdits])
169-
return { ...change, edits }
170-
},
171-
token,
172-
)
159+
const documentChanges: (ls.TextDocumentEdit | ls.CreateFile | ls.RenameFile | ls.DeleteFile)[] =
160+
await async.map(
161+
item.documentChanges,
162+
change => {
163+
if (!ls.TextDocumentEdit.is(change)) return change
164+
const uri = code.Uri.parse(change.textDocument.uri)
165+
const snippetEdits: code.SnippetTextEdit[] = []
166+
const edits = change.edits.filter(edit => {
167+
if (!SnippetTextEdit.is(edit)) return true
168+
const range = p2cConverter.asRange(edit.range)
169+
snippetEdits.push(
170+
new code.SnippetTextEdit(range, new code.SnippetString(edit.leanExtSnippet.value)),
171+
)
172+
return false
173+
})
174+
snippetChanges.push([uri, snippetEdits])
175+
return { ...change, edits }
176+
},
177+
token,
178+
)
173179
const newItem = { ...item, documentChanges }
174180
const result: code.WorkspaceEdit = await oldP2CAsWorkspaceEdit.apply(this, [newItem, token])
181+
// Despite the name and docstring,
182+
// `WorkspaceEdit.set` appends `snippetEdits` rather than replacing what's already there.
175183
for (const [uri, snippetEdits] of snippetChanges) result.set(uri, snippetEdits)
176184
return result
177185
}
178186
return oldP2CAsWorkspaceEdit.apply(this, [item, token])
179187
}
180188

189+
// Note: as of vscode-languageclient 9.0.1, there is no c2pConverter.asWorkspaceEdit.
190+
// This is possibly because code.WorkspaceEdit supports features
191+
// that cannot be encoded in ls.WorkspaceEdit.
192+
181193
// eslint-disable-next-line @typescript-eslint/unbound-method
182194
const oldP2cAsCodeAction = p2cConverter.asCodeAction
183-
p2cConverter.asCodeAction = async function (
184-
item: ls.CodeAction | null | undefined,
185-
token?: code.CancellationToken,
186-
) {
195+
p2cConverter.asCodeAction = async function (item, token) {
187196
if (item === undefined || item === null) return undefined
188-
if (item.edit || item.diagnostics) {
189-
const result: code.CodeAction = await oldP2cAsCodeAction.apply(this, [item, token])
190-
if (item.diagnostics !== undefined)
191-
result.diagnostics = await p2cConverter.asDiagnostics(item.diagnostics, token)
192-
if (item.edit) result.edit = await p2cConverter.asWorkspaceEdit(item.edit, token)
197+
const result: code.CodeAction = await oldP2cAsCodeAction.apply(this, [item, token])
198+
if (item.diagnostics !== undefined)
199+
// Call our modified asDiagnostics, defined above. Upstream calls `asDiagnosticsSync`.
200+
result.diagnostics = await p2cConverter.asDiagnostics(item.diagnostics, token)
201+
if (item.edit !== undefined) result.edit = await p2cConverter.asWorkspaceEdit(item.edit, token)
202+
return result as any /* tsc incompleteness */
203+
}
204+
p2cConverter.asCodeActionResult = async (items, token) =>
205+
async.mapAsync(
206+
items,
207+
async item => (ls.Command.is(item) ? p2cConverter.asCommand(item) : p2cConverter.asCodeAction(item, token)),
208+
token,
209+
)
210+
211+
// eslint-disable-next-line @typescript-eslint/unbound-method
212+
const oldC2pAsCodeAction = c2pConverter.asCodeAction
213+
c2pConverter.asCodeAction = async function (item, token) {
214+
const result: ls.CodeAction = await oldC2pAsCodeAction.apply(this, [item, token])
215+
if (item.diagnostics !== undefined)
216+
result.diagnostics = await c2pConverter.asDiagnostics(item.diagnostics, token)
217+
return result
218+
}
219+
220+
// eslint-disable-next-line @typescript-eslint/unbound-method
221+
const oldC2pAsCodeActionSync = c2pConverter.asCodeActionSync
222+
c2pConverter.asCodeActionSync = function (item) {
223+
const result: ls.CodeAction = oldC2pAsCodeActionSync.apply(this, [item])
224+
if (item.diagnostics !== undefined) result.diagnostics = c2pConverter.asDiagnosticsSync(item.diagnostics)
225+
return result
226+
}
227+
228+
// eslint-disable-next-line @typescript-eslint/unbound-method
229+
const oldC2pAsCodeActionContext = c2pConverter.asCodeActionContext
230+
c2pConverter.asCodeActionContext = async function (context, token) {
231+
const result = await oldC2pAsCodeActionContext.apply(this, [context, token])
232+
if (context.diagnostics !== undefined) {
233+
result.diagnostics = await c2pConverter.asDiagnostics(context.diagnostics as code.Diagnostic[], token)
193234
}
194-
return oldP2cAsCodeAction.apply(this, [item, token])
235+
return result
195236
}
196237

197-
// Note: as of 2023-12-10, there is no c2pConverter.asWorkspaceEdit.
198-
// This is possibly because code.WorkspaceEdit supports features
199-
// that cannot be encoded in ls.WorkspaceEdit.
238+
// eslint-disable-next-line @typescript-eslint/unbound-method
239+
const oldC2pAsCodeActionContextSync = c2pConverter.asCodeActionContextSync
240+
c2pConverter.asCodeActionContextSync = function (context) {
241+
const result = oldC2pAsCodeActionContextSync.apply(this, [context])
242+
if (context.diagnostics !== undefined) {
243+
result.diagnostics = c2pConverter.asDiagnosticsSync(context.diagnostics as code.Diagnostic[])
244+
}
245+
return result
246+
}
200247
}
201248

202249
patchConverters(p2cConverter, c2pConverter)

0 commit comments

Comments
 (0)