diff --git a/.gitignore b/.gitignore index 2a70c90..a28e37c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ coverage/ node_modules/ +/test/.testremarkrc.json *.log *.d.ts *.tgz diff --git a/lib/index.js b/lib/index.js index d9adda5..ffaec60 100644 --- a/lib/index.js +++ b/lib/index.js @@ -24,8 +24,18 @@ * 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. + * @property {string} configurationSection + * This option will be used to give the client a hint of which configuration + * section to use. + * For example VSCode extensions use this to pick only settings that use this + * as a prefix in order to prevent conflicts and reduce the amount of data + * sent to the language server. * * @typedef {EngineFields & LanguageServerFields} Options + * + * @typedef UnifiedLanguageServerSettings + * @property {boolean} [requireConfig=false] + * If true, files will only be checked if a configuration file is present. */ import path from 'node:path' @@ -42,6 +52,7 @@ import { CodeActionKind, Diagnostic, DiagnosticSeverity, + DidChangeConfigurationNotification, Position, ProposedFeatures, Range, @@ -122,6 +133,7 @@ function lspDocumentToVfile(document, cwd) { * Configuration for `unified-engine` and the language server. */ export function createUnifiedLanguageServer({ + configurationSection, ignoreName, packageField, pluginPrefix, @@ -135,14 +147,49 @@ export function createUnifiedLanguageServer({ const documents = new TextDocuments(TextDocument) /** @type {Set} */ const workspaces = new Set() + /** @type {UnifiedLanguageServerSettings} */ + const globalSettings = {requireConfig: false} + /** @type {Map>} */ + const documentSettings = new Map() let hasWorkspaceFolderCapability = false + let hasConfigurationCapability = false + + /** + * @param {string} scopeUri + * @returns {Promise} + */ + async function getDocumentSettings(scopeUri) { + if (!hasConfigurationCapability) { + return globalSettings + } + + let result = documentSettings.get(scopeUri) + if (!result) { + result = connection.workspace + .getConfiguration({scopeUri, section: configurationSection}) + .then( + /** @param {Record} raw */ + (raw) => ({requireConfig: Boolean(raw.requireConfig)}) + ) + documentSettings.set(scopeUri, result) + } + + return result + } /** * @param {string} cwd * @param {VFile[]} files * @param {boolean} alwaysStringify + * @param {boolean} ignoreUnconfigured + * @returns {Promise} */ - async function processWorkspace(cwd, files, alwaysStringify) { + async function processWorkspace( + cwd, + files, + alwaysStringify, + ignoreUnconfigured + ) { /** @type {EngineOptions['processor']} */ let processor @@ -190,6 +237,7 @@ export function createUnifiedLanguageServer({ cwd, files, ignoreName, + ignoreUnconfigured, packageField, pluginPrefix, plugins, @@ -233,6 +281,8 @@ export function createUnifiedLanguageServer({ .sort((a, b) => b.length - a.length) /** @type {Map>} */ const workspacePathToFiles = new Map() + /** @type {Map>} */ + const workspacePathToFilesRequireConfig = new Map() await Promise.all( textDocuments.map(async (textDocument) => { @@ -269,10 +319,16 @@ export function createUnifiedLanguageServer({ if (!cwd) return + const configuration = await getDocumentSettings(textDocument.uri) + const file = lspDocumentToVfile(textDocument, cwd) - const files = workspacePathToFiles.get(cwd) || [] - workspacePathToFiles.set(cwd, [...files, file]) + const filesMap = configuration.requireConfig + ? workspacePathToFilesRequireConfig + : workspacePathToFiles + const files = filesMap.get(cwd) || [] + files.push(file) + filesMap.set(cwd, files) }) ) @@ -280,7 +336,11 @@ export function createUnifiedLanguageServer({ const promises = [] for (const [cwd, files] of workspacePathToFiles) { - promises.push(processWorkspace(cwd, files, alwaysStringify)) + promises.push(processWorkspace(cwd, files, alwaysStringify, false)) + } + + for (const [cwd, files] of workspacePathToFilesRequireConfig) { + promises.push(processWorkspace(cwd, files, alwaysStringify, true)) } const listsOfFiles = await Promise.all(promises) @@ -324,6 +384,9 @@ export function createUnifiedLanguageServer({ workspaces.add(event.rootUri) } + hasConfigurationCapability = Boolean( + event.capabilities.workspace && event.capabilities.workspace.configuration + ) hasWorkspaceFolderCapability = Boolean( event.capabilities.workspace && event.capabilities.workspace.workspaceFolders @@ -345,6 +408,10 @@ export function createUnifiedLanguageServer({ }) connection.onInitialized(() => { + if (hasConfigurationCapability) { + connection.client.register(DidChangeConfigurationNotification.type) + } + if (hasWorkspaceFolderCapability) { connection.workspace.onDidChangeWorkspaceFolders((event) => { for (const workspace of event.removed) { @@ -399,6 +466,7 @@ export function createUnifiedLanguageServer({ version, diagnostics: [] }) + documentSettings.delete(uri) }) // Check everything again if the file system watched by the client changes. @@ -406,6 +474,22 @@ export function createUnifiedLanguageServer({ checkDocuments(...documents.all()) }) + connection.onDidChangeConfiguration((change) => { + if (hasConfigurationCapability) { + // Reset all cached document settings + documentSettings.clear() + } else { + globalSettings.requireConfig = Boolean( + /** @type {Omit & { settings: Record }} */ ( + change + ).settings.requireConfig + ) + } + + // Revalidate all open text documents + checkDocuments(...documents.all()) + }) + connection.onCodeAction((event) => { /** @type {CodeAction[]} */ const codeActions = [] diff --git a/readme.md b/readme.md index d622955..1cedc59 100644 --- a/readme.md +++ b/readme.md @@ -21,6 +21,7 @@ Create a **[language server][]** based on **[unified][]** ecosystems. * [Examples](#examples) * [Types](#types) * [Language Server features](#language-server-features) + * [Configuration](#configuration) * [Compatibility](#compatibility) * [Related](#related) * [Contribute](#contribute) @@ -205,6 +206,11 @@ server features: Any messages collected are published to the client using `textDocument/publishDiagnostics`. +### Configuration + +* `requireConfig` (default: `false`) + — If true, files will only be checked if a configuration file is present. + ## Compatibility Projects maintained by the unified collective are compatible with all maintained diff --git a/test/code-actions.js b/test/code-actions.js index 63f1aa9..b240c53 100644 --- a/test/code-actions.js +++ b/test/code-actions.js @@ -1,6 +1,7 @@ import {createUnifiedLanguageServer} from 'unified-language-server' createUnifiedLanguageServer({ + configurationSection: 'remark', processorName: 'remark', processorSpecifier: 'remark', plugins: [warn] diff --git a/test/folder/remark-with-cwd.js b/test/folder/remark-with-cwd.js index 087feda..295f720 100644 --- a/test/folder/remark-with-cwd.js +++ b/test/folder/remark-with-cwd.js @@ -1,6 +1,7 @@ import {createUnifiedLanguageServer} from 'unified-language-server' createUnifiedLanguageServer({ + configurationSection: 'remark', processorName: 'remark', processorSpecifier: 'remark', plugins: [warn] diff --git a/test/index.js b/test/index.js index feb5453..2fec7b2 100644 --- a/test/index.js +++ b/test/index.js @@ -1,5 +1,7 @@ /** + * @typedef {import('vscode-languageserver').ConfigurationParams} ConfigurationParams * @typedef {import('vscode-languageserver').ProtocolConnection} ProtocolConnection + * @typedef {import('../lib/index.js').UnifiedLanguageServerSettings} UnifiedLanguageServerSettings */ import assert from 'node:assert/strict' @@ -11,23 +13,31 @@ import {fileURLToPath} from 'node:url' import { createProtocolConnection, CodeActionRequest, + ConfigurationRequest, + DidChangeConfigurationNotification, DidChangeWorkspaceFoldersNotification, + DidChangeWatchedFilesNotification, DidCloseTextDocumentNotification, DidOpenTextDocumentNotification, DocumentFormattingRequest, LogMessageNotification, + InitializedNotification, InitializeRequest, IPCMessageReader, IPCMessageWriter, PublishDiagnosticsNotification, + RegistrationRequest, ShowMessageRequest } from 'vscode-languageserver/node.js' /** @type {ProtocolConnection} */ let connection +const testremarkrcPath = new URL('.testremarkrc.json', import.meta.url) +afterEach(() => fs.rm(testremarkrcPath, {force: true})) + afterEach(() => { - connection.dispose() + connection?.dispose() }) test('`initialize`', async () => { @@ -183,6 +193,159 @@ test('`textDocument/didOpen`, `textDocument/didClose` (and diagnostics)', async ) }) +test('workspace configuration `requireConfig`', async () => { + startLanguageServer('remark-with-warnings.js') + + await connection.sendRequest(InitializeRequest.type, { + processId: null, + rootUri: null, + capabilities: { + workspace: {configuration: true} + }, + workspaceFolders: null + }) + await new Promise((resolve) => { + connection.onRequest(RegistrationRequest.type, resolve) + connection.sendNotification(InitializedNotification.type, {}) + }) + + /** @type {ConfigurationParams | undefined} */ + let configRequest + let requireConfig = false + connection.onRequest(ConfigurationRequest.type, (request) => { + configRequest = request + return [{requireConfig}] + }) + const uri = new URL('lsp.md', import.meta.url).href + + const openDiagnosticsPromise = createOnNotificationPromise( + PublishDiagnosticsNotification.type + ) + connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: {uri, languageId: 'markdown', version: 1, text: '# hi'} + }) + const openDiagnostics = await openDiagnosticsPromise + assert.notEqual( + openDiagnostics.diagnostics.length, + 0, + 'should emit diagnostics on `textDocument/didOpen`' + ) + assert.deepEqual( + configRequest, + {items: [{scopeUri: uri, section: 'remark'}]}, + 'should request configurations for the open file' + ) + + configRequest = undefined + const cachedOpenDiagnosticsPromise = createOnNotificationPromise( + PublishDiagnosticsNotification.type + ) + connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: {uri, languageId: 'markdown', version: 1, text: '# hi'} + }) + await cachedOpenDiagnosticsPromise + assert.equal( + configRequest, + undefined, + 'should cache workspace configurations' + ) + + const closeDiagnosticsPromise = createOnNotificationPromise( + PublishDiagnosticsNotification.type + ) + connection.sendNotification(DidCloseTextDocumentNotification.type, { + textDocument: {uri} + }) + await closeDiagnosticsPromise + const reopenDiagnosticsPromise = createOnNotificationPromise( + PublishDiagnosticsNotification.type + ) + connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: {uri, languageId: 'markdown', version: 1, text: '# hi'} + }) + await reopenDiagnosticsPromise + assert.deepEqual( + configRequest, + {items: [{scopeUri: uri, section: 'remark'}]}, + 'should clear the cache if the file is opened' + ) + + configRequest = undefined + const changeConfigurationDiagnosticsPromise = createOnNotificationPromise( + PublishDiagnosticsNotification.type + ) + requireConfig = true + connection.sendNotification(DidChangeConfigurationNotification.type, { + settings: {} + }) + const changeConfigurationDiagnostics = + await changeConfigurationDiagnosticsPromise + assert.deepEqual( + configRequest, + {items: [{scopeUri: uri, section: 'remark'}]}, + 'should clear the cache if the configuration changed' + ) + assert.deepEqual( + {uri, version: 1, diagnostics: []}, + changeConfigurationDiagnostics, + 'should not emit diagnostics if requireConfig is false' + ) +}) + +test('global configuration `requireConfig`', async () => { + startLanguageServer('remark-with-warnings.js') + + await connection.sendRequest(InitializeRequest.type, { + processId: null, + rootUri: null, + capabilities: {}, + workspaceFolders: null + }) + + const uri = new URL('lsp.md', import.meta.url).href + + const openDiagnosticsPromise = createOnNotificationPromise( + PublishDiagnosticsNotification.type + ) + connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: {uri, languageId: 'markdown', version: 1, text: '# hi'} + }) + const openDiagnostics = await openDiagnosticsPromise + assert.notEqual( + openDiagnostics.diagnostics.length, + 0, + 'should emit diagnostics on `textDocument/didOpen`' + ) + + const changeConfigurationDiagnosticsPromise = createOnNotificationPromise( + PublishDiagnosticsNotification.type + ) + connection.sendNotification(DidChangeConfigurationNotification.type, { + settings: {requireConfig: true} + }) + const changeConfigurationDiagnostics = + await changeConfigurationDiagnosticsPromise + assert.deepEqual( + {uri, version: 1, diagnostics: []}, + changeConfigurationDiagnostics, + 'should emit empty diagnostics if requireConfig is true without config' + ) + + await fs.writeFile(testremarkrcPath, '{}\n') + const watchedFileDiagnosticsPromise = createOnNotificationPromise( + PublishDiagnosticsNotification.type + ) + connection.sendNotification(DidChangeWatchedFilesNotification.type, { + changes: [] + }) + const watchedFileDiagnostics = await watchedFileDiagnosticsPromise + assert.equal( + 0, + watchedFileDiagnostics.diagnostics.length, + 'should emit diagnostics if requireConfig is true with config' + ) +}) + test('uninstalled processor so `window/showMessageRequest`', async () => { startLanguageServer('missing-package.js') diff --git a/test/missing-package-with-default.js b/test/missing-package-with-default.js index e5d752b..ae144a6 100644 --- a/test/missing-package-with-default.js +++ b/test/missing-package-with-default.js @@ -2,6 +2,7 @@ import {remark} from 'remark' import {createUnifiedLanguageServer} from 'unified-language-server' createUnifiedLanguageServer({ + configurationSection: 'xxx-missing-yyy', processorName: 'xxx-missing-yyy', // @ts-expect-error This will be ok when we update to remark 15. defaultProcessor: remark diff --git a/test/missing-package.js b/test/missing-package.js index 520e9ee..7572d70 100644 --- a/test/missing-package.js +++ b/test/missing-package.js @@ -1,5 +1,6 @@ import {createUnifiedLanguageServer} from 'unified-language-server' createUnifiedLanguageServer({ + configurationSection: 'xxx-missing-yyy', processorName: 'xxx-missing-yyy' }) diff --git a/test/remark-with-cwd.js b/test/remark-with-cwd.js index 087feda..295f720 100644 --- a/test/remark-with-cwd.js +++ b/test/remark-with-cwd.js @@ -1,6 +1,7 @@ import {createUnifiedLanguageServer} from 'unified-language-server' createUnifiedLanguageServer({ + configurationSection: 'remark', processorName: 'remark', processorSpecifier: 'remark', plugins: [warn] diff --git a/test/remark-with-error.js b/test/remark-with-error.js index f1a606e..7d3e927 100644 --- a/test/remark-with-error.js +++ b/test/remark-with-error.js @@ -1,6 +1,7 @@ import {createUnifiedLanguageServer} from 'unified-language-server' createUnifiedLanguageServer({ + configurationSection: 'remark', processorName: 'remark', processorSpecifier: 'remark', // This is resolved from the directory containing package.json diff --git a/test/remark-with-warnings.js b/test/remark-with-warnings.js index fd72a0d..88e3f28 100644 --- a/test/remark-with-warnings.js +++ b/test/remark-with-warnings.js @@ -1,8 +1,10 @@ import {createUnifiedLanguageServer} from 'unified-language-server' createUnifiedLanguageServer({ + configurationSection: 'remark', processorName: 'remark', processorSpecifier: 'remark', + rcName: 'testremark', // This is resolved from the directory containing package.json plugins: ['./test/lots-of-warnings.js'] }) diff --git a/test/remark.js b/test/remark.js index 1673edb..7cf5acf 100644 --- a/test/remark.js +++ b/test/remark.js @@ -1,6 +1,7 @@ import {createUnifiedLanguageServer} from 'unified-language-server' createUnifiedLanguageServer({ + configurationSection: 'remark', processorName: 'remark', processorSpecifier: 'remark' })