Skip to content

Commit

Permalink
Add support for loading local processors
Browse files Browse the repository at this point in the history
*   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 <[email protected]>

Closes GH-23.
Related to: remarkjs/remark-language-server#3.
  • Loading branch information
wooorm committed Jan 8, 2022
1 parent f222a65 commit 711e692
Show file tree
Hide file tree
Showing 9 changed files with 266 additions and 21 deletions.
78 changes: 70 additions & 8 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,39 @@
* @typedef {import('unist').Position} UnistPosition
* @typedef {import('vfile-message').VFileMessage} VFileMessage
* @typedef {import('vscode-languageserver').Connection} Connection
* @typedef {Partial<Pick<
* import('unified-engine').Options,
* @typedef {import('unified-engine').Options} EngineOptions
* @typedef {Pick<
* EngineOptions,
* | 'ignoreName'
* | 'packageField'
* | 'pluginPrefix'
* | 'plugins'
* | 'processor'
* | 'rcName'
* >>} 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 {
Expand Down Expand Up @@ -140,7 +158,9 @@ export function configureUnifiedLanguageServer(
packageField,
pluginPrefix,
plugins,
processor = unified(),
processorName,
processorSpecifier = 'default',
defaultProcessor,
rcName
}
) {
Expand All @@ -152,7 +172,48 @@ export function configureUnifiedLanguageServer(
* @param {boolean} alwaysStringify
* @returns {Promise<VFile[]>}
*/
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(
{
Expand All @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
39 changes: 30 additions & 9 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,10 @@ createUnifiedLanguageServer({
ignoreName: '.remarkignore',
packageField: 'remarkConfig',
pluginPrefix: 'remark',
processor: remark,
rcName: '.remarkrc'
rcName: '.remarkrc',
processorName: 'remark',
processorSpecifier: 'remark',
defaultProcessor: remark
})
```

Expand All @@ -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

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

Expand Down
7 changes: 7 additions & 0 deletions test/missing-package-with-default.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import {remark} from 'remark'
import {createUnifiedLanguageServer} from '../index.js'

createUnifiedLanguageServer({
processorName: 'xxx-missing-yyy',
defaultProcessor: remark
})
5 changes: 5 additions & 0 deletions test/missing-package.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {createUnifiedLanguageServer} from '../index.js'

createUnifiedLanguageServer({
processorName: 'xxx-missing-yyy'
})
4 changes: 3 additions & 1 deletion test/remark-with-error.js
Original file line number Diff line number Diff line change
@@ -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']
})
4 changes: 3 additions & 1 deletion test/remark-with-warnings.js
Original file line number Diff line number Diff line change
@@ -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']
})
5 changes: 4 additions & 1 deletion test/remark.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import {createUnifiedLanguageServer} from '../index.js'

createUnifiedLanguageServer({plugins: ['remark-parse', 'remark-stringify']})
createUnifiedLanguageServer({
processorName: 'remark',
processorSpecifier: 'remark'
})

0 comments on commit 711e692

Please sign in to comment.