Skip to content

Commit

Permalink
Add support for VS Code Quick Fixes
Browse files Browse the repository at this point in the history
Closes GH-17.

Reviewed-by: Titus Wormer <[email protected]>
  • Loading branch information
remcohaszing authored Dec 30, 2021
1 parent e767805 commit c777812
Show file tree
Hide file tree
Showing 4 changed files with 214 additions and 2 deletions.
67 changes: 66 additions & 1 deletion lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {engine} from 'unified-engine'
import {VFile} from 'vfile'
import {
createConnection,
CodeAction,
CodeActionKind,
Diagnostic,
DiagnosticSeverity,
Position,
Expand Down Expand Up @@ -102,6 +104,12 @@ function vfileMessageToDiagnostic(message) {
diagnostic.codeDescription = {href: message.url}
}

if (message.expected) {
diagnostic.data = {
expected: message.expected
}
}

return diagnostic
}

Expand Down Expand Up @@ -199,7 +207,11 @@ export function configureUnifiedLanguageServer(
connection.onInitialize(() => ({
capabilities: {
textDocumentSync: TextDocumentSyncKind.Full,
documentFormattingProvider: true
documentFormattingProvider: true,
codeActionProvider: {
codeActionKinds: [CodeActionKind.QuickFix],
resolveProvider: true
}
}
}))

Expand Down Expand Up @@ -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
})
}

/**
Expand Down
5 changes: 5 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
139 changes: 138 additions & 1 deletion test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -34,6 +35,7 @@ function createMockConnection() {
onInitialize: spy(),
onDidChangeConfiguration: spy(),
onDidChangeWatchedFiles: spy(),
onCodeAction: spy(),
onDocumentFormatting: spy(),
sendDiagnostics: stub()
}
Expand Down Expand Up @@ -83,7 +85,11 @@ test('onInitialize', (t) => {
t.deepEquals(result, {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Full,
documentFormattingProvider: true
documentFormattingProvider: true,
codeActionProvider: {
codeActionKinds: [CodeActionKind.QuickFix],
resolveProvider: true
}
}
})

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
5 changes: 5 additions & 0 deletions test/test-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
Expand Down

0 comments on commit c777812

Please sign in to comment.