From 711e69216f0f22b1d4de48e66392de3f60bafdcc Mon Sep 17 00:00:00 2001 From: Titus Date: Sat, 8 Jan 2022 18:56:01 +0100 Subject: [PATCH] Add support for loading local processors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * This adds support for loading a local processor (such as `rehype`) from the file system (`node_modules/`) in the project * By default, it now shows a warningh notification when that processor can’t be found locally, instead of throwing an error * Optionally, a default processor can be passed when creating the language server, which will be used if a local processor can’t be found, instead of the previously mentioned warning notification Reviewed-by: Remco Haszing Closes GH-23. Related to: remarkjs/remark-language-server#3. --- lib/index.js | 78 +++++++++++++-- package.json | 3 +- readme.md | 39 ++++++-- test/index.js | 142 +++++++++++++++++++++++++++ test/missing-package-with-default.js | 7 ++ test/missing-package.js | 5 + test/remark-with-error.js | 4 +- test/remark-with-warnings.js | 4 +- test/remark.js | 5 +- 9 files changed, 266 insertions(+), 21 deletions(-) create mode 100644 test/missing-package-with-default.js create mode 100644 test/missing-package.js diff --git a/lib/index.js b/lib/index.js index 72e82be..539253e 100644 --- a/lib/index.js +++ b/lib/index.js @@ -3,21 +3,39 @@ * @typedef {import('unist').Position} UnistPosition * @typedef {import('vfile-message').VFileMessage} VFileMessage * @typedef {import('vscode-languageserver').Connection} Connection - * @typedef {Partial>} Options + * >} EngineFields + * + * @typedef LanguageServerFields + * @property {string} processorName + * The package ID of the expected processor (example: `'remark'`). + * Will be loaded from the local workspace. + * @property {string} [processorSpecifier='default'] + * The specifier to get the processor on the resolved module. + * For example, remark uses the specifier `remark` to expose its processor and + * a default export can be requested by passing `'default'` (the default). + * @property {EngineOptions['processor']} [defaultProcessor] + * Optional fallback processor to use if `processorName` can’t be found + * locally in `node_modules`. + * This can be used to ship a processor with your package, to be used if no + * processor is found locally. + * If this isn’t passed, a warning is shown if `processorName` can’t be found. + * + * @typedef {EngineFields & LanguageServerFields} Options */ import {PassThrough} from 'node:stream' +import process from 'node:process' import {URL, pathToFileURL} from 'node:url' -import {unified} from 'unified' +import {loadPlugin} from 'load-plugin' import {engine} from 'unified-engine' import {VFile} from 'vfile' import { @@ -140,7 +158,9 @@ export function configureUnifiedLanguageServer( packageField, pluginPrefix, plugins, - processor = unified(), + processorName, + processorSpecifier = 'default', + defaultProcessor, rcName } ) { @@ -152,7 +172,48 @@ export function configureUnifiedLanguageServer( * @param {boolean} alwaysStringify * @returns {Promise} */ - function processDocuments(textDocuments, alwaysStringify = false) { + 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 + ) + + connection.console.log(String(problem)) + + processor = defaultProcessor + } + return new Promise((resolve, reject) => { engine( { @@ -179,12 +240,13 @@ export function configureUnifiedLanguageServer( } else { resolve((context && context.files) || []) } - /* c8 ignore stop */ } ) }) } + /* c8 ignore stop */ + /** * Process various LSP text documents using unified and send back the * resulting messages as diagnostics. diff --git a/package.json b/package.json index 0a4cc36..6879fca 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ ], "dependencies": { "@types/unist": "^2.0.0", - "unified": "^10.0.0", + "load-plugin": "^4.0.0", "unified-engine": "^9.0.0", "vfile": "^5.0.0", "vfile-message": "^3.0.0", @@ -51,6 +51,7 @@ "tape": "^5.0.0", "type-coverage": "^2.0.0", "typescript": "^4.0.0", + "unified": "^10.0.0", "xo": "^0.47.0" }, "scripts": { diff --git a/readme.md b/readme.md index 913c634..8663eba 100644 --- a/readme.md +++ b/readme.md @@ -93,8 +93,10 @@ createUnifiedLanguageServer({ ignoreName: '.remarkignore', packageField: 'remarkConfig', pluginPrefix: 'remark', - processor: remark, - rcName: '.remarkrc' + rcName: '.remarkrc', + processorName: 'remark', + processorSpecifier: 'remark', + defaultProcessor: remark }) ``` @@ -111,28 +113,47 @@ Create a language server for a unified ecosystem. Configuration for `unified-engine` and the language server. +###### `options.processorName` + +The package ID of the expected processor (`string`, required, example: +`'remark'`). +Will be loaded from the local workspace. + +###### `options.processorSpecifier` + +The specifier to get the processor on the resolved module (`string`, optional, +default: `'default'`). +For example, remark uses the specifier `remark` to expose its processor and +a default export can be requested by passing `'default'` (the default). + +###### `options.defaultProcessor` + +Optional fallback processor to use if `processorName` can’t be found +locally in `node_modules` ([`Unified`][unified], optional). +This can be used to ship a processor with your package, to be used if no +processor is found locally. +If this isn’t passed, a warning is shown if `processorName` can’t be found. + ###### `options.ignoreName` -Name of ignore files to load (`string`, optional) +Name of ignore files to load (`string`, optional). ###### `options.packageField` Property at which configuration can be found in package.json files (`string`, -optional) +optional). ###### `options.pluginPrefix` -Optional prefix to use when searching for plugins (`string`, optional) +Optional prefix to use when searching for plugins (`string`, optional). ###### `options.plugins` -Plugins to use by default (`Array|Object`, optional) - -Typically this contains 2 plugins named `*-parse` and `*-stringify`. +Plugins to use by default (`Array|Object`, optional). ###### `options.rcName` -Name of configuration files to load (`string`, optional) +Name of configuration files to load (`string`, optional). ## Examples diff --git a/test/index.js b/test/index.js index eb3ae07..031af00 100644 --- a/test/index.js +++ b/test/index.js @@ -205,6 +205,148 @@ test('`textDocument/didOpen`, `textDocument/didClose` (and diagnostics)', async t.end() }) +test('uninstalled processor so `window/showMessageRequest`', async (t) => { + const stdin = new PassThrough() + const promise = execa('node', ['missing-package.js', '--stdio'], { + cwd: fileURLToPath(new URL('.', import.meta.url)), + 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 = messages[1].params + + t.deepEqual( + parameters, + { + type: 3, + message: + 'Cannot turn on language server without `xxx-missing-yyy` locally. Run `npm install xxx-missing-yyy` to enable it', + actions: [] + }, + 'should emit a `window/showMessageRequest` when the processor can’t be found locally' + ) + } + + t.end() +}) + +test('uninstalled processor w/ `defaultProcessor`', async (t) => { + const stdin = new PassThrough() + const promise = execa( + 'node', + ['missing-package-with-default.js', '--stdio'], + { + cwd: fileURLToPath(new URL('.', import.meta.url)), + 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, 3, 'should emit messages') + + const parameters = + /** @type {import('vscode-languageserver').LogMessageParams} */ ( + messages[1].params + ) + + t.deepEqual( + cleanStack(parameters.message, 2).replace( + /(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", + 'should work w/ `defaultProcessor`' + ) + } + + t.end() +}) + test('`textDocument/formatting`', async (t) => { const stdin = new PassThrough() diff --git a/test/missing-package-with-default.js b/test/missing-package-with-default.js new file mode 100644 index 0000000..3fd5d91 --- /dev/null +++ b/test/missing-package-with-default.js @@ -0,0 +1,7 @@ +import {remark} from 'remark' +import {createUnifiedLanguageServer} from '../index.js' + +createUnifiedLanguageServer({ + processorName: 'xxx-missing-yyy', + defaultProcessor: remark +}) diff --git a/test/missing-package.js b/test/missing-package.js new file mode 100644 index 0000000..0dd116b --- /dev/null +++ b/test/missing-package.js @@ -0,0 +1,5 @@ +import {createUnifiedLanguageServer} from '../index.js' + +createUnifiedLanguageServer({ + processorName: 'xxx-missing-yyy' +}) diff --git a/test/remark-with-error.js b/test/remark-with-error.js index 2f21a3a..457fe3d 100644 --- a/test/remark-with-error.js +++ b/test/remark-with-error.js @@ -1,5 +1,7 @@ import {createUnifiedLanguageServer} from '../index.js' createUnifiedLanguageServer({ - plugins: ['remark-parse', 'remark-stringify', './one-error.js'] + processorName: 'remark', + processorSpecifier: 'remark', + plugins: ['./one-error.js'] }) diff --git a/test/remark-with-warnings.js b/test/remark-with-warnings.js index 578a00f..4dd8ab1 100644 --- a/test/remark-with-warnings.js +++ b/test/remark-with-warnings.js @@ -1,5 +1,7 @@ import {createUnifiedLanguageServer} from '../index.js' createUnifiedLanguageServer({ - plugins: ['remark-parse', 'remark-stringify', './lots-of-warnings.js'] + processorName: 'remark', + processorSpecifier: 'remark', + plugins: ['./lots-of-warnings.js'] }) diff --git a/test/remark.js b/test/remark.js index 96fef38..0eaee65 100644 --- a/test/remark.js +++ b/test/remark.js @@ -1,3 +1,6 @@ import {createUnifiedLanguageServer} from '../index.js' -createUnifiedLanguageServer({plugins: ['remark-parse', 'remark-stringify']}) +createUnifiedLanguageServer({ + processorName: 'remark', + processorSpecifier: 'remark' +})