├── .editorconfig ├── .github └── workflows │ ├── bb.yml │ └── main.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── index.js ├── lib └── index.js ├── license ├── package.json ├── readme.md ├── test ├── code-actions.js ├── folder-with-package-json │ └── package.json ├── folder │ └── remark-with-cwd.js ├── index.js ├── lots-of-warnings.js ├── misconfigured.js ├── missing-package-with-default.js ├── missing-package.js ├── one-error.js ├── remark-with-cwd.js ├── remark-with-error.js ├── remark-with-warnings.js └── remark.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/workflows/bb.yml: -------------------------------------------------------------------------------- 1 | name: bb 2 | on: 3 | issues: 4 | types: [opened, reopened, edited, closed, labeled, unlabeled] 5 | pull_request_target: 6 | types: [opened, reopened, edited, closed, labeled, unlabeled] 7 | jobs: 8 | main: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: unifiedjs/beep-boop-beta@main 12 | with: 13 | repo-token: ${{secrets.GITHUB_TOKEN}} 14 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | on: 3 | - pull_request 4 | - push 5 | jobs: 6 | main: 7 | name: '${{matrix.node}} on ${{matrix.os}}' 8 | runs-on: ${{matrix.os}} 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: ${{matrix.node}} 14 | - run: npm install 15 | - run: npm test 16 | - uses: codecov/codecov-action@v3 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu-latest 21 | - windows-latest 22 | node: 23 | - lts/gallium 24 | - node 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules/ 3 | /test/.testremarkrc.json 4 | *.log 5 | *.d.ts 6 | *.tgz 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.md 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('./lib/index.js').Options} Options 3 | */ 4 | 5 | export {createUnifiedLanguageServer} from './lib/index.js' 6 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('vfile-message').VFileMessage} VFileMessage 3 | * @typedef {import('unified-engine').Options} EngineOptions 4 | * @typedef {Pick< 5 | * EngineOptions, 6 | * | 'ignoreName' 7 | * | 'packageField' 8 | * | 'pluginPrefix' 9 | * | 'plugins' 10 | * | 'rcName' 11 | * >} EngineFields 12 | * 13 | * @typedef LanguageServerFields 14 | * @property {string} processorName 15 | * The package ID of the expected processor (example: `'remark'`). 16 | * Will be loaded from the local workspace. 17 | * @property {string} [processorSpecifier='default'] 18 | * The specifier to get the processor on the resolved module. 19 | * For example, remark uses the specifier `remark` to expose its processor and 20 | * a default export can be requested by passing `'default'` (the default). 21 | * @property {EngineOptions['processor']} [defaultProcessor] 22 | * Optional fallback processor to use if `processorName` can’t be found 23 | * locally in `node_modules`. 24 | * This can be used to ship a processor with your package, to be used if no 25 | * processor is found locally. 26 | * If this isn’t passed, a warning is shown if `processorName` can’t be found. 27 | * @property {string} configurationSection 28 | * This option will be used to give the client a hint of which configuration 29 | * section to use. 30 | * For example VSCode extensions use this to pick only settings that use this 31 | * as a prefix in order to prevent conflicts and reduce the amount of data 32 | * sent to the language server. 33 | * 34 | * @typedef {EngineFields & LanguageServerFields} Options 35 | * 36 | * @typedef UnifiedLanguageServerSettings 37 | * @property {boolean} [requireConfig=false] 38 | * If true, files will only be checked if a configuration file is present. 39 | */ 40 | 41 | import path from 'node:path' 42 | import {PassThrough} from 'node:stream' 43 | import {fileURLToPath, pathToFileURL} from 'node:url' 44 | import {findUp, pathExists} from 'find-up' 45 | import {loadPlugin} from 'load-plugin' 46 | import {engine} from 'unified-engine' 47 | import {fromPoint, fromPosition} from 'unist-util-lsp' 48 | import {VFile} from 'vfile' 49 | import { 50 | createConnection, 51 | CodeAction, 52 | CodeActionKind, 53 | Diagnostic, 54 | DiagnosticSeverity, 55 | DidChangeConfigurationNotification, 56 | Position, 57 | ProposedFeatures, 58 | Range, 59 | TextDocuments, 60 | TextDocumentSyncKind, 61 | TextEdit 62 | } from 'vscode-languageserver/node.js' 63 | import {TextDocument} from 'vscode-languageserver-textdocument' 64 | 65 | /** 66 | * Convert a vfile message to a language server protocol diagnostic. 67 | * 68 | * @param {VFileMessage} message 69 | * @returns {Diagnostic} 70 | */ 71 | function vfileMessageToDiagnostic(message) { 72 | const diagnostic = Diagnostic.create( 73 | message.place 74 | ? 'start' in message.place 75 | ? fromPosition(message.place) 76 | : {start: fromPoint(message.place), end: fromPoint(message.place)} 77 | : Range.create(0, 0, 0, 0), 78 | message.reason, 79 | message.fatal === true 80 | ? DiagnosticSeverity.Error 81 | : message.fatal === false 82 | ? DiagnosticSeverity.Warning 83 | : DiagnosticSeverity.Information, 84 | message.ruleId || undefined, 85 | message.source || undefined 86 | ) 87 | if (message.url) { 88 | diagnostic.codeDescription = {href: message.url} 89 | } 90 | 91 | if (message.expected) { 92 | // type-coverage:ignore-next-line 93 | diagnostic.data = { 94 | expected: message.expected 95 | } 96 | } 97 | 98 | if ( 99 | typeof message.cause === 'object' && 100 | message.cause && 101 | 'stack' in message.cause 102 | ) { 103 | diagnostic.message += '\n' + message.cause.stack 104 | } 105 | 106 | if (message.note) { 107 | diagnostic.message += '\n' + message.note 108 | } 109 | 110 | return diagnostic 111 | } 112 | 113 | /** 114 | * Convert language server protocol text document to a vfile. 115 | * 116 | * @param {TextDocument} document 117 | * @param {string} cwd 118 | * @returns {VFile} 119 | */ 120 | function lspDocumentToVfile(document, cwd) { 121 | return new VFile({ 122 | cwd, 123 | path: new URL(document.uri), 124 | value: document.getText(), 125 | data: {lspDocumentUri: document.uri} 126 | }) 127 | } 128 | 129 | /** 130 | * Create a language server for a unified ecosystem. 131 | * 132 | * @param {Options} options 133 | * Configuration for `unified-engine` and the language server. 134 | */ 135 | export function createUnifiedLanguageServer({ 136 | configurationSection, 137 | ignoreName, 138 | packageField, 139 | pluginPrefix, 140 | plugins, 141 | processorName, 142 | processorSpecifier = 'default', 143 | defaultProcessor, 144 | rcName 145 | }) { 146 | const connection = createConnection(ProposedFeatures.all) 147 | const documents = new TextDocuments(TextDocument) 148 | /** @type {Set} */ 149 | const workspaces = new Set() 150 | /** @type {UnifiedLanguageServerSettings} */ 151 | const globalSettings = {requireConfig: false} 152 | /** @type {Map>} */ 153 | const documentSettings = new Map() 154 | let hasWorkspaceFolderCapability = false 155 | let hasConfigurationCapability = false 156 | 157 | /** 158 | * @param {string} scopeUri 159 | * @returns {Promise} 160 | */ 161 | async function getDocumentSettings(scopeUri) { 162 | if (!hasConfigurationCapability) { 163 | return globalSettings 164 | } 165 | 166 | let result = documentSettings.get(scopeUri) 167 | if (!result) { 168 | result = connection.workspace 169 | .getConfiguration({scopeUri, section: configurationSection}) 170 | .then( 171 | /** @param {Record} raw */ 172 | (raw) => ({requireConfig: Boolean(raw.requireConfig)}) 173 | ) 174 | documentSettings.set(scopeUri, result) 175 | } 176 | 177 | return result 178 | } 179 | 180 | /** 181 | * @param {string} cwd 182 | * @param {VFile[]} files 183 | * @param {boolean} alwaysStringify 184 | * @param {boolean} ignoreUnconfigured 185 | * @returns {Promise} 186 | */ 187 | async function processWorkspace( 188 | cwd, 189 | files, 190 | alwaysStringify, 191 | ignoreUnconfigured 192 | ) { 193 | /** @type {EngineOptions['processor']} */ 194 | let processor 195 | 196 | try { 197 | processor = /** @type {EngineOptions['processor']} */ ( 198 | await loadPlugin(processorName, { 199 | from: pathToFileURL(cwd + '/'), 200 | key: processorSpecifier 201 | }) 202 | ) 203 | } catch (error) { 204 | const exception = /** @type {NodeJS.ErrnoException} */ (error) 205 | 206 | // Pass other funky errors through. 207 | /* c8 ignore next 3 */ 208 | if (exception.code !== 'ERR_MODULE_NOT_FOUND') { 209 | throw error 210 | } 211 | 212 | if (!defaultProcessor) { 213 | connection.window.showInformationMessage( 214 | 'Cannot turn on language server without `' + 215 | processorName + 216 | '` locally. Run `npm install ' + 217 | processorName + 218 | '` to enable it' 219 | ) 220 | return [] 221 | } 222 | 223 | connection.console.log( 224 | 'Cannot find `' + 225 | processorName + 226 | '` locally but using `defaultProcessor`, original error:\n' + 227 | exception.stack 228 | ) 229 | 230 | processor = defaultProcessor 231 | } 232 | 233 | return new Promise((resolve) => { 234 | engine( 235 | { 236 | alwaysStringify, 237 | cwd, 238 | files, 239 | ignoreName, 240 | ignoreUnconfigured, 241 | packageField, 242 | pluginPrefix, 243 | plugins, 244 | processor, 245 | quiet: false, 246 | rcName, 247 | silentlyIgnore: true, 248 | streamError: new PassThrough(), 249 | streamOut: new PassThrough() 250 | }, 251 | (error) => { 252 | // An error never occured and can’t be reproduced. This is an internal 253 | // error in unified-engine. If a plugin throws, it’s reported as a 254 | // vfile message. 255 | if (error) { 256 | for (const file of files) { 257 | file.message(error).fatal = true 258 | } 259 | } 260 | 261 | resolve(files) 262 | } 263 | ) 264 | }) 265 | } 266 | 267 | /** 268 | * Process various LSP text documents using unified and send back the 269 | * resulting messages as diagnostics. 270 | * 271 | * @param {TextDocument[]} textDocuments 272 | * @param {boolean} alwaysStringify 273 | * @returns {Promise} 274 | */ 275 | async function processDocuments(textDocuments, alwaysStringify = false) { 276 | // LSP uses `file:` URLs (hrefs), `unified-engine` expects a paths. 277 | // `process.cwd()` does not add a final slash, but `file:` URLs often do. 278 | const workspacesAsPaths = [...workspaces] 279 | .map((d) => d.replace(/[/\\]?$/, '')) 280 | // Sort the longest (closest to the file) first. 281 | .sort((a, b) => b.length - a.length) 282 | /** @type {Map>} */ 283 | const workspacePathToFiles = new Map() 284 | /** @type {Map>} */ 285 | const workspacePathToFilesRequireConfig = new Map() 286 | 287 | await Promise.all( 288 | textDocuments.map(async (textDocument) => { 289 | /** @type {string | undefined} */ 290 | let cwd 291 | if (workspaces.size === 0) { 292 | cwd = await findUp( 293 | async (directory) => { 294 | const packageExists = await pathExists( 295 | path.join(directory, 'package.json') 296 | ) 297 | if (packageExists) { 298 | return directory 299 | } 300 | 301 | const gitExists = await pathExists(path.join(directory, '.git')) 302 | if (gitExists) { 303 | return directory 304 | } 305 | }, 306 | { 307 | cwd: path.dirname(fileURLToPath(textDocument.uri)), 308 | type: 'directory' 309 | } 310 | ) 311 | } else { 312 | // Because the workspaces are sorted longest to shortest, the first 313 | // match is closest to the file. 314 | const ancestor = workspacesAsPaths.find((d) => 315 | textDocument.uri.startsWith(d + '/') 316 | ) 317 | if (ancestor) { 318 | cwd = fileURLToPath(ancestor) 319 | } 320 | } 321 | 322 | if (!cwd) return 323 | 324 | const configuration = await getDocumentSettings(textDocument.uri) 325 | 326 | const file = lspDocumentToVfile(textDocument, cwd) 327 | 328 | const filesMap = configuration.requireConfig 329 | ? workspacePathToFilesRequireConfig 330 | : workspacePathToFiles 331 | const files = filesMap.get(cwd) || [] 332 | files.push(file) 333 | filesMap.set(cwd, files) 334 | }) 335 | ) 336 | 337 | /** @type {Array>>} */ 338 | const promises = [] 339 | 340 | for (const [cwd, files] of workspacePathToFiles) { 341 | promises.push(processWorkspace(cwd, files, alwaysStringify, false)) 342 | } 343 | 344 | for (const [cwd, files] of workspacePathToFilesRequireConfig) { 345 | promises.push(processWorkspace(cwd, files, alwaysStringify, true)) 346 | } 347 | 348 | const listsOfFiles = await Promise.all(promises) 349 | return listsOfFiles.flat() 350 | } 351 | 352 | /** 353 | * Process various LSP text documents using unified and send back the 354 | * resulting messages as diagnostics. 355 | * 356 | * @param {TextDocument[]} textDocuments 357 | */ 358 | async function checkDocuments(...textDocuments) { 359 | const documentVersions = new Map( 360 | textDocuments.map((document) => [document.uri, document.version]) 361 | ) 362 | const files = await processDocuments(textDocuments) 363 | 364 | for (const file of files) { 365 | // All the vfiles we create have a `lspDocumentUri`. 366 | const uri = /** @type {string} */ (file.data.lspDocumentUri) 367 | 368 | connection.sendDiagnostics({ 369 | uri, 370 | version: documentVersions.get(uri), 371 | diagnostics: file.messages.map((message) => 372 | vfileMessageToDiagnostic(message) 373 | ) 374 | }) 375 | } 376 | } 377 | 378 | connection.onInitialize((event) => { 379 | if (event.workspaceFolders) { 380 | for (const workspace of event.workspaceFolders) { 381 | workspaces.add(workspace.uri) 382 | } 383 | } 384 | 385 | if (workspaces.size === 0 && event.rootUri) { 386 | workspaces.add(event.rootUri) 387 | } 388 | 389 | hasConfigurationCapability = Boolean( 390 | event.capabilities.workspace && event.capabilities.workspace.configuration 391 | ) 392 | hasWorkspaceFolderCapability = Boolean( 393 | event.capabilities.workspace && 394 | event.capabilities.workspace.workspaceFolders 395 | ) 396 | 397 | return { 398 | capabilities: { 399 | textDocumentSync: TextDocumentSyncKind.Full, 400 | documentFormattingProvider: true, 401 | codeActionProvider: { 402 | codeActionKinds: [CodeActionKind.QuickFix], 403 | resolveProvider: true 404 | }, 405 | workspace: hasWorkspaceFolderCapability 406 | ? {workspaceFolders: {supported: true, changeNotifications: true}} 407 | : undefined 408 | } 409 | } 410 | }) 411 | 412 | connection.onInitialized(() => { 413 | if (hasConfigurationCapability) { 414 | connection.client.register(DidChangeConfigurationNotification.type) 415 | } 416 | 417 | if (hasWorkspaceFolderCapability) { 418 | connection.workspace.onDidChangeWorkspaceFolders((event) => { 419 | for (const workspace of event.removed) { 420 | workspaces.delete(workspace.uri) 421 | } 422 | 423 | for (const workspace of event.added) { 424 | workspaces.add(workspace.uri) 425 | } 426 | 427 | checkDocuments(...documents.all()) 428 | }) 429 | } 430 | }) 431 | 432 | connection.onDocumentFormatting(async (event) => { 433 | const document = documents.get(event.textDocument.uri) 434 | 435 | // This might happen if a client calls this function without synchronizing 436 | // the document first. 437 | if (!document) { 438 | return 439 | } 440 | 441 | const [file] = await processDocuments([document], true) 442 | 443 | if (!file) { 444 | return 445 | } 446 | 447 | const result = String(file) 448 | const text = document.getText() 449 | if (result === text) { 450 | return 451 | } 452 | 453 | const start = Position.create(0, 0) 454 | const end = document.positionAt(text.length) 455 | 456 | return [TextEdit.replace(Range.create(start, end), result)] 457 | }) 458 | 459 | documents.onDidChangeContent((event) => { 460 | checkDocuments(event.document) 461 | }) 462 | 463 | // Send empty diagnostics for closed files. 464 | documents.onDidClose((event) => { 465 | const {uri, version} = event.document 466 | connection.sendDiagnostics({ 467 | uri, 468 | version, 469 | diagnostics: [] 470 | }) 471 | documentSettings.delete(uri) 472 | }) 473 | 474 | // Check everything again if the file system watched by the client changes. 475 | connection.onDidChangeWatchedFiles(() => { 476 | checkDocuments(...documents.all()) 477 | }) 478 | 479 | connection.onDidChangeConfiguration((change) => { 480 | if (hasConfigurationCapability) { 481 | // Reset all cached document settings 482 | documentSettings.clear() 483 | } else { 484 | globalSettings.requireConfig = Boolean( 485 | /** @type {Omit & { settings: Record }} */ ( 486 | change 487 | ).settings.requireConfig 488 | ) 489 | } 490 | 491 | // Revalidate all open text documents 492 | checkDocuments(...documents.all()) 493 | }) 494 | 495 | connection.onCodeAction((event) => { 496 | /** @type {CodeAction[]} */ 497 | const codeActions = [] 498 | 499 | const document = documents.get(event.textDocument.uri) 500 | 501 | // This might happen if a client calls this function without synchronizing 502 | // the document first. 503 | if (!document) { 504 | return 505 | } 506 | 507 | for (const diagnostic of event.context.diagnostics) { 508 | // type-coverage:ignore-next-line 509 | const data = /** @type {{expected?: unknown[]}} */ (diagnostic.data) 510 | if (typeof data !== 'object' || !data) { 511 | continue 512 | } 513 | 514 | const {expected} = data 515 | 516 | if (!Array.isArray(expected)) { 517 | continue 518 | } 519 | 520 | const {end, start} = diagnostic.range 521 | const actual = document.getText(diagnostic.range) 522 | 523 | for (const replacement of expected) { 524 | if (typeof replacement !== 'string') { 525 | continue 526 | } 527 | 528 | const codeAction = CodeAction.create( 529 | replacement 530 | ? start.line === end.line && start.character === end.character 531 | ? 'Insert `' + replacement + '`' 532 | : 'Replace `' + actual + '` with `' + replacement + '`' 533 | : 'Remove `' + actual + '`', 534 | { 535 | changes: { 536 | [document.uri]: [TextEdit.replace(diagnostic.range, replacement)] 537 | } 538 | }, 539 | CodeActionKind.QuickFix 540 | ) 541 | 542 | if (expected.length === 1) { 543 | codeAction.isPreferred = true 544 | } 545 | 546 | codeActions.push(codeAction) 547 | } 548 | } 549 | 550 | return codeActions 551 | }) 552 | 553 | documents.listen(connection) 554 | connection.listen() 555 | } 556 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2019 aecepoglu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unified-language-server", 3 | "version": "4.0.0", 4 | "description": "Language server for unified", 5 | "license": "MIT", 6 | "keywords": [ 7 | "lsp", 8 | "langserver", 9 | "language server", 10 | "unified" 11 | ], 12 | "repository": "unifiedjs/unified-language-server", 13 | "bugs": "https://github.com/unifiedjs/unified-language-server/issues", 14 | "author": "aecepoglu", 15 | "funding": { 16 | "type": "opencollective", 17 | "url": "https://opencollective.com/unified" 18 | }, 19 | "contributors": [ 20 | "Remco Haszing ", 21 | "Christian Murphy ", 22 | "Titus Wormer (https://wooorm.com)" 23 | ], 24 | "sideEffects": false, 25 | "type": "module", 26 | "main": "index.js", 27 | "exports": "./index.js", 28 | "files": [ 29 | "lib/", 30 | "index.js", 31 | "index.d.ts" 32 | ], 33 | "dependencies": { 34 | "find-up": "^6.0.0", 35 | "load-plugin": "^6.0.0", 36 | "unified-engine": "^11.0.0", 37 | "unist-util-lsp": "^2.0.0", 38 | "vfile": "^6.0.0", 39 | "vfile-message": "^4.0.0", 40 | "vscode-languageserver": "^9.0.0", 41 | "vscode-languageserver-textdocument": "^1.0.0" 42 | }, 43 | "devDependencies": { 44 | "@types/node": "^20.0.0", 45 | "c8": "^9.0.0", 46 | "prettier": "^3.0.0", 47 | "remark": "^15.0.0", 48 | "remark-cli": "^12.0.0", 49 | "remark-preset-wooorm": "^10.0.0", 50 | "type-coverage": "^2.0.0", 51 | "typescript": "^5.0.0", 52 | "unified": "^11.0.0", 53 | "xo": "^0.58.0" 54 | }, 55 | "scripts": { 56 | "prepack": "npm run build", 57 | "build": "tsc --build --clean && tsc --build && type-coverage", 58 | "format": "remark . -qfo && prettier . -w --log-level warn && xo --fix", 59 | "test-api": "node --unhandled-rejections=strict --conditions development test/index.js", 60 | "test-coverage": "c8 --check-coverage --100 --reporter lcov npm run test-api", 61 | "test": "npm run build && npm run format && npm run test-coverage" 62 | }, 63 | "prettier": { 64 | "tabWidth": 2, 65 | "useTabs": false, 66 | "singleQuote": true, 67 | "bracketSpacing": false, 68 | "semi": false, 69 | "trailingComma": "none" 70 | }, 71 | "xo": { 72 | "prettier": true, 73 | "rules": { 74 | "capitalized-comments": "off" 75 | } 76 | }, 77 | "remarkConfig": { 78 | "plugins": [ 79 | "remark-preset-wooorm" 80 | ] 81 | }, 82 | "typeCoverage": { 83 | "atLeast": 100, 84 | "detail": true, 85 | "ignoreNested": true, 86 | "strict": true 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # unified-language-server 2 | 3 | [![Build][build-badge]][build] 4 | [![Coverage][coverage-badge]][coverage] 5 | [![Downloads][downloads-badge]][downloads] 6 | [![Size][size-badge]][size] 7 | [![Sponsors][sponsors-badge]][collective] 8 | [![Backers][backers-badge]][collective] 9 | [![Chat][chat-badge]][chat] 10 | 11 | Create a **[language server][]** based on **[unified][]** ecosystems. 12 | 13 | ## Contents 14 | 15 | * [What is this?](#what-is-this) 16 | * [When should I use this?](#when-should-i-use-this) 17 | * [Install](#install) 18 | * [Use](#use) 19 | * [API](#api) 20 | * [`createUnifiedLanguageServer(options)`](#createunifiedlanguageserveroptions) 21 | * [Examples](#examples) 22 | * [Types](#types) 23 | * [Language Server features](#language-server-features) 24 | * [Watching files](#watching-files) 25 | * [Requests](#requests) 26 | * [Configuration](#configuration) 27 | * [Compatibility](#compatibility) 28 | * [Related](#related) 29 | * [Contribute](#contribute) 30 | * [License](#license) 31 | 32 | ## What is this? 33 | 34 | This package exports a function which can be used to create a 35 | [language server][] based on [unified][] processors. 36 | It can do the following: 37 | 38 | * format documents based on a unified processor 39 | * validate documents based on a unified processor 40 | * support configuration files (such as `.remarkrc`) using 41 | [`unified-engine`][unified-engine] 42 | 43 | **unified** is a project that validates and transforms content with abstract 44 | syntax trees (ASTs). 45 | **unified-engine** is an engine to process multiple files with unified using 46 | configuration files. 47 | **language server** is a standardized language independent way for creating 48 | editor integrations. 49 | 50 | ## When should I use this? 51 | 52 | This package is useful when you want to create a language server for an existing 53 | unified ecosystem. 54 | Ideally this should follow the same rules as a CLI for this ecosystem created 55 | using [`unified-args`][unified-args]. 56 | The resulting package may then be used to create plugins for this ecosystem for 57 | various editors. 58 | 59 | ## Install 60 | 61 | This package is [ESM only][]. 62 | In Node.js (version 16.0+), install with [npm][]: 63 | 64 | ```sh 65 | npm install unified-language-server 66 | ``` 67 | 68 | ## Use 69 | 70 | Let’s say you want to create a language server for [remark][]. 71 | 72 | Create a file names `package.json` with the following content: 73 | 74 | ```json 75 | { 76 | "name": "remark-language-server", 77 | "version": "1.0.0", 78 | "bin": "./index.js", 79 | "type": "module", 80 | "dependencies": { 81 | "remark": "^14.0.0", 82 | "unified-language-server": "^1.0.0" 83 | } 84 | } 85 | ``` 86 | 87 | Then create `index.js` with the following content: 88 | 89 | ```js 90 | import {remark} from 'remark' 91 | import {createUnifiedLanguageServer} from 'unified-language-server' 92 | 93 | process.title = 'remark-language-server' 94 | 95 | createUnifiedLanguageServer({ 96 | ignoreName: '.remarkignore', 97 | packageField: 'remarkConfig', 98 | pluginPrefix: 'remark', 99 | rcName: '.remarkrc', 100 | processorName: 'remark', 101 | processorSpecifier: 'remark', 102 | defaultProcessor: remark 103 | }) 104 | ``` 105 | 106 | That’s all there is to it. 107 | You have just created a language server for remark. 108 | 109 | ## API 110 | 111 | ### `createUnifiedLanguageServer(options)` 112 | 113 | Create a language server for a unified ecosystem. 114 | 115 | ##### `options` 116 | 117 | Configuration for `unified-engine` and the language server. 118 | 119 | ###### `options.processorName` 120 | 121 | The package ID of the expected processor (`string`, required, example: 122 | `'remark'`). 123 | Will be loaded from the local workspace. 124 | 125 | ###### `options.processorSpecifier` 126 | 127 | The specifier to get the processor on the resolved module (`string`, optional, 128 | default: `'default'`). 129 | For example, remark uses the specifier `remark` to expose its processor and 130 | a default export can be requested by passing `'default'` (the default). 131 | 132 | ###### `options.defaultProcessor` 133 | 134 | Optional fallback processor to use if `processorName` can’t be found 135 | locally in `node_modules` ([`Unified`][unified], optional). 136 | This can be used to ship a processor with your package, to be used if no 137 | processor is found locally. 138 | If this isn’t passed, a warning is shown if `processorName` can’t be found. 139 | 140 | ###### `options.ignoreName` 141 | 142 | Name of ignore files to load (`string`, optional). 143 | 144 | ###### `options.packageField` 145 | 146 | Property at which configuration can be found in package.json files (`string`, 147 | optional). 148 | 149 | ###### `options.pluginPrefix` 150 | 151 | Optional prefix to use when searching for plugins (`string`, optional). 152 | 153 | ###### `options.plugins` 154 | 155 | Plugins to use by default (`Array|Object`, optional). 156 | 157 | ###### `options.rcName` 158 | 159 | Name of configuration files to load (`string`, optional). 160 | 161 | ## Examples 162 | 163 | For examples, see the following projects: 164 | 165 | * `redot-language-server` 166 | (coming soon) 167 | * `rehype-language-server` 168 | (coming soon) 169 | * [`remark-language-server`](https://github.com/remarkjs/remark-language-server) 170 | 171 | ## Types 172 | 173 | This package is fully typed with [TypeScript][]. 174 | It exports an `Options` type, which specifies the interface of the accepted 175 | options. 176 | 177 | ## Language Server features 178 | 179 | ### Watching files 180 | 181 | Clients should watch the `unified-engine` 182 | [config files][unified-engine-configuration] and notify the language server if a 183 | change was made. 184 | 185 | ### Requests 186 | 187 | Language servers created using this package implement the following language 188 | server features: 189 | 190 | * `textDocument/codeAction` 191 | — the language server implements code actions based on the `expected` field 192 | on reported messages. 193 | A code action can either insert, replace, or delete text based on the range 194 | of the message and the expected value. 195 | * `textDocument/didChange` 196 | — when a document is changed by the client, the language server processes it 197 | using a unified pipeline. 198 | Any messages collected are published to the client using 199 | `textDocument/publishDiagnostics`. 200 | * `textDocument/didClose` 201 | — when a document is closed by the client, the language server resets 202 | diagnostics by publishing an empty array using 203 | `textDocument/publishDiagnostics`. 204 | * `textDocument/didOpen` 205 | — when a document is opened by the client, the language server processes it 206 | using a unified pipeline. 207 | Any messages collected are published to the client using 208 | `textDocument/publishDiagnostics`. 209 | * `textDocument/formatting` 210 | — when document formatting is requested by the client, the language server 211 | processes it using a unified pipeline. 212 | The stringified result is returned. 213 | * `workspace/didChangeWatchedFiles` and `workspace/didChangeWorkspaceFolders` 214 | — when the client signals a watched file or workspace has changed, the 215 | language server processes all open files using a unified pipeline. 216 | Any messages collected are published to the client using 217 | `textDocument/publishDiagnostics`. 218 | 219 | ### Configuration 220 | 221 | * `requireConfig` (default: `false`) 222 | — If true, files will only be checked if a configuration file is present. 223 | 224 | ## Compatibility 225 | 226 | Projects maintained by the unified collective are compatible with all maintained 227 | versions of Node.js. 228 | As of now, that is Node.js 16.0+. 229 | Our projects sometimes work with older versions, but this is not guaranteed. 230 | 231 | This project uses [`vscode-languageserver`][vscode-languageserver] 7, which 232 | implements language server protocol 3.17.0. 233 | It should work anywhere where LSP 3.6.0 or later is implemented. 234 | 235 | ## Related 236 | 237 | * [`unified`](https://github.com/unifiedjs/unified) 238 | — create pipeline for working with syntax trees 239 | * [`unified-args`](https://github.com/unifiedjs/unified-args) 240 | — create a CLI for a unified pipeline 241 | 242 | ## Contribute 243 | 244 | See [`contributing.md`][contributing] in [`unifiedjs/.github`][health] for ways 245 | to get started. 246 | See [`support.md`][support] for ways to get help. 247 | 248 | This project has a [code of conduct][coc]. 249 | By interacting with this repository, organization, or community you agree to 250 | abide by its terms. 251 | 252 | ## License 253 | 254 | [MIT][license] © [@aecepoglu][author] 255 | 256 | 257 | 258 | [build-badge]: https://github.com/unifiedjs/unified-language-server/workflows/main/badge.svg 259 | 260 | [build]: https://github.com/unifiedjs/unified-language-server/actions 261 | 262 | [coverage-badge]: https://img.shields.io/codecov/c/github/unifiedjs/unified-language-server.svg 263 | 264 | [coverage]: https://codecov.io/github/unifiedjs/unified-language-server 265 | 266 | [downloads-badge]: https://img.shields.io/npm/dm/unified-language-server.svg 267 | 268 | [downloads]: https://www.npmjs.com/package/unified-language-server 269 | 270 | [esm only]: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c 271 | 272 | [size-badge]: https://img.shields.io/bundlephobia/minzip/unified-language-server.svg 273 | 274 | [size]: https://bundlephobia.com/result?p=unified-language-server 275 | 276 | [sponsors-badge]: https://opencollective.com/unified/sponsors/badge.svg 277 | 278 | [backers-badge]: https://opencollective.com/unified/backers/badge.svg 279 | 280 | [collective]: https://opencollective.com/unified 281 | 282 | [chat-badge]: https://img.shields.io/badge/chat-discussions-success.svg 283 | 284 | [chat]: https://github.com/unifiedjs/rehype/discussions 285 | 286 | [npm]: https://docs.npmjs.com/cli/install 287 | 288 | [health]: https://github.com/unifiedjs/.github 289 | 290 | [contributing]: https://github.com/unifiedjs/.github/blob/HEAD/contributing.md 291 | 292 | [support]: https://github.com/unifiedjs/.github/blob/HEAD/support.md 293 | 294 | [coc]: https://github.com/unifiedjs/.github/blob/HEAD/code-of-conduct.md 295 | 296 | [language server]: https://microsoft.github.io/language-server-protocol/ 297 | 298 | [license]: license 299 | 300 | [author]: https://github.com/aecepoglu 301 | 302 | [typescript]: https://www.typescriptlang.org 303 | 304 | [unified]: https://github.com/unifiedjs/unified 305 | 306 | [remark]: https://github.com/remarkjs/remark 307 | 308 | [unified-args]: https://github.com/unifiedjs/unified-args 309 | 310 | [unified-engine]: https://github.com/unifiedjs/unified-engine 311 | 312 | [unified-engine-configuration]: https://github.com/unifiedjs/unified-engine/blob/main/readme.md#implicit-configuration 313 | 314 | [vscode-languageserver]: https://github.com/microsoft/vscode-languageserver-node/tree/main/server 315 | -------------------------------------------------------------------------------- /test/code-actions.js: -------------------------------------------------------------------------------- 1 | import {createUnifiedLanguageServer} from 'unified-language-server' 2 | 3 | createUnifiedLanguageServer({ 4 | configurationSection: 'remark', 5 | processorName: 'remark', 6 | processorSpecifier: 'remark', 7 | plugins: [warn] 8 | }) 9 | 10 | /** @type {import('unified').Plugin<[]>} */ 11 | function warn() { 12 | return (_, file) => { 13 | // Insert 14 | file.message('', {line: 1, column: 1}).expected = ['insert me'] 15 | 16 | // Replace 17 | file.message('', { 18 | start: {line: 1, column: 1}, 19 | end: {line: 1, column: 7} 20 | }).expected = ['replacement'] 21 | 22 | // Delete 23 | file.message('', { 24 | start: {line: 1, column: 1}, 25 | end: {line: 1, column: 7} 26 | }).expected = [''] 27 | 28 | // Insert 29 | file.message('', { 30 | start: {line: 1, column: 1}, 31 | end: {line: 1, column: 7} 32 | }).expected = ['alternative a', 'alternative b'] 33 | 34 | // @ts-expect-error We are deliberately testing invalid types here, because 35 | // the expected field used to be untyped for a long time. 36 | file.message('', {line: 1, column: 1}).expected = 'insert me' 37 | // @ts-expect-error 38 | file.message('', {line: 1, column: 1}).expected = [12] 39 | file.message('', {line: 1, column: 1}) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/folder-with-package-json/package.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /test/folder/remark-with-cwd.js: -------------------------------------------------------------------------------- 1 | import {createUnifiedLanguageServer} from 'unified-language-server' 2 | 3 | createUnifiedLanguageServer({ 4 | configurationSection: 'remark', 5 | processorName: 'remark', 6 | processorSpecifier: 'remark', 7 | plugins: [warn] 8 | }) 9 | 10 | /** @type {import('unified').Plugin<[]>} */ 11 | function warn() { 12 | return (_, file) => { 13 | file.message(file.cwd) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('vscode-languageserver').ConfigurationParams} ConfigurationParams 3 | * @typedef {import('vscode-languageserver').ProtocolConnection} ProtocolConnection 4 | * @typedef {import('../lib/index.js').UnifiedLanguageServerSettings} UnifiedLanguageServerSettings 5 | */ 6 | 7 | import assert from 'node:assert/strict' 8 | import {spawn} from 'node:child_process' 9 | import process from 'node:process' 10 | import fs from 'node:fs/promises' 11 | import {afterEach, test} from 'node:test' 12 | import {fileURLToPath} from 'node:url' 13 | import { 14 | createProtocolConnection, 15 | CodeActionRequest, 16 | ConfigurationRequest, 17 | DidChangeConfigurationNotification, 18 | DidChangeWorkspaceFoldersNotification, 19 | DidChangeWatchedFilesNotification, 20 | DidCloseTextDocumentNotification, 21 | DidOpenTextDocumentNotification, 22 | DocumentFormattingRequest, 23 | LogMessageNotification, 24 | InitializedNotification, 25 | InitializeRequest, 26 | IPCMessageReader, 27 | IPCMessageWriter, 28 | PublishDiagnosticsNotification, 29 | RegistrationRequest, 30 | ShowMessageRequest 31 | } from 'vscode-languageserver/node.js' 32 | 33 | /** @type {ProtocolConnection} */ 34 | let connection 35 | 36 | const testremarkrcPath = new URL('.testremarkrc.json', import.meta.url) 37 | afterEach(() => fs.rm(testremarkrcPath, {force: true})) 38 | 39 | afterEach(() => { 40 | connection?.dispose() 41 | }) 42 | 43 | test('`initialize`', async () => { 44 | startLanguageServer('remark.js') 45 | const initializeResponse = await connection.sendRequest( 46 | InitializeRequest.type, 47 | { 48 | processId: null, 49 | rootUri: null, 50 | capabilities: {}, 51 | workspaceFolders: null 52 | } 53 | ) 54 | 55 | assert.deepEqual( 56 | initializeResponse, 57 | { 58 | capabilities: { 59 | textDocumentSync: 1, 60 | documentFormattingProvider: true, 61 | codeActionProvider: { 62 | codeActionKinds: ['quickfix'], 63 | resolveProvider: true 64 | } 65 | } 66 | }, 67 | 'should emit an introduction on `initialize`' 68 | ) 69 | }) 70 | 71 | test('`initialize` workspace capabilities', async () => { 72 | startLanguageServer('remark.js') 73 | 74 | const initializeResponse = await connection.sendRequest( 75 | InitializeRequest.type, 76 | { 77 | processId: null, 78 | rootUri: null, 79 | capabilities: {workspace: {workspaceFolders: true}}, 80 | workspaceFolders: null 81 | } 82 | ) 83 | 84 | assert.deepEqual( 85 | initializeResponse, 86 | { 87 | capabilities: { 88 | textDocumentSync: 1, 89 | documentFormattingProvider: true, 90 | codeActionProvider: { 91 | codeActionKinds: ['quickfix'], 92 | resolveProvider: true 93 | }, 94 | workspace: { 95 | workspaceFolders: {supported: true, changeNotifications: true} 96 | } 97 | } 98 | }, 99 | 'should emit an introduction on `initialize`' 100 | ) 101 | }) 102 | 103 | test('`textDocument/didOpen`, `textDocument/didClose` (and diagnostics)', async () => { 104 | startLanguageServer('remark-with-warnings.js') 105 | await connection.sendRequest(InitializeRequest.type, { 106 | processId: null, 107 | rootUri: null, 108 | capabilities: {}, 109 | workspaceFolders: null 110 | }) 111 | const uri = new URL('lsp.md', import.meta.url).href 112 | 113 | const openDiagnosticsPromise = createOnNotificationPromise( 114 | PublishDiagnosticsNotification.type 115 | ) 116 | connection.sendNotification(DidOpenTextDocumentNotification.type, { 117 | textDocument: { 118 | uri, 119 | languageId: 'markdown', 120 | version: 1, 121 | text: '# hi' 122 | } 123 | }) 124 | const openDiagnostics = await openDiagnosticsPromise 125 | 126 | assert.deepEqual( 127 | openDiagnostics, 128 | { 129 | uri, 130 | version: 1, 131 | diagnostics: [ 132 | { 133 | range: {start: {line: 0, character: 0}, end: {line: 0, character: 4}}, 134 | message: 'info', 135 | severity: 3 136 | }, 137 | { 138 | range: {start: {line: 0, character: 0}, end: {line: 0, character: 4}}, 139 | message: 'warning', 140 | severity: 2 141 | }, 142 | { 143 | range: {start: {line: 0, character: 2}, end: {line: 0, character: 4}}, 144 | message: 'error', 145 | severity: 1, 146 | code: 'a', 147 | source: 'b', 148 | codeDescription: {href: 'd'}, 149 | data: {expected: ['hello']} 150 | }, 151 | { 152 | range: {start: {line: 1, character: 2}, end: {line: 1, character: 3}}, 153 | message: 'node', 154 | severity: 2 155 | }, 156 | { 157 | range: {start: {line: 1, character: 2}, end: {line: 1, character: 3}}, 158 | message: 'position', 159 | severity: 2 160 | }, 161 | { 162 | range: {start: {line: 1, character: 2}, end: {line: 1, character: 2}}, 163 | message: 'point', 164 | severity: 2 165 | }, 166 | { 167 | range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, 168 | message: 'nothing', 169 | severity: 2 170 | }, 171 | { 172 | range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, 173 | message: 'note\nThese are some additional notes', 174 | severity: 2 175 | } 176 | ] 177 | }, 178 | 'should emit diagnostics on `textDocument/didOpen`' 179 | ) 180 | 181 | const closeDiagnosticsPromise = createOnNotificationPromise( 182 | PublishDiagnosticsNotification.type 183 | ) 184 | connection.sendNotification(DidCloseTextDocumentNotification.type, { 185 | textDocument: {uri} 186 | }) 187 | const closeDiagnostics = await closeDiagnosticsPromise 188 | 189 | assert.deepEqual( 190 | closeDiagnostics, 191 | {uri, version: 1, diagnostics: []}, 192 | 'should emit empty diagnostics on `textDocument/didClose`' 193 | ) 194 | }) 195 | 196 | test('workspace configuration `requireConfig`', async () => { 197 | startLanguageServer('remark-with-warnings.js') 198 | 199 | await connection.sendRequest(InitializeRequest.type, { 200 | processId: null, 201 | rootUri: null, 202 | capabilities: { 203 | workspace: {configuration: true} 204 | }, 205 | workspaceFolders: null 206 | }) 207 | await new Promise((resolve) => { 208 | connection.onRequest(RegistrationRequest.type, resolve) 209 | connection.sendNotification(InitializedNotification.type, {}) 210 | }) 211 | 212 | /** @type {ConfigurationParams | undefined} */ 213 | let configRequest 214 | let requireConfig = false 215 | connection.onRequest(ConfigurationRequest.type, (request) => { 216 | configRequest = request 217 | return [{requireConfig}] 218 | }) 219 | const uri = new URL('lsp.md', import.meta.url).href 220 | 221 | const openDiagnosticsPromise = createOnNotificationPromise( 222 | PublishDiagnosticsNotification.type 223 | ) 224 | connection.sendNotification(DidOpenTextDocumentNotification.type, { 225 | textDocument: {uri, languageId: 'markdown', version: 1, text: '# hi'} 226 | }) 227 | const openDiagnostics = await openDiagnosticsPromise 228 | assert.notEqual( 229 | openDiagnostics.diagnostics.length, 230 | 0, 231 | 'should emit diagnostics on `textDocument/didOpen`' 232 | ) 233 | assert.deepEqual( 234 | configRequest, 235 | {items: [{scopeUri: uri, section: 'remark'}]}, 236 | 'should request configurations for the open file' 237 | ) 238 | 239 | configRequest = undefined 240 | const cachedOpenDiagnosticsPromise = createOnNotificationPromise( 241 | PublishDiagnosticsNotification.type 242 | ) 243 | connection.sendNotification(DidOpenTextDocumentNotification.type, { 244 | textDocument: {uri, languageId: 'markdown', version: 1, text: '# hi'} 245 | }) 246 | await cachedOpenDiagnosticsPromise 247 | assert.equal( 248 | configRequest, 249 | undefined, 250 | 'should cache workspace configurations' 251 | ) 252 | 253 | const closeDiagnosticsPromise = createOnNotificationPromise( 254 | PublishDiagnosticsNotification.type 255 | ) 256 | connection.sendNotification(DidCloseTextDocumentNotification.type, { 257 | textDocument: {uri} 258 | }) 259 | await closeDiagnosticsPromise 260 | const reopenDiagnosticsPromise = createOnNotificationPromise( 261 | PublishDiagnosticsNotification.type 262 | ) 263 | connection.sendNotification(DidOpenTextDocumentNotification.type, { 264 | textDocument: {uri, languageId: 'markdown', version: 1, text: '# hi'} 265 | }) 266 | await reopenDiagnosticsPromise 267 | assert.deepEqual( 268 | configRequest, 269 | {items: [{scopeUri: uri, section: 'remark'}]}, 270 | 'should clear the cache if the file is opened' 271 | ) 272 | 273 | configRequest = undefined 274 | const changeConfigurationDiagnosticsPromise = createOnNotificationPromise( 275 | PublishDiagnosticsNotification.type 276 | ) 277 | requireConfig = true 278 | connection.sendNotification(DidChangeConfigurationNotification.type, { 279 | settings: {} 280 | }) 281 | const changeConfigurationDiagnostics = 282 | await changeConfigurationDiagnosticsPromise 283 | assert.deepEqual( 284 | configRequest, 285 | {items: [{scopeUri: uri, section: 'remark'}]}, 286 | 'should clear the cache if the configuration changed' 287 | ) 288 | assert.deepEqual( 289 | {uri, version: 1, diagnostics: []}, 290 | changeConfigurationDiagnostics, 291 | 'should not emit diagnostics if requireConfig is false' 292 | ) 293 | }) 294 | 295 | test('global configuration `requireConfig`', async () => { 296 | startLanguageServer('remark-with-warnings.js') 297 | 298 | await connection.sendRequest(InitializeRequest.type, { 299 | processId: null, 300 | rootUri: null, 301 | capabilities: {}, 302 | workspaceFolders: null 303 | }) 304 | 305 | const uri = new URL('lsp.md', import.meta.url).href 306 | 307 | const openDiagnosticsPromise = createOnNotificationPromise( 308 | PublishDiagnosticsNotification.type 309 | ) 310 | connection.sendNotification(DidOpenTextDocumentNotification.type, { 311 | textDocument: {uri, languageId: 'markdown', version: 1, text: '# hi'} 312 | }) 313 | const openDiagnostics = await openDiagnosticsPromise 314 | assert.notEqual( 315 | openDiagnostics.diagnostics.length, 316 | 0, 317 | 'should emit diagnostics on `textDocument/didOpen`' 318 | ) 319 | 320 | const changeConfigurationDiagnosticsPromise = createOnNotificationPromise( 321 | PublishDiagnosticsNotification.type 322 | ) 323 | connection.sendNotification(DidChangeConfigurationNotification.type, { 324 | settings: {requireConfig: true} 325 | }) 326 | const changeConfigurationDiagnostics = 327 | await changeConfigurationDiagnosticsPromise 328 | assert.deepEqual( 329 | {uri, version: 1, diagnostics: []}, 330 | changeConfigurationDiagnostics, 331 | 'should emit empty diagnostics if requireConfig is true without config' 332 | ) 333 | 334 | await fs.writeFile(testremarkrcPath, '{}\n') 335 | const watchedFileDiagnosticsPromise = createOnNotificationPromise( 336 | PublishDiagnosticsNotification.type 337 | ) 338 | connection.sendNotification(DidChangeWatchedFilesNotification.type, { 339 | changes: [] 340 | }) 341 | const watchedFileDiagnostics = await watchedFileDiagnosticsPromise 342 | assert.equal( 343 | 0, 344 | watchedFileDiagnostics.diagnostics.length, 345 | 'should emit diagnostics if requireConfig is true with config' 346 | ) 347 | }) 348 | 349 | test('unified-engine errors', async () => { 350 | startLanguageServer('misconfigured.js') 351 | await connection.sendRequest(InitializeRequest.type, { 352 | processId: null, 353 | rootUri: null, 354 | capabilities: {}, 355 | workspaceFolders: null 356 | }) 357 | const uri = new URL('lsp.md', import.meta.url).href 358 | 359 | const openDiagnosticsPromise = createOnNotificationPromise( 360 | PublishDiagnosticsNotification.type 361 | ) 362 | connection.sendNotification(DidOpenTextDocumentNotification.type, { 363 | textDocument: { 364 | uri, 365 | languageId: 'markdown', 366 | version: 1, 367 | text: '# hi' 368 | } 369 | }) 370 | const openDiagnostics = await openDiagnosticsPromise 371 | 372 | assert.deepEqual( 373 | openDiagnostics.diagnostics.map(({message, ...rest}) => ({ 374 | message: cleanStack(message, 3), 375 | ...rest 376 | })), 377 | [ 378 | { 379 | message: 380 | 'Missing `processor`\n' + 381 | 'Error: Missing `processor`\n' + 382 | ' at engine (index.js:1:1)', 383 | range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, 384 | severity: 1 385 | } 386 | ] 387 | ) 388 | }) 389 | 390 | test('uninstalled processor so `window/showMessageRequest`', async () => { 391 | startLanguageServer('missing-package.js') 392 | 393 | await connection.sendRequest(InitializeRequest.type, { 394 | processId: null, 395 | rootUri: null, 396 | capabilities: {}, 397 | workspaceFolders: null 398 | }) 399 | 400 | const messageRequestPromise = createOnRequestPromise(ShowMessageRequest.type) 401 | connection.sendNotification(DidOpenTextDocumentNotification.type, { 402 | textDocument: { 403 | uri: new URL('lsp.md', import.meta.url).href, 404 | languageId: 'markdown', 405 | version: 1, 406 | text: '# hi' 407 | } 408 | }) 409 | const messageRequest = await messageRequestPromise 410 | 411 | assert.deepEqual( 412 | messageRequest, 413 | { 414 | type: 3, 415 | message: 416 | 'Cannot turn on language server without `xxx-missing-yyy` locally. Run `npm install xxx-missing-yyy` to enable it', 417 | actions: [] 418 | }, 419 | 'should emit a `window/showMessageRequest` when the processor can’t be found locally' 420 | ) 421 | }) 422 | 423 | test('uninstalled processor w/ `defaultProcessor`', async () => { 424 | startLanguageServer('missing-package-with-default.js') 425 | 426 | await connection.sendRequest(InitializeRequest.type, { 427 | processId: null, 428 | rootUri: null, 429 | capabilities: {}, 430 | workspaceFolders: null 431 | }) 432 | 433 | const logPromise = createOnNotificationPromise(LogMessageNotification.type) 434 | connection.sendNotification(DidOpenTextDocumentNotification.type, { 435 | textDocument: { 436 | uri: new URL('lsp.md', import.meta.url).href, 437 | languageId: 'markdown', 438 | version: 1, 439 | text: '# hi' 440 | } 441 | }) 442 | const log = await logPromise 443 | 444 | assert.deepEqual( 445 | cleanStack(log.message, 2).replace(/(imported from )[^\r\n]+/, '$1zzz'), 446 | "Cannot find `xxx-missing-yyy` locally but using `defaultProcessor`, original error:\nError: Cannot find package 'xxx-missing-yyy' imported from zzz", 447 | 'should work w/ `defaultProcessor`' 448 | ) 449 | }) 450 | 451 | test('`textDocument/formatting`', async () => { 452 | startLanguageServer('remark.js') 453 | 454 | await connection.sendRequest(InitializeRequest.type, { 455 | processId: null, 456 | rootUri: null, 457 | capabilities: {}, 458 | workspaceFolders: null 459 | }) 460 | 461 | connection.sendNotification(DidOpenTextDocumentNotification.type, { 462 | textDocument: { 463 | uri: new URL('bad.md', import.meta.url).href, 464 | languageId: 'markdown', 465 | version: 1, 466 | text: ' # hi \n' 467 | } 468 | }) 469 | 470 | connection.sendNotification(DidOpenTextDocumentNotification.type, { 471 | textDocument: { 472 | uri: new URL('good.md', import.meta.url).href, 473 | languageId: 'markdown', 474 | version: 1, 475 | text: '# hi\n' 476 | } 477 | }) 478 | 479 | const resultBad = await connection.sendRequest( 480 | DocumentFormattingRequest.type, 481 | { 482 | textDocument: {uri: new URL('bad.md', import.meta.url).href}, 483 | options: {tabSize: 2, insertSpaces: true} 484 | } 485 | ) 486 | assert.deepEqual( 487 | resultBad, 488 | [ 489 | { 490 | range: {start: {line: 0, character: 0}, end: {line: 1, character: 0}}, 491 | newText: '# hi\n' 492 | } 493 | ], 494 | 'should format bad documents on `textDocument/formatting`' 495 | ) 496 | 497 | const resultGood = await connection.sendRequest( 498 | DocumentFormattingRequest.type, 499 | { 500 | textDocument: {uri: new URL('good.md', import.meta.url).href}, 501 | options: {tabSize: 2, insertSpaces: true} 502 | } 503 | ) 504 | assert.deepEqual( 505 | resultGood, 506 | null, 507 | 'should format good documents on `textDocument/formatting`' 508 | ) 509 | 510 | const resultUnknown = await connection.sendRequest( 511 | DocumentFormattingRequest.type, 512 | { 513 | textDocument: {uri: new URL('unknown.md', import.meta.url).href}, 514 | options: {tabSize: 2, insertSpaces: true} 515 | } 516 | ) 517 | assert.deepEqual( 518 | resultUnknown, 519 | null, 520 | 'should ignore unsynchronized documents on `textDocument/formatting`' 521 | ) 522 | 523 | connection.sendNotification(DidOpenTextDocumentNotification.type, { 524 | textDocument: { 525 | uri: new URL('../../outside.md', import.meta.url).href, 526 | languageId: 'markdown', 527 | version: 1, 528 | text: ' # hi \n' 529 | } 530 | }) 531 | 532 | const resultOutside = await connection.sendRequest( 533 | DocumentFormattingRequest.type, 534 | { 535 | textDocument: { 536 | uri: new URL('../../outside.md', import.meta.url).href 537 | }, 538 | options: {tabSize: 2, insertSpaces: true} 539 | } 540 | ) 541 | assert.deepEqual( 542 | resultOutside, 543 | null, 544 | 'should ignore documents outside of workspace on `textDocument/formatting`' 545 | ) 546 | }) 547 | 548 | test('`workspace/didChangeWatchedFiles`', async () => { 549 | startLanguageServer('remark.js') 550 | 551 | await connection.sendRequest(InitializeRequest.type, { 552 | processId: null, 553 | rootUri: null, 554 | capabilities: {}, 555 | workspaceFolders: null 556 | }) 557 | 558 | const openDiagnosticsPromise = createOnNotificationPromise( 559 | PublishDiagnosticsNotification.type 560 | ) 561 | connection.sendNotification(DidOpenTextDocumentNotification.type, { 562 | textDocument: { 563 | uri: new URL('a.md', import.meta.url).href, 564 | languageId: 'markdown', 565 | version: 1, 566 | text: '# hi' 567 | } 568 | }) 569 | await openDiagnosticsPromise 570 | 571 | const changeWatchDiagnosticsPromise = createOnNotificationPromise( 572 | PublishDiagnosticsNotification.type 573 | ) 574 | connection.sendNotification('workspace/didChangeWatchedFiles', {changes: []}) 575 | const changeWatchDiagnostics = await changeWatchDiagnosticsPromise 576 | 577 | assert.deepEqual( 578 | changeWatchDiagnostics, 579 | {uri: new URL('a.md', import.meta.url).href, version: 1, diagnostics: []}, 580 | 'should emit diagnostics for registered files on any `workspace/didChangeWatchedFiles`' 581 | ) 582 | }) 583 | 584 | test('`initialize`, `textDocument/didOpen` (and a broken plugin)', async () => { 585 | startLanguageServer('remark-with-error.js') 586 | 587 | await connection.sendRequest(InitializeRequest.type, { 588 | processId: null, 589 | rootUri: null, 590 | capabilities: {}, 591 | workspaceFolders: null 592 | }) 593 | 594 | const openDiagnosticsPromise = createOnNotificationPromise( 595 | PublishDiagnosticsNotification.type 596 | ) 597 | connection.sendNotification(DidOpenTextDocumentNotification.type, { 598 | textDocument: { 599 | uri: new URL('lsp.md', import.meta.url).href, 600 | languageId: 'markdown', 601 | version: 1, 602 | text: '# hi' 603 | } 604 | }) 605 | const openDiagnostics = await openDiagnosticsPromise 606 | 607 | assert.deepEqual( 608 | openDiagnostics.diagnostics.map(({message, ...rest}) => ({ 609 | message: cleanStack(message, 3), 610 | ...rest 611 | })), 612 | [ 613 | { 614 | message: 615 | 'Cannot process file\n' + 616 | 'Error: Whoops!\n' + 617 | ' at Function.oneError (one-error.js:1:1)', 618 | range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, 619 | severity: 1 620 | } 621 | ], 622 | 'should show stack traces on crashes' 623 | ) 624 | }) 625 | 626 | test('`textDocument/codeAction` (and diagnostics)', async () => { 627 | startLanguageServer('code-actions.js') 628 | const uri = new URL('lsp.md', import.meta.url).href 629 | 630 | await connection.sendRequest(InitializeRequest.type, { 631 | processId: null, 632 | rootUri: null, 633 | capabilities: {}, 634 | workspaceFolders: null 635 | }) 636 | 637 | const openDiagnosticsPromise = createOnNotificationPromise( 638 | PublishDiagnosticsNotification.type 639 | ) 640 | connection.sendNotification(DidOpenTextDocumentNotification.type, { 641 | textDocument: { 642 | uri, 643 | languageId: 'markdown', 644 | version: 1, 645 | text: 'actual content' 646 | } 647 | }) 648 | const openDiagnostics = await openDiagnosticsPromise 649 | 650 | const codeActions = await connection.sendRequest(CodeActionRequest.type, { 651 | textDocument: {uri}, 652 | range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, 653 | context: { 654 | diagnostics: openDiagnostics.diagnostics 655 | } 656 | }) 657 | 658 | assert.deepEqual( 659 | codeActions, 660 | [ 661 | { 662 | title: 'Insert `insert me`', 663 | edit: { 664 | changes: { 665 | [uri]: [ 666 | { 667 | range: { 668 | start: {line: 0, character: 0}, 669 | end: {line: 0, character: 0} 670 | }, 671 | newText: 'insert me' 672 | } 673 | ] 674 | } 675 | }, 676 | kind: 'quickfix', 677 | isPreferred: true 678 | }, 679 | { 680 | title: 'Replace `actual` with `replacement`', 681 | edit: { 682 | changes: { 683 | [uri]: [ 684 | { 685 | range: { 686 | start: {line: 0, character: 0}, 687 | end: {line: 0, character: 6} 688 | }, 689 | newText: 'replacement' 690 | } 691 | ] 692 | } 693 | }, 694 | kind: 'quickfix', 695 | isPreferred: true 696 | }, 697 | { 698 | title: 'Remove `actual`', 699 | edit: { 700 | changes: { 701 | [uri]: [ 702 | { 703 | range: { 704 | start: {line: 0, character: 0}, 705 | end: {line: 0, character: 6} 706 | }, 707 | newText: '' 708 | } 709 | ] 710 | } 711 | }, 712 | kind: 'quickfix', 713 | isPreferred: true 714 | }, 715 | { 716 | title: 'Replace `actual` with `alternative a`', 717 | edit: { 718 | changes: { 719 | [uri]: [ 720 | { 721 | range: { 722 | start: {line: 0, character: 0}, 723 | end: {line: 0, character: 6} 724 | }, 725 | newText: 'alternative a' 726 | } 727 | ] 728 | } 729 | }, 730 | kind: 'quickfix' 731 | }, 732 | { 733 | title: 'Replace `actual` with `alternative b`', 734 | edit: { 735 | changes: { 736 | [uri]: [ 737 | { 738 | range: { 739 | start: {line: 0, character: 0}, 740 | end: {line: 0, character: 6} 741 | }, 742 | newText: 'alternative b' 743 | } 744 | ] 745 | } 746 | }, 747 | kind: 'quickfix' 748 | } 749 | ], 750 | 'should emit quick fixes on a `textDocument/codeAction`' 751 | ) 752 | 753 | const closedCodeActions = await connection.sendRequest( 754 | CodeActionRequest.type, 755 | { 756 | textDocument: {uri: new URL('closed.md', import.meta.url).href}, 757 | range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, 758 | context: {diagnostics: []} 759 | } 760 | ) 761 | assert.equal( 762 | closedCodeActions, 763 | null, 764 | 'should not emit quick fixes for unsynchronized documents' 765 | ) 766 | }) 767 | 768 | test('`initialize` w/ nothing (finds closest `package.json`)', async () => { 769 | startLanguageServer('remark-with-cwd.js', '../') 770 | 771 | await connection.sendRequest(InitializeRequest.type, { 772 | processId: null, 773 | rootUri: null, 774 | capabilities: {}, 775 | workspaceFolders: null 776 | }) 777 | 778 | const openDiagnosticsPromise = createOnNotificationPromise( 779 | PublishDiagnosticsNotification.type 780 | ) 781 | connection.sendNotification(DidOpenTextDocumentNotification.type, { 782 | textDocument: { 783 | uri: new URL('folder-with-package-json/folder/file.md', import.meta.url) 784 | .href, 785 | languageId: 'markdown', 786 | version: 1, 787 | text: '# hi' 788 | } 789 | }) 790 | const openDiagnostics = await openDiagnosticsPromise 791 | 792 | assert.deepEqual( 793 | openDiagnostics.diagnostics[0].message, 794 | fileURLToPath(new URL('folder-with-package-json', import.meta.url).href), 795 | 'should default to a `cwd` of the parent folder of the closest `package.json`' 796 | ) 797 | }) 798 | 799 | test('`initialize` w/ nothing (find closest `.git`)', async () => { 800 | startLanguageServer('remark-with-cwd.js', '../') 801 | await fs.mkdir(new URL('folder-with-git/.git', import.meta.url), { 802 | recursive: true 803 | }) 804 | 805 | await connection.sendRequest(InitializeRequest.type, { 806 | processId: null, 807 | rootUri: null, 808 | capabilities: {}, 809 | workspaceFolders: null 810 | }) 811 | 812 | const openDiagnosticsPromise = createOnNotificationPromise( 813 | PublishDiagnosticsNotification.type 814 | ) 815 | connection.sendNotification(DidOpenTextDocumentNotification.type, { 816 | textDocument: { 817 | uri: new URL('folder-with-git/folder/file.md', import.meta.url).href, 818 | languageId: 'markdown', 819 | version: 1, 820 | text: '# hi' 821 | } 822 | }) 823 | const openDiagnostics = await openDiagnosticsPromise 824 | 825 | assert.deepEqual( 826 | openDiagnostics.diagnostics[0].message, 827 | fileURLToPath(new URL('folder-with-git', import.meta.url).href), 828 | 'should default to a `cwd` of the parent folder of the closest `.git`' 829 | ) 830 | }) 831 | 832 | test('`initialize` w/ `rootUri`', async () => { 833 | const cwd = new URL('folder/', import.meta.url) 834 | startLanguageServer('remark-with-cwd.js') 835 | 836 | await connection.sendRequest(InitializeRequest.type, { 837 | processId: null, 838 | rootUri: cwd.href, 839 | capabilities: {}, 840 | workspaceFolders: [] 841 | }) 842 | 843 | const openDiagnosticsPromise = createOnNotificationPromise( 844 | PublishDiagnosticsNotification.type 845 | ) 846 | connection.sendNotification(DidOpenTextDocumentNotification.type, { 847 | textDocument: { 848 | uri: new URL('lsp.md', cwd).href, 849 | languageId: 'markdown', 850 | version: 1, 851 | text: '# hi' 852 | } 853 | }) 854 | const openDiagnostics = await openDiagnosticsPromise 855 | 856 | assert.deepEqual( 857 | openDiagnostics.diagnostics[0].message, 858 | fileURLToPath(cwd).slice(0, -1), 859 | 'should use `rootUri`' 860 | ) 861 | }) 862 | 863 | test('`initialize` w/ `workspaceFolders`', async () => { 864 | const processCwd = new URL('./', import.meta.url) 865 | startLanguageServer('remark-with-cwd.js') 866 | 867 | const otherCwd = new URL('folder/', processCwd) 868 | 869 | await connection.sendRequest(InitializeRequest.type, { 870 | processId: null, 871 | rootUri: null, 872 | capabilities: {}, 873 | workspaceFolders: [ 874 | {uri: processCwd.href, name: ''}, // Farthest 875 | {uri: otherCwd.href, name: ''} // Nearest 876 | ] 877 | }) 878 | 879 | const openDiagnosticsPromise = createOnNotificationPromise( 880 | PublishDiagnosticsNotification.type 881 | ) 882 | connection.sendNotification(DidOpenTextDocumentNotification.type, { 883 | textDocument: { 884 | uri: new URL('lsp.md', otherCwd).href, 885 | languageId: 'markdown', 886 | version: 1, 887 | text: '# hi' 888 | } 889 | }) 890 | const openDiagnostics = await openDiagnosticsPromise 891 | 892 | assert.deepEqual( 893 | openDiagnostics.diagnostics[0].message, 894 | fileURLToPath(otherCwd).slice(0, -1), 895 | 'should use `workspaceFolders`' 896 | ) 897 | }) 898 | 899 | test('`workspace/didChangeWorkspaceFolders`', async () => { 900 | const processCwd = new URL('./', import.meta.url) 901 | 902 | startLanguageServer('remark-with-cwd.js') 903 | 904 | await connection.sendRequest(InitializeRequest.type, { 905 | processId: null, 906 | rootUri: null, 907 | capabilities: {workspace: {workspaceFolders: true}}, 908 | workspaceFolders: [{uri: processCwd.href, name: ''}] 909 | }) 910 | 911 | connection.sendNotification('initialized', {}) 912 | 913 | const otherCwd = new URL('folder/', processCwd) 914 | 915 | const openDiagnosticsPromise = createOnNotificationPromise( 916 | PublishDiagnosticsNotification.type 917 | ) 918 | connection.sendNotification(DidOpenTextDocumentNotification.type, { 919 | textDocument: { 920 | uri: new URL('lsp.md', otherCwd).href, 921 | languageId: 'markdown', 922 | version: 1, 923 | text: '# hi' 924 | } 925 | }) 926 | const openDiagnostics = await openDiagnosticsPromise 927 | assert.equal( 928 | openDiagnostics.diagnostics[0].message, 929 | fileURLToPath(processCwd).slice(0, -1) 930 | ) 931 | 932 | const didAddDiagnosticsPromise = createOnNotificationPromise( 933 | PublishDiagnosticsNotification.type 934 | ) 935 | connection.sendNotification(DidChangeWorkspaceFoldersNotification.type, { 936 | event: {added: [{uri: otherCwd.href, name: ''}], removed: []} 937 | }) 938 | const didAddDiagnostics = await didAddDiagnosticsPromise 939 | assert.equal( 940 | didAddDiagnostics.diagnostics[0].message, 941 | fileURLToPath(otherCwd).slice(0, -1) 942 | ) 943 | 944 | const didRemoveDiagnosticsPromise = createOnNotificationPromise( 945 | PublishDiagnosticsNotification.type 946 | ) 947 | connection.sendNotification(DidChangeWorkspaceFoldersNotification.type, { 948 | event: {added: [], removed: [{uri: otherCwd.href, name: ''}]} 949 | }) 950 | const didRemoveDiagnostics = await didRemoveDiagnosticsPromise 951 | assert.equal( 952 | didRemoveDiagnostics.diagnostics[0].message, 953 | fileURLToPath(processCwd).slice(0, -1) 954 | ) 955 | }) 956 | 957 | /** 958 | * @param {string} stack 959 | * @param {number} max 960 | * @returns {string} 961 | */ 962 | function cleanStack(stack, max) { 963 | return stack 964 | .replaceAll(/\(.+\//g, '(') 965 | .replaceAll(/\d+:\d+/g, '1:1') 966 | .split('\n') 967 | .slice(0, max) 968 | .join('\n') 969 | } 970 | 971 | /** 972 | * Start a language server. 973 | * 974 | * It will be cleaned up automatically. 975 | * 976 | * Any `window/logMessage` events emitted by the language server will be logged 977 | * to the console. 978 | * 979 | * @param {string} serverFilePath The path to the language server relative to 980 | * this test file. 981 | * @param relativeCwd The cwd to use for the process relative to this test file. 982 | */ 983 | function startLanguageServer(serverFilePath, relativeCwd = './') { 984 | const binary = fileURLToPath(new URL(serverFilePath, import.meta.url)) 985 | const cwd = new URL(relativeCwd, import.meta.url) 986 | 987 | // Using ipc is useful for debugging. This allows logging in the language 988 | // server. 989 | // Enabling this breaks code coverage 990 | // https://github.com/bcoe/c8/issues/189 991 | if (process.argv.includes('--ipc')) { 992 | const serverProcess = spawn('node', [binary, '--node-ipc'], { 993 | cwd, 994 | stdio: [null, 'inherit', 'inherit', 'ipc'] 995 | }) 996 | connection = createProtocolConnection( 997 | new IPCMessageReader(serverProcess), 998 | new IPCMessageWriter(serverProcess) 999 | ) 1000 | connection.onDispose(() => { 1001 | serverProcess.kill() 1002 | }) 1003 | } else { 1004 | const serverProcess = spawn('node', [binary, '--stdio'], {cwd}) 1005 | connection = createProtocolConnection( 1006 | serverProcess.stdout, 1007 | serverProcess.stdin 1008 | ) 1009 | } 1010 | 1011 | connection.onDispose(() => { 1012 | connection.end() 1013 | }) 1014 | connection.listen() 1015 | } 1016 | 1017 | /** 1018 | * Wait for an event type to be omitted. 1019 | * 1020 | * @template ReturnType 1021 | * @param {import('vscode-languageserver').NotificationType} type 1022 | * @returns {Promise} 1023 | */ 1024 | async function createOnNotificationPromise(type) { 1025 | return new Promise((resolve) => { 1026 | const disposable = connection.onNotification(type, (result) => { 1027 | disposable.dispose() 1028 | setTimeout(() => resolve(result), 0) 1029 | }) 1030 | }) 1031 | } 1032 | 1033 | /** 1034 | * Wait for a request to be sent from the server to the client. 1035 | * 1036 | * @template Params 1037 | * @param {import('vscode-languageserver').RequestType} type 1038 | * @returns {Promise} 1039 | */ 1040 | async function createOnRequestPromise(type) { 1041 | return new Promise((resolve) => { 1042 | const disposable = connection.onRequest(type, (result) => { 1043 | disposable.dispose() 1044 | resolve(result) 1045 | }) 1046 | }) 1047 | } 1048 | -------------------------------------------------------------------------------- /test/lots-of-warnings.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | 3 | /** @type {import('unified').Plugin<[], import('mdast').Root>} */ 4 | export default function lotsOfWarnings() { 5 | return (tree, file) => { 6 | // This tiny plugins expects running on a `# heading`. 7 | assert(tree.type === 'root', 'expected `root`') 8 | const head = tree.children[0] 9 | assert(head.type === 'heading', 'expected `heading`') 10 | const headHead = head.children[0] 11 | assert(headHead.type === 'text', 'expected `text`') 12 | 13 | file.info('info', tree) 14 | file.message('warning', head) 15 | Object.assign(file.message('error', headHead), { 16 | fatal: true, 17 | ruleId: 'a', 18 | source: 'b', 19 | url: 'd', 20 | actual: 'hi', 21 | expected: ['hello'] 22 | }) 23 | file.message('node', { 24 | type: 'a', 25 | position: {start: {line: 2, column: 3}, end: {line: 2, column: 4}} 26 | }) 27 | file.message('position', { 28 | start: {line: 2, column: 3}, 29 | end: {line: 2, column: 4} 30 | }) 31 | file.message('point', {line: 2, column: 3}) 32 | file.message('nothing') 33 | Object.assign(file.message('note'), { 34 | note: 'These are some additional notes' 35 | }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/misconfigured.js: -------------------------------------------------------------------------------- 1 | import {createUnifiedLanguageServer} from 'unified-language-server' 2 | 3 | createUnifiedLanguageServer({configurationSection: '', processorName: 'remark'}) 4 | -------------------------------------------------------------------------------- /test/missing-package-with-default.js: -------------------------------------------------------------------------------- 1 | import {remark} from 'remark' 2 | import {createUnifiedLanguageServer} from 'unified-language-server' 3 | 4 | createUnifiedLanguageServer({ 5 | configurationSection: 'xxx-missing-yyy', 6 | processorName: 'xxx-missing-yyy', 7 | defaultProcessor: remark 8 | }) 9 | -------------------------------------------------------------------------------- /test/missing-package.js: -------------------------------------------------------------------------------- 1 | import {createUnifiedLanguageServer} from 'unified-language-server' 2 | 3 | createUnifiedLanguageServer({ 4 | configurationSection: 'xxx-missing-yyy', 5 | processorName: 'xxx-missing-yyy' 6 | }) 7 | -------------------------------------------------------------------------------- /test/one-error.js: -------------------------------------------------------------------------------- 1 | /** @type {import('unified').Plugin<[], import('mdast').Root>} */ 2 | export default function oneError() { 3 | throw new Error('Whoops!') 4 | } 5 | -------------------------------------------------------------------------------- /test/remark-with-cwd.js: -------------------------------------------------------------------------------- 1 | import {createUnifiedLanguageServer} from 'unified-language-server' 2 | 3 | createUnifiedLanguageServer({ 4 | configurationSection: 'remark', 5 | processorName: 'remark', 6 | processorSpecifier: 'remark', 7 | plugins: [warn] 8 | }) 9 | 10 | /** @type {import('unified').Plugin<[]>} */ 11 | function warn() { 12 | return (_, file) => { 13 | file.message(file.cwd) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/remark-with-error.js: -------------------------------------------------------------------------------- 1 | import {createUnifiedLanguageServer} from 'unified-language-server' 2 | 3 | createUnifiedLanguageServer({ 4 | configurationSection: 'remark', 5 | processorName: 'remark', 6 | processorSpecifier: 'remark', 7 | // This is resolved from the directory containing package.json 8 | plugins: ['./test/one-error.js'] 9 | }) 10 | -------------------------------------------------------------------------------- /test/remark-with-warnings.js: -------------------------------------------------------------------------------- 1 | import {createUnifiedLanguageServer} from 'unified-language-server' 2 | 3 | createUnifiedLanguageServer({ 4 | configurationSection: 'remark', 5 | processorName: 'remark', 6 | processorSpecifier: 'remark', 7 | rcName: 'testremark', 8 | // This is resolved from the directory containing package.json 9 | plugins: ['./test/lots-of-warnings.js'] 10 | }) 11 | -------------------------------------------------------------------------------- /test/remark.js: -------------------------------------------------------------------------------- 1 | import {createUnifiedLanguageServer} from 'unified-language-server' 2 | 3 | createUnifiedLanguageServer({ 4 | configurationSection: 'remark', 5 | processorName: 'remark', 6 | processorSpecifier: 'remark' 7 | }) 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["lib/**/*.js", "test/**/*.js", "*.js"], 3 | "compilerOptions": { 4 | "checkJs": true, 5 | "declaration": true, 6 | "emitDeclarationOnly": true, 7 | "lib": ["es2022"], 8 | "module": "node16", 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "target": "es2022" 12 | } 13 | } 14 | --------------------------------------------------------------------------------