Skip to content

Commit

Permalink
Add support for workspaces
Browse files Browse the repository at this point in the history
Closes GH-22.

Reviewed-by: Remco Haszing <[email protected]>
  • Loading branch information
wooorm authored Jan 10, 2022
1 parent 711e692 commit 2859d30
Show file tree
Hide file tree
Showing 6 changed files with 508 additions and 86 deletions.
234 changes: 154 additions & 80 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()})
}

/**
Expand All @@ -164,6 +160,9 @@ export function configureUnifiedLanguageServer(
rcName
}
) {
/** @type {Set<string>} */
const workspaces = new Set()

/**
* Process various LSP text documents using unified and send back the
* resulting messages as diagnostics.
Expand All @@ -173,76 +172,119 @@ export function configureUnifiedLanguageServer(
* @returns {Promise<VFile[]>}
*/
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<string, Array<VFile>>} */
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<Promise<Array<VFile>>>} */
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 */
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,5 +77,10 @@
"plugins": [
"remark-preset-wooorm"
]
},
"typeCoverage": {
"atLeast": 100,
"detail": true,
"strict": true
}
}
11 changes: 6 additions & 5 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`.

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

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

createUnifiedLanguageServer({
processorName: 'remark',
processorSpecifier: 'remark',
plugins: [warn]
})

/** @type {import('unified').Plugin<Array<void>>} */
function warn() {
return (_, file) => {
file.message(file.cwd)
}
}
Loading

0 comments on commit 2859d30

Please sign in to comment.