diff --git a/lib/index.js b/lib/index.js index 539253e..a230ed5 100644 --- a/lib/index.js +++ b/lib/index.js @@ -31,9 +31,10 @@ * @typedef {EngineFields & LanguageServerFields} Options */ -import {PassThrough} from 'node:stream' +import path from 'node:path' import process from 'node:process' -import {URL, pathToFileURL} from 'node:url' +import {PassThrough} from 'node:stream' +import {URL, pathToFileURL, fileURLToPath} from 'node:url' import {loadPlugin} from 'load-plugin' import {engine} from 'unified-engine' @@ -137,12 +138,7 @@ function vfileMessageToDiagnostic(message) { * @returns {VFile} */ function lspDocumentToVfile(document) { - return new VFile({ - // VFile expects a file path or file URL object, but LSP provides a file URI - // as a string. - path: new URL(document.uri), - value: document.getText() - }) + return new VFile({path: new URL(document.uri), value: document.getText()}) } /** @@ -164,6 +160,9 @@ export function configureUnifiedLanguageServer( rcName } ) { + /** @type {Set} */ + const workspaces = new Set() + /** * Process various LSP text documents using unified and send back the * resulting messages as diagnostics. @@ -173,76 +172,119 @@ export function configureUnifiedLanguageServer( * @returns {Promise} */ async function processDocuments(textDocuments, alwaysStringify = false) { - /** @type {EngineOptions['processor']} */ - let processor - - try { - // @ts-expect-error: assume we load a unified processor. - processor = await loadPlugin(processorName, { - cwd: process.cwd(), - key: processorSpecifier - }) - } catch (error) { - const exception = /** @type {NodeJS.ErrnoException} */ (error) - - // Pass other funky errors through. - /* c8 ignore next 3 */ - if (exception.code !== 'ERR_MODULE_NOT_FOUND') { - throw error - } - - if (!defaultProcessor) { - connection.window.showInformationMessage( - 'Cannot turn on language server without `' + - processorName + - '` locally. Run `npm install ' + - processorName + - '` to enable it' - ) - return [] - } - - const problem = new Error( - 'Cannot find `' + - processorName + - '` locally but using `defaultProcessor`, original error:\n' + - exception.stack - ) + // LSP uses `file:` URLs (hrefs), `unified-engine` expects a paths. + // `process.cwd()` does not add a final slash, but `file:` URLs often do. + const workspacesAsPaths = [...workspaces].map((d) => + fileURLToPath(d.replace(/\/$/, '')) + ) + /** @type {Map>} */ + const workspacePathToFiles = new Map() - connection.console.log(String(problem)) + if (workspacesAsPaths.length === 0) { + workspacesAsPaths.push(process.cwd()) + } - processor = defaultProcessor + for (const textDocument of textDocuments) { + const file = lspDocumentToVfile(textDocument) + const [cwd] = workspacesAsPaths + // Every workspace that includes the document. + .filter((d) => file.path.slice(0, d.length + 1) === d + path.sep) + // Sort the longest (closest to the file) first. + .sort((a, b) => b.length - a.length) + + // This presumably should not occur: a file outside a workspace. + // So ignore the file. + /* c8 ignore next */ + if (!cwd) continue + + const files = workspacePathToFiles.get(cwd) || [] + workspacePathToFiles.set(cwd, [...files, file]) } - return new Promise((resolve, reject) => { - engine( - { - alwaysStringify, - files: textDocuments.map((document) => lspDocumentToVfile(document)), - ignoreName, - packageField, - pluginPrefix, - plugins, - processor, - quiet: false, - rcName, - silentlyIgnore: true, - streamError: new PassThrough(), - streamOut: new PassThrough() - }, - (error, _, context) => { - // An error never occur and can’t be reproduced. Thus us ab internal - // error in unified-engine. If a plugin throws, it’s reported as a - // vfile message. - /* c8 ignore start */ - if (error) { - reject(error) - } else { - resolve((context && context.files) || []) + /** @type {Array>>} */ + const promises = [] + + for (const [cwd, files] of workspacePathToFiles) { + promises.push( + (async function () { + /** @type {EngineOptions['processor']} */ + let processor + + try { + // @ts-expect-error: assume we load a unified processor. + processor = await loadPlugin(processorName, { + cwd, + key: processorSpecifier + }) + } catch (error) { + const exception = /** @type {NodeJS.ErrnoException} */ (error) + + // Pass other funky errors through. + /* c8 ignore next 3 */ + if (exception.code !== 'ERR_MODULE_NOT_FOUND') { + throw error + } + + if (!defaultProcessor) { + connection.window.showInformationMessage( + 'Cannot turn on language server without `' + + processorName + + '` locally. Run `npm install ' + + processorName + + '` to enable it' + ) + return [] + } + + connection.console.log( + 'Cannot find `' + + processorName + + '` locally but using `defaultProcessor`, original error:\n' + + exception.stack + ) + + processor = defaultProcessor } - } + + return new Promise((resolve, reject) => { + engine( + { + alwaysStringify, + cwd, + files, + ignoreName, + packageField, + pluginPrefix, + plugins, + processor, + quiet: false, + rcName, + silentlyIgnore: true, + streamError: new PassThrough(), + streamOut: new PassThrough() + }, + (error, _, context) => { + // An error never occured and can’t be reproduced. This is an internal + // error in unified-engine. If a plugin throws, it’s reported as a + // vfile message. + /* c8 ignore start */ + if (error) { + reject(error) + } else { + resolve((context && context.files) || []) + } + } + ) + }) + })() + /* c8 ignore stop */ ) - }) + } + + const listsOfFiles = await Promise.all(promises) + // XXX [engine:node@>16] V8 coverage bug on Dubnium (Node 12). + /* c8 ignore next 3 */ + return listsOfFiles.flat() } /* c8 ignore stop */ @@ -272,16 +314,48 @@ export function configureUnifiedLanguageServer( } } - connection.onInitialize(() => ({ - capabilities: { - textDocumentSync: TextDocumentSyncKind.Full, - documentFormattingProvider: true, - codeActionProvider: { - codeActionKinds: [CodeActionKind.QuickFix], - resolveProvider: true + connection.onInitialize((event) => { + if (event.workspaceFolders) { + for (const workspace of event.workspaceFolders) { + workspaces.add(workspace.uri) } } - })) + + if (workspaces.size === 0 && event.rootUri) { + workspaces.add(event.rootUri) + } + + if ( + event.capabilities.workspace && + event.capabilities.workspace.workspaceFolders + ) { + connection.workspace.onDidChangeWorkspaceFolders(function (event) { + for (const workspace of event.removed) { + workspaces.delete(workspace.uri) + } + + for (const workspace of event.added) { + workspaces.add(workspace.uri) + } + + checkDocuments(...documents.all()) + }) + } + + return { + capabilities: { + textDocumentSync: TextDocumentSyncKind.Full, + documentFormattingProvider: true, + codeActionProvider: { + codeActionKinds: [CodeActionKind.QuickFix], + resolveProvider: true + }, + workspace: { + workspaceFolders: {supported: true, changeNotifications: true} + } + } + } + }) connection.onDocumentFormatting(async (event) => { const document = documents.get(event.textDocument.uri) diff --git a/package.json b/package.json index 6879fca..0396b64 100644 --- a/package.json +++ b/package.json @@ -77,5 +77,10 @@ "plugins": [ "remark-preset-wooorm" ] + }, + "typeCoverage": { + "atLeast": 100, + "detail": true, + "strict": true } } diff --git a/readme.md b/readme.md index 8663eba..6e1d4af 100644 --- a/readme.md +++ b/readme.md @@ -177,7 +177,7 @@ 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 + — 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. @@ -199,9 +199,9 @@ server features: — when document formatting is requested by the client, the language server processes it using a unified pipeline. The stringified result is returned. -* `workspace/didChangeWatchedFiles` - — When the client signals a watched file has changed, the language server - processes all open files using a unified pipeline. +* `workspace/didChangeWatchedFiles` and `workspace/didChangeWorkspaceFolders` + — when the client signals a watched file or workspace has changed, the + language server processes all open files using a unified pipeline. Any messages collected are published to the client using `textDocument/publishDiagnostics`. @@ -213,7 +213,8 @@ As of now, that is Node.js 12.20+, 14.14+, and 16.0+. Our projects sometimes work with older versions, but this is not guaranteed. This project uses [`vscode-languageserver`][vscode-languageserver] 7, which -implements language server protocol 3.16. +implements language server protocol 3.16.0. +It should work anywhere where LSP 3.6.0 or later is implemented. ## Related diff --git a/test/folder/remark-with-cwd.js b/test/folder/remark-with-cwd.js new file mode 100644 index 0000000..1403c1e --- /dev/null +++ b/test/folder/remark-with-cwd.js @@ -0,0 +1,14 @@ +import {createUnifiedLanguageServer} from '../../index.js' + +createUnifiedLanguageServer({ + processorName: 'remark', + processorSpecifier: 'remark', + plugins: [warn] +}) + +/** @type {import('unified').Plugin>} */ +function warn() { + return (_, file) => { + file.message(file.cwd) + } +} diff --git a/test/index.js b/test/index.js index 031af00..2f21f05 100644 --- a/test/index.js +++ b/test/index.js @@ -69,6 +69,9 @@ test('`initialize`', async (t) => { codeActionProvider: { codeActionKinds: ['quickfix'], resolveProvider: true + }, + workspace: { + workspaceFolders: {supported: true, changeNotifications: true} } } }, @@ -339,7 +342,7 @@ test('uninstalled processor w/ `defaultProcessor`', async (t) => { /(imported from )[^\r\n]+/, '$1zzz' ), - "Error: Cannot find `xxx-missing-yyy` locally but using `defaultProcessor`, original error:\nError [ERR_MODULE_NOT_FOUND]: Cannot find package 'xxx-missing-yyy' imported from zzz", + "Cannot find `xxx-missing-yyy` locally but using `defaultProcessor`, original error:\nError [ERR_MODULE_NOT_FOUND]: Cannot find package 'xxx-missing-yyy' imported from zzz", 'should work w/ `defaultProcessor`' ) } @@ -751,6 +754,9 @@ test('`textDocument/codeAction` (and diagnostics)', async (t) => { codeActionProvider: { codeActionKinds: ['quickfix'], resolveProvider: true + }, + workspace: { + workspaceFolders: {supported: true, changeNotifications: true} } } } @@ -825,6 +831,314 @@ test('`textDocument/codeAction` (and diagnostics)', async (t) => { t.end() }) +test('`initialize` w/ nothing', async (t) => { + const stdin = new PassThrough() + const cwd = new URL('.', import.meta.url) + const promise = execa('node', ['remark-with-cwd.js', '--stdio'], { + cwd: fileURLToPath(cwd), + input: stdin, + timeout + }) + + stdin.write( + toMessage({ + method: 'initialize', + id: 0, + /** @type {import('vscode-languageserver').InitializeParams} */ + params: { + processId: null, + rootUri: null, + capabilities: {}, + workspaceFolders: null + } + }) + ) + + await sleep(delay) + + stdin.write( + toMessage({ + method: 'textDocument/didOpen', + /** @type {import('vscode-languageserver').DidOpenTextDocumentParams} */ + params: { + textDocument: { + uri: new URL('lsp.md', import.meta.url).href, + languageId: 'markdown', + version: 1, + text: '# hi' + } + } + }) + ) + + await sleep(delay) + + assert(promise.stdout) + promise.stdout.on('data', () => setImmediate(() => stdin.end())) + + try { + await promise + t.fail('should reject') + } catch (error) { + const exception = /** @type {ExecError} */ (error) + const messages = fromMessages(exception.stdout) + t.equal(messages.length, 2, 'should emit messages') + const parameters = + /** @type {import('vscode-languageserver').PublishDiagnosticsParams} */ ( + messages[1].params + ) + const info = parameters.diagnostics[0] + t.ok(info, 'should emit the cwd') + t.deepEqual( + info.message, + fileURLToPath(cwd).slice(0, -1), + 'should default to a `cwd` of `process.cwd()`' + ) + } + + t.end() +}) + +test('`initialize` w/ `rootUri`', async (t) => { + const stdin = new PassThrough() + const cwd = new URL('./folder/', import.meta.url) + const processCwd = new URL('..', cwd) + const promise = execa('node', ['folder/remark-with-cwd.js', '--stdio'], { + cwd: fileURLToPath(processCwd), + input: stdin, + timeout + }) + + stdin.write( + toMessage({ + method: 'initialize', + id: 0, + /** @type {import('vscode-languageserver').InitializeParams} */ + params: { + processId: null, + rootUri: cwd.href, + capabilities: {}, + workspaceFolders: [] + } + }) + ) + + await sleep(delay) + + stdin.write( + toMessage({ + method: 'textDocument/didOpen', + /** @type {import('vscode-languageserver').DidOpenTextDocumentParams} */ + params: { + textDocument: { + uri: new URL('lsp.md', cwd).href, + languageId: 'markdown', + version: 1, + text: '# hi' + } + } + }) + ) + + await sleep(delay) + + assert(promise.stdout) + promise.stdout.on('data', () => setImmediate(() => stdin.end())) + + try { + await promise + t.fail('should reject') + } catch (error) { + const exception = /** @type {ExecError} */ (error) + const messages = fromMessages(exception.stdout) + t.equal(messages.length, 2, 'should emit messages') + const parameters = + /** @type {import('vscode-languageserver').PublishDiagnosticsParams} */ ( + messages[1].params + ) + const info = parameters.diagnostics[0] + t.ok(info, 'should emit the cwd') + t.deepEqual( + info.message, + fileURLToPath(cwd).slice(0, -1), + 'should use `rootUri`' + ) + } + + t.end() +}) + +test('`initialize` w/ `workspaceFolders`', async (t) => { + const stdin = new PassThrough() + const processCwd = new URL('.', import.meta.url) + const promise = execa('node', ['remark-with-cwd.js', '--stdio'], { + cwd: fileURLToPath(processCwd), + input: stdin, + timeout + }) + + const otherCwd = new URL('./folder/', processCwd) + + stdin.write( + toMessage({ + method: 'initialize', + id: 0, + /** @type {import('vscode-languageserver').InitializeParams} */ + params: { + processId: null, + rootUri: null, + capabilities: {}, + workspaceFolders: [ + {uri: processCwd.href, name: ''}, // Farthest + {uri: otherCwd.href, name: ''} // Nearest + ] + } + }) + ) + + await sleep(delay) + + stdin.write( + toMessage({ + method: 'textDocument/didOpen', + /** @type {import('vscode-languageserver').DidOpenTextDocumentParams} */ + params: { + textDocument: { + uri: new URL('lsp.md', otherCwd).href, + languageId: 'markdown', + version: 1, + text: '# hi' + } + } + }) + ) + + await sleep(delay) + + assert(promise.stdout) + promise.stdout.on('data', () => setImmediate(() => stdin.end())) + + try { + await promise + t.fail('should reject') + } catch (error) { + const exception = /** @type {ExecError} */ (error) + const messages = fromMessages(exception.stdout) + t.equal(messages.length, 2, 'should emit messages') + const parameters = + /** @type {import('vscode-languageserver').PublishDiagnosticsParams} */ ( + messages[1].params + ) + const info = parameters.diagnostics[0] + t.ok(info, 'should emit the cwd') + t.deepEqual( + info.message, + fileURLToPath(otherCwd).slice(0, -1), + 'should use `workspaceFolders`' + ) + } + + t.end() +}) + +test('`workspace/didChangeWorkspaceFolders`', async (t) => { + const stdin = new PassThrough() + const processCwd = new URL('.', import.meta.url) + const promise = execa('node', ['remark-with-cwd.js', '--stdio'], { + cwd: fileURLToPath(processCwd), + input: stdin, + timeout + }) + + stdin.write( + toMessage({ + method: 'initialize', + id: 0, + /** @type {import('vscode-languageserver').InitializeParams} */ + params: { + processId: null, + rootUri: null, + capabilities: {workspace: {workspaceFolders: true}}, + workspaceFolders: [{uri: processCwd.href, name: ''}] + } + }) + ) + + await sleep(delay) + + const otherCwd = new URL('./folder/', processCwd) + + stdin.write( + toMessage({ + method: 'textDocument/didOpen', + /** @type {import('vscode-languageserver').DidOpenTextDocumentParams} */ + params: { + textDocument: { + uri: new URL('lsp.md', otherCwd).href, + languageId: 'markdown', + version: 1, + text: '# hi' + } + } + }) + ) + + await sleep(delay) + + stdin.write( + toMessage({ + method: 'workspace/didChangeWorkspaceFolders', + /** @type {import('vscode-languageserver').DidChangeWorkspaceFoldersParams} */ + params: {event: {added: [{uri: otherCwd.href, name: ''}], removed: []}} + }) + ) + + await sleep(delay) + + stdin.write( + toMessage({ + method: 'workspace/didChangeWorkspaceFolders', + /** @type {import('vscode-languageserver').DidChangeWorkspaceFoldersParams} */ + params: { + event: {added: [], removed: [{uri: otherCwd.href, name: ''}]} + } + }) + ) + + await sleep(delay) + + assert(promise.stdout) + promise.stdout.on('data', () => setImmediate(() => stdin.end())) + + try { + await promise + t.fail('should reject') + } catch (error) { + const exception = /** @type {ExecError} */ (error) + const messages = fromMessages(exception.stdout) + t.deepEqual( + messages + .filter((d) => d.method === 'textDocument/publishDiagnostics') + .flatMap((d) => { + const parameters = + /** @type {import('vscode-languageserver').PublishDiagnosticsParams} */ ( + d.params + ) + return parameters.diagnostics + }) + .map((d) => d.message), + [ + fileURLToPath(processCwd).slice(0, -1), + fileURLToPath(otherCwd).slice(0, -1), + fileURLToPath(processCwd).slice(0, -1) + ], + 'should support `workspaceFolders`' + ) + } + + t.end() +}) + /** * @param {string} data * @returns {Array>} diff --git a/test/remark-with-cwd.js b/test/remark-with-cwd.js new file mode 100644 index 0000000..cce69d1 --- /dev/null +++ b/test/remark-with-cwd.js @@ -0,0 +1,14 @@ +import {createUnifiedLanguageServer} from '../index.js' + +createUnifiedLanguageServer({ + processorName: 'remark', + processorSpecifier: 'remark', + plugins: [warn] +}) + +/** @type {import('unified').Plugin>} */ +function warn() { + return (_, file) => { + file.message(file.cwd) + } +}