From c777812f7cb52fa82af100109487382743ad3498 Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Thu, 30 Dec 2021 11:43:09 +0100 Subject: [PATCH] Add support for VS Code Quick Fixes Closes GH-17. Reviewed-by: Titus Wormer --- lib/index.js | 67 ++++++++++++++++++++- readme.md | 5 ++ test/index.js | 139 +++++++++++++++++++++++++++++++++++++++++++- test/test-plugin.js | 5 ++ 4 files changed, 214 insertions(+), 2 deletions(-) diff --git a/lib/index.js b/lib/index.js index 0a101d4..b27f5ee 100644 --- a/lib/index.js +++ b/lib/index.js @@ -17,6 +17,8 @@ import {engine} from 'unified-engine' import {VFile} from 'vfile' import { createConnection, + CodeAction, + CodeActionKind, Diagnostic, DiagnosticSeverity, Position, @@ -102,6 +104,12 @@ function vfileMessageToDiagnostic(message) { diagnostic.codeDescription = {href: message.url} } + if (message.expected) { + diagnostic.data = { + expected: message.expected + } + } + return diagnostic } @@ -199,7 +207,11 @@ export function configureUnifiedLanguageServer( connection.onInitialize(() => ({ capabilities: { textDocumentSync: TextDocumentSyncKind.Full, - documentFormattingProvider: true + documentFormattingProvider: true, + codeActionProvider: { + codeActionKinds: [CodeActionKind.QuickFix], + resolveProvider: true + } } })) @@ -238,6 +250,59 @@ export function configureUnifiedLanguageServer( connection.onDidChangeWatchedFiles(() => { checkDocuments(...documents.all()) }) + + connection.onCodeAction((event) => { + /** @type {CodeAction[]} */ + const codeActions = [] + + const document = documents.get(event.textDocument.uri) + if (!document) { + return + } + + const text = document.getText() + + for (const diagnostic of event.context.diagnostics) { + const {data} = diagnostic + if (typeof data !== 'object' || !data) { + continue + } + + const {expected} = /** @type {{expected?: string[]}} */ (data) + + if (!Array.isArray(expected)) { + continue + } + + const {end, start} = diagnostic.range + const actual = text.slice( + document.offsetAt(start), + document.offsetAt(end) + ) + + for (const replacement of expected) { + codeActions.push( + CodeAction.create( + replacement + ? start.line === end.line && start.character === end.character + ? 'Insert `' + replacement + '`' + : 'Replace `' + actual + '` with `' + replacement + '`' + : 'Remove `' + actual + '`', + { + changes: { + [document.uri]: [ + TextEdit.replace(diagnostic.range, replacement) + ] + } + }, + CodeActionKind.QuickFix + ) + ) + } + } + + return codeActions + }) } /** diff --git a/readme.md b/readme.md index 0e94ee2..24571f9 100644 --- a/readme.md +++ b/readme.md @@ -154,6 +154,11 @@ options. Language servers created using this package implement the following language server features: +* `textDocument/codeAction` + — The language server implements code actions based on the `expected` field + on reported messages. + A code action can either insert, replace, or delete text based on the range + of the message and the expected value. * `textDocument/didChange` — when a document is changed by the client, the language server processes it using a unified pipeline. diff --git a/test/index.js b/test/index.js index 8b0feac..153d8e8 100644 --- a/test/index.js +++ b/test/index.js @@ -4,6 +4,7 @@ import {spy, stub} from 'sinon' import test from 'tape' import * as exports from 'unified-language-server' import { + CodeActionKind, DiagnosticSeverity, Position, Range, @@ -34,6 +35,7 @@ function createMockConnection() { onInitialize: spy(), onDidChangeConfiguration: spy(), onDidChangeWatchedFiles: spy(), + onCodeAction: spy(), onDocumentFormatting: spy(), sendDiagnostics: stub() } @@ -83,7 +85,11 @@ test('onInitialize', (t) => { t.deepEquals(result, { capabilities: { textDocumentSync: TextDocumentSyncKind.Full, - documentFormattingProvider: true + documentFormattingProvider: true, + codeActionProvider: { + codeActionKinds: [CodeActionKind.QuickFix], + resolveProvider: true + } } }) @@ -354,6 +360,26 @@ test('onDidChangeContent has error', async (t) => { t.end() }) +test('onDidChangeContent expected', async (t) => { + const uri = String(pathToFileURL('test.md')) + const diagnostics = await getDiagnostic(uri, 'expected') + + t.deepEquals(diagnostics, { + uri, + version: 0, + diagnostics: [ + { + data: {expected: ['suggestion']}, + range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, + message: 'expected', + severity: DiagnosticSeverity.Warning + } + ] + }) + + t.end() +}) + test('onDidClose', async (t) => { const connection = createMockConnection() const documents = new TextDocuments(TextDocument) @@ -454,6 +480,117 @@ test('onDidChangeWatchedFiles', async (t) => { t.end() }) +test('onCodeAction not found', async (t) => { + const connection = createMockConnection() + const documents = new TextDocuments(TextDocument) + + configureUnifiedLanguageServer(connection, documents, { + plugins: ['./test/test-plugin.js'] + }) + + const onCodeAction = /** @type import('sinon').SinonSpy */ ( + connection.onCodeAction + ) + const codeActions = onCodeAction.firstCall.firstArg({ + textDocument: {uri: 'file:///non-existent.txt'} + }) + + t.equals(codeActions, undefined) +}) + +test('onCodeAction diagnostics', async (t) => { + const connection = createMockConnection() + const documents = new TextDocuments(TextDocument) + const uri = String(pathToFileURL('test.txt')) + + Object.defineProperty(documents, 'get', { + value: () => TextDocument.create(uri, 'text', 0, 'invalid') + }) + + configureUnifiedLanguageServer(connection, documents, { + plugins: ['./test/test-plugin.js'] + }) + + const onCodeAction = /** @type import('sinon').SinonSpy */ ( + connection.onCodeAction + ) + const codeActions = onCodeAction.firstCall.firstArg({ + textDocument: {uri}, + context: { + diagnostics: [ + {}, + {data: null}, + { + data: {expected: ['text to insert']}, + range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}} + }, + { + data: {expected: ['replacement text']}, + range: {start: {line: 0, character: 0}, end: {line: 0, character: 7}} + }, + { + data: {expected: ['']}, + range: {start: {line: 0, character: 0}, end: {line: 0, character: 7}} + } + ] + } + }) + + t.deepEquals(codeActions, [ + { + title: 'Insert `text to insert`', + kind: CodeActionKind.QuickFix, + edit: { + changes: { + [uri]: [ + { + newText: 'text to insert', + range: { + start: {line: 0, character: 0}, + end: {line: 0, character: 0} + } + } + ] + } + } + }, + { + title: 'Replace `invalid` with `replacement text`', + kind: CodeActionKind.QuickFix, + edit: { + changes: { + [uri]: [ + { + newText: 'replacement text', + range: { + start: {line: 0, character: 0}, + end: {line: 0, character: 7} + } + } + ] + } + } + }, + { + title: 'Remove `invalid`', + kind: CodeActionKind.QuickFix, + edit: { + changes: { + [uri]: [ + { + newText: '', + range: { + start: {line: 0, character: 0}, + end: {line: 0, character: 7} + } + } + ] + } + } + } + ]) +}) + test('exports', (t) => { t.equal(exports.createUnifiedLanguageServer, createUnifiedLanguageServer) diff --git a/test/test-plugin.js b/test/test-plugin.js index 89c0cda..e5b8972 100644 --- a/test/test-plugin.js +++ b/test/test-plugin.js @@ -56,6 +56,11 @@ export default function unifiedTestPlugin() { message.url = 'https://example.com' } + if (value.includes('expected')) { + const message = file.message('expected') + message.expected = ['suggestion'] + } + if (value.includes('has error')) { throw new Error('Test error') }