diff --git a/.gitignore b/.gitignore index c2658d7..2a70c90 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ +coverage/ node_modules/ +*.log +*.d.ts +*.tgz diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 2cf8302..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,28 +0,0 @@ -// A launch configuration that compiles the extension and then opens it inside a new window -{ - "version": "0.2.0", - "configurations": [ - { - "type": "extensionHost", - "request": "launch", - "name": "Launch Client", - "runtimeExecutable": "${execPath}", - "args": ["--extensionDevelopmentPath=${workspaceRoot}"], - "outFiles": ["${workspaceRoot}/src/index.js"] - }, - { - "type": "node", - "request": "attach", - "name": "Attach to Server", - "port": 6009, - "restart": true, - "outFiles": ["${workspaceRoot}/src/server.js"] - } - ], - "compounds": [ - { - "name": "Client + Server", - "configurations": ["Launch Client", "Attach to Server"] - } - ] -} diff --git a/CONFIGURATION.md b/CONFIGURATION.md deleted file mode 100644 index bf1e36e..0000000 --- a/CONFIGURATION.md +++ /dev/null @@ -1,182 +0,0 @@ -# Configuration - -The default settings are **roughly** -[(see the exact configuration)](#default-settings) equal to: - -```json -{ - "retext-english": { - "plugins": [ - ["#retext-profanities"], - ["#retext-spell", "#dictionary-en-gb"] - ], - }, - "remark-parse": { - "plugins": [ - ["#remark-preset-lint-markdown-style-guide"] - ["#remark-retext", "#parse-latin"], - ["#retext-profanities"], - ["#retext-spell", "#dictionary-en-gb"] - ] - } -} -``` - -Your text editors (if they are [LSP clients](https://langserver.org/)) can be -configured to override these settings. - -## Configuration for text editors - -### For NeoVim - -```vim -"inside .vimrc -let g:LanguageClient_settingsPath = "/home/aecepoglu/.settings.json" -``` - -and the file `/home/aecepoglu/.settings.json` would be: - -```json -{ - "unified-language-server": { - "retext-english": { - "plugins": [ - ... - ] - }, - "remark-parse": { - "plugins": [ - ... - ] - } - } -} -``` - -### For other editors - -(TODO) - -*** - -## Re-Using Settings - -If I wanted to use `retext-redundant-acronyms`, `retext-overuse`, -`retext-intensify` and `retext-repeated-words` plugins for my text files and -markdown files, I would do: - -```json -{ - "unified-language-server": { - "retext-english": { - "plugins": [ - ["#retext-redundant-acronyms"], - ["#retext-overuse"], - ["#retext-intensify"], - ["#retext-repeated-words"], - ["#retext-spell", "#dictionary-en-gb"] - ] - }, - "remark-parse": { - "plugins": [ - ["#remark-preset-lint-markdown-style-guide"], - ["#remark-retext", "#parse-latin"], - ["#retext-redundant-acronyms"], - ["#retext-overuse"], - ["#retext-intensify"], - ["#retext-repeated-words"], - ["#retext-spell", "#dictionary-en-gb"] - ] - } - } -} -``` - -*BUT*, I (as a sane person) want my markdown rules to just follow my -`retext-english` rules. -I could enable that with: - -```json -{ - "unified-language-server": { - "retext-english": { - "plugins": [ - ["#retext-redundant-acronyms"], - ["#retext-overuse"], - ["#retext-intensify"], - ["#retext-repeated-words"], - ["#retext-spell", "#dictionary-en-gb"] - ] - }, - "remark-parse": { - "plugins": [ - ["#remark-preset-lint-markdown-style-guide"] - ], - "checkTextWith": { - "setting": "retext-english", - "mutator": ["#remark-retext", "#parse-latin"] - } - } - } -} -``` - -And in fact, this is how the default configuration actually is: - -## Default Settings - -```json -{ - "unified-language-server": { - "retext-english": { - "plugins": [ - ["#retext-spell", "#dictionary-en-gb"] - ] - }, - "remark-parse": { - "plugins": [ - ["#remark-preset-lint-markdown-style-guide"] - ], - "checkTextWith": { - "setting": "retext-english", - "mutator": ["#remark-retext", "#parse-latin"] - } - }, - } -} -``` - -## Applying Settings Partially - -When you omit any of the parsers in your configuration the default configuration -will be used in its place. - -If are happy with what `remark-parse` does but you want `remark-english` to be -different, you could: - -```json -{ - "unified-language-server": { - "retext-english": { - "plugins": [ - ["#retext-repeated-words"], - ["#retext-spell", "#dictionary-en-gb"] - ] - } - } -} -``` - -## The Format - -(TODO) - -We rely entirely on -[UnifiedJS parsers and processors](https://github.com/unifiedjs/unified#list-of-processors). - -The command line `--parser` option is used to parse the data. - -If a string value starts with `#`, then the module with that name will be -required. - -If a string value starts with `//`, then the file with that path will be read. diff --git a/README.md b/README.md index 62159ab..b7ed37b 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,235 @@ -# Unified-Language-Server +# unified-language-server + +[![Build][build-badge]][build] +[![Coverage][coverage-badge]][coverage] +[![Downloads][downloads-badge]][downloads] +[![Size][size-badge]][size] +[![Sponsors][sponsors-badge]][collective] +[![Backers][backers-badge]][collective] +[![Chat][chat-badge]][chat] + +Create a **[language server][]** based on **[unified][]** ecosystems. + +## Contents + +* [What is this?](#what-is-this) +* [When should I use this?](#when-should-i-use-this) +* [Install](#install) +* [Use](#use) +* [API](#api) + * [`createUnifiedLanguageServer(options)`](#createunifiedlanguageserveroptions) +* [Examples](#examples) +* [Types](#types) +* [Compatibility](#compatibility) +* [Related](#related) +* [Contribute](#contribute) +* [License](#license) + +## What is this? + +This package exports a function which can be used to create a +[language server][] based on [unified][] processors. +It can do the following: + +* format documents based on a unified processor +* validate documents based on a unified processor +* support configuration files (such as `.remarkrc`) using [unified-engine][] + +**unified** is a project that validates and transforms content with abstract +syntax trees (ASTs). +**unified-engine** is an engine to process multiple files with unified using +configuration files. +**language server** is a standardized language independant way for creating +editor integrations. + +## When should I use this? + +This package is useful when you want to create a language server for an existing +unified ecosystem. +Ideally this should follow the same rules as a CLI for this ecosystem created +using [unified-args][]. +The resulting package may then be used to create plugins for this ecosystem for +various editors. -A [language server](http://langserver.org) for text. +## Install -![demo gif](https://media.giphy.com/media/8BlBVMzDbmGY6ORBeL/giphy.gif) +This package is [ESM only](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c). +In Node.js (version 12.20+, 14.14+, or 16.0+), install with [npm][]: -It supports all formats [Unified.JS](https://unified.js.org) can understand: +```sh +npm install unified-language-server +``` -* plain text -* markdown -* HTML -* and [other syntax](https://github.com/unifiedjs/awesome#syntaxes) +## Use -And it provides: +Let’s say you want to create a language server for \[remark]\[]. -* prose and syntax checking -* formatting *(in progress)* +Create a file names `package.json` with the following content: -## Install - -```bash -yarn global add unified-language-server -# OR -npm install -g unified-language-server +```json +{ + "name": "remark-language-server", + "version": "1.0.0", + "bin": "./index.js", + "type": "module", + "dependencies": { + "unified-language-server" + } +} ``` -And configure your text editor to use: +Then create `index.js` with the following content: -* `unified-language-server --parser=retext-english --stdio` for `text` -* `unified-language-server --parser=remark-parse --stdio` for `markdown` +```js +import {createUnifiedLanguageServer} from 'unified-language-server' -### For NeoVim +process.title = 'remark-language-server' -```vim -"inside .vimrc -let g:LanguageClient_serverCommands = { -\ 'text': ['unified-language-server', '--parser=retext-english', '--stdio'], -\ 'markdown': ['unified-language-server', '--parser=remark-parse', '--stdio'], -\ } +createUnifiedLanguageServer({ + ignoreName: '.remarkignore', + packageField: 'remarkConfig', + pluginPrefix: 'remark', + plugins: ['remark-parse', 'remark-stringify'], + rcName: '.remarkrc' +}) ``` -And you’re ready to go! +That’s all there is to it. +You have just created a language server for remark. -## Configuration +## API -The server has default configurations for `remark-parse`(for markdown) and -`remark-english` (for text): +### `createUnifiedLanguageServer(options)` -```json -{ - "retext-english": { - "plugins": [ - ["#retext-profanities"], - ["#retext-spell", "#dictionary-en-gb"] - ], - }, - "remark-parse": { - "plugins": [ - ["#remark-preset-lint-markdown-style-guide"] - ["#remark-retext", "#parse-latin"], - ["#retext-profanities"], - ["#retext-spell", "#dictionary-en-gb"] - ] - } -} -``` +Create a language server for a unified ecosystem. + +##### `options` + +Configuration for `unified-engine` and the language server. + +###### `options.defaultSource` + +Default source used for diagnostics (`string`, optional) + +###### `options.ignoreName` + +Name of ignore files to load (`string`, optional) + +###### `options.packageField` + +Property at which configuration can be found in package.json files (`string`, +optional) + +###### `options.pluginPrefix` + +Optional prefix to use when searching for plugins (`string`, optional) + +###### `options.plugins` + +Plugins to use by default (`Array|Object`, optional) + +Typically this contains 2 plugins named `*-parse` and `*-stringify`. + +###### `options.rcName` + +Name of configuration files to load (`string`, optional) + +## Examples + +For examples, see the following projects: + +* [redot-language-server][] (Coming soon) +* [rehype-language-server][] (Coming soon) +* [remark-language-server][] (Coming soon) + +## Types + +This package is fully typed with [TypeScript][]. +It exports an `Options` type, which specifies the interface of the accepted +options. + +## Compatibility + +Projects maintained by the unified collective are compatible with all maintained +versions of Node.js. +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. + +## Related + +* [unified][] + — create pipeline for working with syntax trees +* [unified-args][] + — create a CLI for a unified pipeline + +## Contribute + +See [`contributing.md`][contributing] in [`unified/.github`][health] for ways +to get started. +See [`support.md`][support] for ways to get help. + +This project has a [code of conduct][coc]. +By interacting with this repository, organization, or community you agree to +abide by its terms. + +## License + +[MIT][license] © [@aecepoglu][author] + + + +[build-badge]: https://github.com/unifiedjs/unified-language-server/workflows/main/badge.svg + +[build]: https://github.com/unifiedjs/unified-language-server/actions + +[coverage-badge]: https://img.shields.io/codecov/c/github/unifiedjs/unified-language-server.svg + +[coverage]: https://codecov.io/github/unifiedjs/unified-language-server + +[downloads-badge]: https://img.shields.io/npm/dm/unified-language-server.svg + +[downloads]: https://www.npmjs.com/package/unified-language-server + +[size-badge]: https://img.shields.io/bundlephobia/minzip/unified-language-server.svg + +[size]: https://bundlephobia.com/result?p=unified-language-server + +[sponsors-badge]: https://opencollective.com/unified/sponsors/badge.svg + +[backers-badge]: https://opencollective.com/unified/backers/badge.svg + +[collective]: https://opencollective.com/unified + +[chat-badge]: https://img.shields.io/badge/chat-discussions-success.svg + +[chat]: https://github.com/unifiedjs/rehype/discussions + +[npm]: https://docs.npmjs.com/cli/install + +[health]: https://github.com/unifiedjs/.github + +[contributing]: https://github.com/unifiedjs/.github/blob/HEAD/contributing.md + +[support]: https://github.com/unifiedjs/.github/blob/HEAD/support.md + +[coc]: https://github.com/unifiedjs/.github/blob/HEAD/code-of-conduct.md + +[language server]: https://microsoft.github.io/language-server-protocol/ + +[license]: LICENSE.txt + +[author]: https://github.com/aecepoglu + +[redot-language-server]: https://github.com/redotjs/redot-language-server -So, for a markdown file: +[rehype-language-server]: https://github.com/redotjs/rehype-language-server -1. because we launched it with `--parser=remark-parse`, it finds the setting - with the same name -2. applies all the plugins: - 1. `remark-preset-lint-markdown-style-guide` checks for markdown usage - 2. `remark-retext` extracts the texts from markdown - 3. `retext-profanities` about usage of profanity words - 4. `retext-spell` does spellcheck +[remark-language-server]: https://github.com/redotjs/remark-language-server -More detail on configuration is available at [CONFIGURATION.md](CONFIGURATION.md) +[typescript]: https://www.typescriptlang.org -## Contributing +[unified]: https://github.com/unifiedjs/unified -To contribute, please: +[unified-args]: https://github.com/unifiedjs/unified-args -* Report any and all issues that you have -* In your issues make sure to mention: - * what version of the server you are running - * your configurations (if you have any) +[unified-engine]: https://github.com/unifiedjs/unified-engine diff --git a/index.js b/index.js new file mode 100644 index 0000000..645fda3 --- /dev/null +++ b/index.js @@ -0,0 +1,5 @@ +/** + * @typedef {import('./lib/index.js').Options} Options + */ + +export {configureUnifiedLanguageServer} from './lib/index.js' diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..0a101d4 --- /dev/null +++ b/lib/index.js @@ -0,0 +1,257 @@ +/** + * @typedef {import('unist').Point} Point + * @typedef {import('unist').Position} UnistPosition + * @typedef {import('vfile-message').VFileMessage} VFileMessage + * @typedef {import('vscode-languageserver').Connection} Connection + * @typedef {Pick< + * import('unified-engine').Options, + * 'ignoreName' | 'packageField' | 'pluginPrefix' | 'plugins' | 'rcName' + * >} Options + */ + +import {PassThrough} from 'node:stream' +import {URL, pathToFileURL} from 'node:url' + +import {unified} from 'unified' +import {engine} from 'unified-engine' +import {VFile} from 'vfile' +import { + createConnection, + Diagnostic, + DiagnosticSeverity, + Position, + ProposedFeatures, + Range, + TextDocuments, + TextDocumentSyncKind, + TextEdit +} from 'vscode-languageserver/node.js' +import {TextDocument} from 'vscode-languageserver-textdocument' + +/** + * Convert a unist point to a language server protocol position. + * + * @param {Point} point + * @returns {Position} + */ +function unistPointToLspPosition(point) { + return Position.create(point.line - 1, point.column - 1) +} + +/** + * @param {Point|null|undefined} point + * @returns {boolean} + */ +function isValidUnistPoint(point) { + return Boolean( + point && Number.isInteger(point.line) && Number.isInteger(point.column) + ) +} + +/** + * Convert a unist position to a language server protocol range. + * + * If no position is given, a range is returned which represents the beginning + * of the document. + * + * @param {UnistPosition|null|undefined} position + * @returns {Range} + */ +function unistLocationToLspRange(position) { + if (position) { + if (isValidUnistPoint(position.start)) { + if (isValidUnistPoint(position.end)) { + return Range.create( + unistPointToLspPosition(position.start), + unistPointToLspPosition(position.end) + ) + } + + const start = unistPointToLspPosition(position.start) + return Range.create(start, start) + } + + if (isValidUnistPoint(position.end)) { + const end = unistPointToLspPosition(position.end) + return Range.create(end, end) + } + } + + return Range.create(0, 0, 0, 0) +} + +/** + * Convert a vfile message to a language server protocol diagnostic. + * + * @param {VFileMessage} message + * @returns {Diagnostic} + */ +function vfileMessageToDiagnostic(message) { + const diagnostic = Diagnostic.create( + unistLocationToLspRange(message.position), + message.reason, + message.fatal === true + ? DiagnosticSeverity.Error + : message.fatal === false + ? DiagnosticSeverity.Warning + : DiagnosticSeverity.Information, + message.ruleId || undefined, + message.source || undefined + ) + if (message.url) { + diagnostic.codeDescription = {href: message.url} + } + + return diagnostic +} + +/** + * Convert language server protocol text document to a vfile. + * + * @param {TextDocument} document + * @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() + }) +} + +/** + * @param {Connection} connection + * @param {TextDocuments} documents + * @param {Options} options + */ +export function configureUnifiedLanguageServer( + connection, + documents, + {ignoreName, packageField, pluginPrefix, plugins, rcName} +) { + /** + * Process various LSP text documents using unified and send back the + * resulting messages as diagnostics. + * + * @param {TextDocument[]} textDocuments + * @param {boolean} alwaysStringify + * @returns {Promise} + */ + function processDocuments(textDocuments, alwaysStringify = false) { + return new Promise((resolve, reject) => { + engine( + { + alwaysStringify, + files: textDocuments.map((document) => lspDocumentToVfile(document)), + ignoreName, + packageField, + pluginPrefix, + plugins, + processor: unified(), + quiet: false, + rcName, + silentlyIgnore: true, + streamError: new PassThrough(), + streamOut: new PassThrough() + }, + (error, code, 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) || []) + } + /* c8 ignore end */ + } + ) + }) + } + + /** + * Process various LSP text documents using unified and send back the + * resulting messages as diagnostics. + * + * @param {TextDocument[]} textDocuments + */ + async function checkDocuments(...textDocuments) { + const documentVersions = new Map( + textDocuments.map((document) => [document.uri, document.version]) + ) + const files = await processDocuments(textDocuments) + + for (const file of files) { + // VFile uses a file path, but LSP expects a file URL as a string. + const uri = String(pathToFileURL(file.path)) + connection.sendDiagnostics({ + uri, + version: documentVersions.get(uri), + diagnostics: file.messages.map((message) => + vfileMessageToDiagnostic(message) + ) + }) + } + } + + connection.onInitialize(() => ({ + capabilities: { + textDocumentSync: TextDocumentSyncKind.Full, + documentFormattingProvider: true + } + })) + + connection.onDocumentFormatting(async ({textDocument: {uri}}) => { + const document = documents.get(uri) + if (!document) { + return + } + + const [file] = await processDocuments([document], true) + const result = String(file) + const text = document.getText() + if (result === text) { + return + } + + const start = Position.create(0, 0) + const end = document.positionAt(text.length) + + return [TextEdit.replace(Range.create(start, end), result)] + }) + + documents.onDidChangeContent((event) => { + checkDocuments(event.document) + }) + + documents.onDidClose((event) => { + const {uri, version} = event.document + connection.sendDiagnostics({ + uri, + version, + diagnostics: [] + }) + }) + + connection.onDidChangeWatchedFiles(() => { + checkDocuments(...documents.all()) + }) +} + +/** + * Create a language server for a unified ecosystem. + * + * @param {Options} options + * Configuration for `unified-engine` and the language server. + */ +export function createUnifiedLanguageServer(options) { + const connection = createConnection(ProposedFeatures.all) + const documents = new TextDocuments(TextDocument) + + configureUnifiedLanguageServer(connection, documents, options) + + documents.listen(connection) + connection.listen() +} diff --git a/package.json b/package.json index cb84b0d..20116f5 100644 --- a/package.json +++ b/package.json @@ -5,15 +5,25 @@ "homepage": "https://github.com/unifiedjs/unified-language-server", "repository": "unifiedjs/unified-language-server", "bugs": "https://github.com/unifiedjs/unified-language-serve/issues", - "main": "src/index.js", + "main": "index.js", "author": "aecepoglu", "license": "MIT", "sideEffects": false, - "bin": "unified-language-server", + "type": "module", + "exports": { + ".": "./index.js" + }, + "files": [ + "index.js", + "index.d.ts", + "lib/" + ], "scripts": { + "build": "rimraf '*.d.ts' 'test/*.d.ts' && tsc && type-coverage", "format": "remark . -qfo && prettier . -w --loglevel warn && xo --fix", - "test-coverage": "tape src/**/*.spec.js", - "test": "npm run format && npm run test-coverage" + "prepack": "npm run build", + "test-coverage": "c8 --check-coverage --branches 100 --functions 100 --lines 100 --statements 100 --reporter lcov node --unhandled-rejections=strict --conditions development test/index.js", + "test": "npm run build && npm run format && npm run test-coverage" }, "prettier": { "tabWidth": 2, @@ -24,29 +34,21 @@ "trailingComma": "none" }, "xo": { - "prettier": true, - "rules": { - "no-new": "off", - "no-warning-comments": "off", - "unicorn/prefer-module": "off" - } + "prettier": true }, "remarkConfig": { "plugins": [ - "preset-wooorm" + "remark-preset-wooorm" ] }, "dependencies": { - "dictionary-en-gb": "^2.0.0", - "parse-latin": "^4.0.0", - "remark-parse": "^6.0.0", - "remark-preset-lint-markdown-style-guide": "^2.0.0", - "remark-retext": "^3.0.0", - "retext": "^6.0.0", - "retext-english": "^3.0.0", - "retext-spell": "^2.0.0", - "unified": "^7.0.0", - "vscode-languageserver": "^5.0.0" + "@types/unist": "^2.0.0", + "unified": "^10.0.0", + "unified-engine": "^9.0.0", + "vfile": "^5.0.0", + "vfile-message": "^3.0.0", + "vscode-languageserver": "^7.0.0", + "vscode-languageserver-textdocument": "^1.0.0" }, "keywords": [ "LSP", @@ -61,12 +63,16 @@ "unified" ], "devDependencies": { + "@types/sinon": "^10.0.0", + "@types/tape": "^4.0.0", + "c8": "^7.0.0", "prettier": "^2.0.0", - "remark-cli": "^10.0.1", + "remark-cli": "^10.0.0", "remark-preset-wooorm": "^9.0.0", - "sinon": "^7.0.0", - "tape": "^4.0.0", - "vfile-message": "^1.0.0", + "sinon": "^12.0.0", + "tape": "^5.0.0", + "type-coverage": "^2.0.0", + "typescript": "^4.0.0", "xo": "^0.47.0" } } diff --git a/src/index.js b/src/index.js deleted file mode 100644 index f3d042f..0000000 --- a/src/index.js +++ /dev/null @@ -1,122 +0,0 @@ -const {readFileSync} = require('fs') -const LangServer = require('vscode-languageserver') -const { - Diagnostic, - Position, - TextDocumentSyncKind -} = require('vscode-languageserver') - -// ConvertPosition :: VFilePosition -> Position -const convertPosition = ({line, column}) => - Position.create(line - 1, column - 1) - -const parsePlugins = (object) => - typeof object === 'undefined' - ? object - : JSON.parse(JSON.stringify(object), (k, v) => { - if (typeof v !== 'string') { - return v - } - - if (v.startsWith('#')) { - return require(v.slice('#'.length)) - } - - if (v.startsWith('//')) { - return readFileSync(v.slice('//'.length), 'utf8') - } - - return v.trim() - }) - -class UnifiedLangServerBase { - constructor(connection, documents, processor0) { - this._connection = connection - this._documents = documents - this._processor0 = processor0 - this._processor = processor0 - - connection.onInitialize((_capabilities) => ({ - capabilities: { - textDocumentSync: TextDocumentSyncKind.Full - } - })) - - documents.onDidChangeContent((_) => this.validate(_)) - } - - setProcessor(x) { - this._processor = x - - return this - } - - configureWith(f) { - // TODO check if client supports configuration? - this._connection.onDidChangeConfiguration((change) => { - try { - this.setProcessor(this.createProcessor(f(change))) - - for (const document of this._documents.all()) { - this.validate({document}) - } - } catch (error) { - this.log(error) - } - }) - - return this - } - - // Sets some callbacks and listening to the connection - start() { - this._documents.listen(this._connection) - this._connection.listen() - } - - createProcessor(settings) { - const processor = this._processor0() - for (const [plugin, options] of parsePlugins(settings.plugins)) { - processor.use(plugin, options) - } - - return processor - } - - // {document: TextDocument} - validate({document}) { - return this._processor - .process(document.getText()) - .then((vfile) => - vfile.messages - .map((message) => - Diagnostic.create( - { - start: convertPosition(message.location.start), - end: convertPosition(message.location.end) - }, - message.reason, - LangServer.DiagnosticSeverity.Hint, - message.actual, - message.source - ) - ) - .sort((_) => _.range.start.line) - ) - .then((diagnostics) => { - this._connection.sendDiagnostics({ - uri: document.uri, - diagnostics - }) - - return diagnostics - }) - .catch(this.log) - } - - log(x) { - this._connection.console.log(x.toString ? x.toString() : JSON.stringify(x)) - } -} - -module.exports = UnifiedLangServerBase diff --git a/src/index.spec.js b/src/index.spec.js deleted file mode 100644 index 2196ea7..0000000 --- a/src/index.spec.js +++ /dev/null @@ -1,309 +0,0 @@ -const test = require('tape') // TODO try 'ava' instead -const unified = require('unified') -const { - TextDocumentSyncKind, - DiagnosticSeverity -} = require('vscode-languageserver-protocol') -const {spy} = require('sinon') -const VMessage = require('vfile-message') - -const parser = require('retext-english') -const Base = require('./index.js') - -function compiler() { - this.Compiler = () => "compiler's output" -} - -const messagePushingAttacher = (vMessage) => () => (tree, file) => { - file.messages.push(vMessage) -} - -const textProcessor = unified().use(parser).use(compiler).freeze() - -const createMockConnection = () => ({ - console: { - log: spy() - }, - listen: spy(), - onInitialize: spy(), - onDidChangeConfiguration: spy(), - sendDiagnostics: spy() -}) -const createMockDocuments = (docs) => ({ - all: () => docs || [], - listen: spy(), - onDidChangeContent: spy() -}) -const createMockDocument = (txt, props) => - Object.assign(props || {}, { - getText: () => txt - }) - -const waitUntilCalled = (spy, timeout) => - new Promise((resolve, reject) => { - timeout = timeout || 1000 - let timePassed = 0 - - const timer = setInterval(() => { - timePassed += 200 - if (timePassed > timeout) { - clearInterval(timer) - reject(new Error("waited for the spy to be called but it wasn't")) - } else if (spy.called) { - clearInterval(timer) - resolve() - } - }, 200) - }) - -test('the constructor', (t) => { - t.plan(3) - - const connection = createMockConnection() - const documents = createMockDocuments() - - new Base(connection, documents, textProcessor) - - t.ok(connection.listen.notCalled, "listen() shouldn't be done implicitly") - t.ok(documents.listen.notCalled, "listen() shouldn't be done implicitly") - - t.deepEqual( - connection.onInitialize.firstCall.args[0](/* client capabilities */), - { - capabilities: { - textDocumentSync: TextDocumentSyncKind.Full - } - }, - 'server must report correct capabilities' - ) -}) - -test('setProcessor()', async (t) => { - t.plan(2) - - const plugin1 = messagePushingAttacher( - new VMessage( - 'msg one', - { - start: {line: 1, column: 5}, - end: {line: 2, column: 10} - }, - 'attacher1:rule1' - ) - ) - const plugin2 = messagePushingAttacher( - new VMessage( - 'msg two', - { - start: {line: 3, column: 6}, - end: {line: 4, column: 8} - }, - 'attacher2:rule1' - ) - ) - - const processor1 = textProcessor().use(plugin1) - const processor2 = textProcessor().use(plugin2) - - const base = new Base( - createMockConnection(), - createMockDocuments(), - processor1 - ) - - const doc = { - document: createMockDocument('', {uri: 'uri-01'}) - } - - t.deepEqual(await base.validate(doc), [ - { - range: { - start: {line: 0, character: 4}, - end: {line: 1, character: 9} - }, - message: 'msg one', - severity: DiagnosticSeverity.Hint, - source: 'attacher1' - } - ]) - - base.setProcessor(processor2) - - t.deepEqual(await base.validate(doc), [ - { - range: { - start: {line: 2, character: 5}, - end: {line: 3, character: 7} - }, - message: 'msg two', - severity: DiagnosticSeverity.Hint, - source: 'attacher2' - } - ]) -}) - -test('createProcessor()', (t) => { - const connection = createMockConnection() - const documents = createMockDocuments() - const TEXT = [ - 'spellinggg misstakes alll overr', - 'and carrot is spelled correctly but my personal dictionary dislikes it' - ].join('\n') - - const base = new Base(connection, documents, textProcessor) - - for (const [description] of [ - ['empty settings', {}], - [ - 'nonexistent modules', - { - plugins: [['#some-unknown-module-by-aecepoglu']] - } - ], - [ - 'nonexistent file', - { - plugins: [['//i-bet-this-file-doesnt-exist.txt']] - } - ] - ]) { - t.test(description, (st) => { - st.plan(1) - st.throws(() => { - base.createProcessor({}) - }, `error thrown for ${description}`) - }) - } - - t.test("defining modules with '#'", async (st) => { - st.plan(1) - - const myProcessor = base.createProcessor({ - plugins: [['#retext-spell', '#dictionary-en-gb']] - }) - - const abc = await myProcessor.process(TEXT) - st.deepEqual( - abc.messages.map((_) => _.actual), - ['spellinggg', 'misstakes', 'alll', 'overr'] - ) - }) - - t.skip("defining files with '//'", async (st) => { - st.plan(1) - - const myProcessor = base.createProcessor({ - plugins: [ - [ - '#retext-spell', - { - dictionary: '#dictionary-en-gb', - personal: '//./sample-dict.txt' - } - ] - ] - }) - - const abc = await myProcessor.process(TEXT) - st.deepEqual( - abc.messages.map((_) => _.actual), - ['spellinggg', 'misstakes', 'alll', 'overr', 'carrot'] - ) - }) -}) - -test('start() listens to connections', (t) => { - t.plan(2) - - const connection = createMockConnection() - const documents = createMockDocuments() - - const base = new Base(connection, documents, textProcessor) - - base.start() - - t.ok(connection.listen.called) - t.ok(documents.listen.calledWith(connection)) -}) - -test('configureWith() is used to listen to changes in settings and updating the processor with them', async (t) => { - t.plan(1) - - const connection = createMockConnection() - const documents = createMockDocuments([ - createMockDocument('text with a spellingg mistake', {uri: 'uri-01'}), - createMockDocument('proper text.', {uri: 'uri-02'}) - ]) - - new Base(connection, documents, textProcessor).configureWith( - (settings) => settings.some.obscure.path - ) - - connection.onDidChangeConfiguration.args[0][0]({ - some: { - obscure: { - path: { - plugins: [['#retext-spell', '#dictionary-en-gb']] - } - } - } - }) - - await waitUntilCalled(connection.sendDiagnostics) - - t.deepEqual( - connection.sendDiagnostics.args.sort((_) => _.uri), - [ - [ - { - uri: 'uri-01', - diagnostics: [ - { - range: { - start: {line: 0, character: 12}, - end: {line: 0, character: 12 + 'spellingg'.length} - }, - message: - '`spellingg` is misspelt; did you mean `spelling`, `spellings`?', - severity: DiagnosticSeverity.Hint, - code: 'spellingg', - source: 'retext-spell' - } - ] - } - ], - [ - { - uri: 'uri-02', - diagnostics: [] - } - ] - ], - 'diagnostics must be sent for all documents' - ) -}) - -test('the cb given to configureWith() throws an error', (t) => { - t.plan(2) - - const connection = createMockConnection() - const documents = createMockDocuments([ - createMockDocument('text with a spellingg mistake', {uri: 'uri-01'}), - createMockDocument('proper text.', {uri: 'uri-02'}) - ]) - - new Base(connection, documents, textProcessor).configureWith(() => { - throw new Error('the error thrown by the configuration filter function') - }) - - t.doesNotThrow(() => { - connection.onDidChangeConfiguration.args[0][0]('settings from the client') - }) - - t.ok( - connection.console.log.calledWith( - 'Error: the error thrown by the configuration filter function' - ), - 'error must be logged' - ) -}) diff --git a/src/sample-dict.txt b/src/sample-dict.txt deleted file mode 100644 index 8e2a9ac..0000000 --- a/src/sample-dict.txt +++ /dev/null @@ -1 +0,0 @@ -*carrot diff --git a/src/sample-servers/retext.js b/src/sample-servers/retext.js deleted file mode 100644 index fbeb4a2..0000000 --- a/src/sample-servers/retext.js +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/node - -const LangServer = require('vscode-languageserver') -const retext = require('retext') - -const Base = require('../unified-language-server/index.js') - -const DEFAULT_SETTINGS = { - plugins: [['#retext-profanities'], ['#retext-spell', '#dictionary-en-gb']] -} - -const connection = LangServer.createConnection(LangServer.ProposedFeatures.all) -const documents = new LangServer.TextDocuments() - -const server = new Base(connection, documents, retext) -server.setProcessor(server.createProcessor(DEFAULT_SETTINGS)) -server.configureWith( - (change) => change.settings['retext-language-server'] || DEFAULT_SETTINGS -) -server.start() diff --git a/src/server.js b/src/server.js deleted file mode 100755 index 66b6696..0000000 --- a/src/server.js +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env node -const process = require('process') -const LangServer = require('vscode-languageserver') -const unified = require('unified') - -const Base = require('./index.js') - -const ALL_SETTINGS = { - 'retext-english': { - plugins: [['#retext-spell', '#dictionary-en-gb']] - }, - 'remark-parse': { - plugins: [['#remark-preset-lint-markdown-style-guide']], - checkTextWith: { - setting: 'retext-english', - mutator: ['#remark-retext', '#parse-latin'] - } - } -} - -function stringify() { - this.Compiler = () => '' -} - -const withCommas = (list) => list.map((x) => `"${x}"`).join(', ') - -const mapObject = (object, f) => { - const result = {} - for (const [key, value] of Object.entries(object)) { - result[key] = f(value) - } - - return result -} - -const getArg = (prefix, isOptional) => { - const arg = process.argv.slice(2).find((_) => _.startsWith(prefix)) - - if (arg) { - return arg.slice(prefix.length) - } - - if (isOptional) { - return - } - - throw new Error(`Command line argument "${prefix}..." couldn't be found`) -} - -const populateTextPlugins = (settings) => - mapObject(settings, ({checkTextWith, plugins, ...rest}) => ({ - plugins: [ - ...plugins, - ...(checkTextWith - ? [checkTextWith.mutator, ...settings[checkTextWith.setting].plugins] - : []) - ], - ...rest - })) - -const validateSettings = (settings) => - mapObject(settings, ({checkTextWith, plugins, ...rest}, name) => { - if (Object.keys(rest).length > 0) { - console.warn( - 'The keys: ' + withCommas(Object.keys(rest)) + ' are not supported' - ) - } - - if (!Array.isArray(plugins)) { - throw new TypeError(`${name}.plugins should be a list`) - } - - if (!plugins.every((plugin) => Array.isArray(plugin))) { - throw new Error(`every item in ${name}.plugins should be a list.`) - } - - if (checkTextWith !== undefined) { - if (typeof checkTextWith !== 'object') { - // TODO make error more verbose - throw new TypeError( - 'checkTextWith must be undefined or an object with 2 fields:' + - '"setting" and "mutator".' - ) - } - - if (settings[checkTextWith.setting] === undefined) { - throw new Error( - 'checkTextWith.setting should be the name of an entry in your settings.' + - ' Candidates are: ' + - withCommas(Object.keys(settings)) - ) - } - - if (!Array.isArray(settings[checkTextWith.mutator]) !== true) { - throw new TypeError( - 'checkTextWith.mutator should be a plugin definition' + - ' (like those in "plugins")' - ) - } - } - - return {checkTextWith, plugins} - }) - -const validateAndProcessSettings = (s) => { - const resp = populateTextPlugins( - validateSettings(Object.assign({}, ALL_SETTINGS, s)) - )[parserName] - - if (resp) { - return resp - } - - throw new Error(`I don't know what the settings for ${parserName} is`) -} - -const parserName = getArg('--parser=') -const processor0 = (function () { - const parser = require(parserName) - - if (parser.Parser === undefined) { - throw new Error( - `The parser you have supplied (${parserName}) is not a valid unifiedJS parser.\n` + - 'The module needs to have a "Parser" method as described here: ' + - 'https://github.com/unifiedjs/unified#processorparser' - ) - } - - return unified().use(parser).use(stringify).freeze() -})() - -const connection = LangServer.createConnection(LangServer.ProposedFeatures.all) -const documents = new LangServer.TextDocuments() - -const server = new Base(connection, documents, processor0()) -server.setProcessor( - server.createProcessor(validateAndProcessSettings(undefined)) -) -server.configureWith((change) => - validateAndProcessSettings(change.settings['unified-language-server']) -) -server.start() diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..70c02ee --- /dev/null +++ b/test/index.js @@ -0,0 +1,451 @@ +import {pathToFileURL} from 'node:url' + +import {spy, stub} from 'sinon' +import test from 'tape' +import { + DiagnosticSeverity, + Position, + Range, + TextDocuments, + TextDocumentSyncKind, + TextEdit +} from 'vscode-languageserver/node.js' +import {TextDocument} from 'vscode-languageserver-textdocument' + +import {configureUnifiedLanguageServer} from '../lib/index.js' + +/** + * @returns {import('vscode-languageserver').Connection} + */ +function createMockConnection() { + return { + // @ts-expect-error The connection is missing here, which is ok for testing. + console: { + error: spy(), + info: spy(), + log: spy(), + warn: spy() + }, + listen: spy(), + onInitialize: spy(), + onDidChangeConfiguration: spy(), + onDidChangeWatchedFiles: spy(), + onDocumentFormatting: spy(), + sendDiagnostics: stub() + } +} + +/** + * @param {string} uri + * @param {string} text + * @returns {Promise} + */ +function getDiagnostic(uri, text) { + const connection = createMockConnection() + const documents = new TextDocuments(TextDocument) + const diagnosticsPromise = new Promise((resolve) => { + const sendDiagnostics = /** @type import('sinon').SinonStub */ ( + connection.sendDiagnostics + ) + sendDiagnostics.callsFake(resolve) + }) + const onDidChangeContent = spy() + Object.defineProperty(documents, 'onDidChangeContent', { + value: onDidChangeContent + }) + + configureUnifiedLanguageServer(connection, documents, { + plugins: ['./test/test-plugin.js'] + }) + + onDidChangeContent.firstCall.firstArg({ + document: TextDocument.create(uri, 'text', 0, text) + }) + + return diagnosticsPromise +} + +test('onInitialize', (t) => { + const connection = createMockConnection() + const documents = new TextDocuments(TextDocument) + + configureUnifiedLanguageServer(connection, documents, {}) + + const initialize = /** @type import('sinon').SinonSpy */ ( + connection.onInitialize + ).firstCall.firstArg + const result = initialize() + + t.deepEquals(result, { + capabilities: { + textDocumentSync: TextDocumentSyncKind.Full, + documentFormattingProvider: true + } + }) + + t.end() +}) + +test('onDocumentFormatting different', async (t) => { + const connection = createMockConnection() + const documents = new TextDocuments(TextDocument) + const uri = String(pathToFileURL('test.md')) + const get = stub(documents, 'get').returns( + TextDocument.create(uri, 'markdown', 0, '# Hello world!') + ) + + configureUnifiedLanguageServer(connection, documents, { + plugins: ['remark-parse', 'remark-stringify'] + }) + + const formatDocument = /** @type import('sinon').SinonSpy */ ( + connection.onDocumentFormatting + ).firstCall.firstArg + const result = await formatDocument({textDocument: {uri}}) + + t.deepEquals(get.firstCall.args, [uri]) + t.deepEquals(result, [ + TextEdit.replace( + Range.create(Position.create(0, 0), Position.create(0, 16)), + '# Hello world!\n' + ) + ]) + + t.end() +}) + +test('onDocumentFormatting not found', async (t) => { + const connection = createMockConnection() + const documents = new TextDocuments(TextDocument) + const uri = String(pathToFileURL('test.md')) + + configureUnifiedLanguageServer(connection, documents, { + plugins: ['remark-parse', 'remark-stringify'] + }) + + const formatDocument = /** @type import('sinon').SinonSpy */ ( + connection.onDocumentFormatting + ).firstCall.firstArg + const result = await formatDocument({textDocument: {uri}}) + + t.deepEquals(result, undefined) + + t.end() +}) + +test('onDocumentFormatting equal', async (t) => { + const connection = createMockConnection() + const documents = new TextDocuments(TextDocument) + const uri = String(pathToFileURL('test.md')) + stub(documents, 'get').returns( + TextDocument.create(uri, 'markdown', 0, '# Hello world!\n') + ) + + configureUnifiedLanguageServer(connection, documents, { + plugins: ['remark-parse', 'remark-stringify'] + }) + + const formatDocument = /** @type import('sinon').SinonSpy */ ( + connection.onDocumentFormatting + ).firstCall.firstArg + const result = await formatDocument({textDocument: {uri}}) + + t.deepEquals(result, undefined) + + t.end() +}) + +test('onDidChangeContent no position', async (t) => { + const uri = String(pathToFileURL('test.md')) + const diagnostics = await getDiagnostic(uri, 'no position') + + t.deepEquals(diagnostics, { + uri, + version: 0, + diagnostics: [ + { + range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, + message: 'no position', + severity: DiagnosticSeverity.Warning + } + ] + }) + + t.end() +}) + +test('onDidChangeContent no end', async (t) => { + const uri = String(pathToFileURL('test.md')) + const diagnostics = await getDiagnostic(uri, 'no end') + + t.deepEquals(diagnostics, { + uri, + version: 0, + diagnostics: [ + { + range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, + message: 'no end', + severity: DiagnosticSeverity.Warning + } + ] + }) + + t.end() +}) + +test('onDidChangeContent start end', async (t) => { + const uri = String(pathToFileURL('test.md')) + const diagnostics = await getDiagnostic(uri, 'start end') + + t.deepEquals(diagnostics, { + uri, + version: 0, + diagnostics: [ + { + range: {start: {line: 0, character: 0}, end: {line: 1, character: 9}}, + message: 'start end', + severity: DiagnosticSeverity.Warning + } + ] + }) + + t.end() +}) + +test('onDidChangeContent no start', async (t) => { + const uri = String(pathToFileURL('test.md')) + const diagnostics = await getDiagnostic(uri, 'no start') + + t.deepEquals(diagnostics, { + uri, + version: 0, + diagnostics: [ + { + range: {start: {line: 1, character: 9}, end: {line: 1, character: 9}}, + message: 'no start', + severity: DiagnosticSeverity.Warning + } + ] + }) + + t.end() +}) + +test('onDidChangeContent fatal true', async (t) => { + const uri = String(pathToFileURL('test.md')) + const diagnostics = await getDiagnostic(uri, 'fatal true') + + t.deepEquals(diagnostics, { + uri, + version: 0, + diagnostics: [ + { + range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, + message: 'fatal true', + severity: DiagnosticSeverity.Error + } + ] + }) + + t.end() +}) + +test('onDidChangeContent fatal unknown', async (t) => { + const uri = String(pathToFileURL('test.md')) + const diagnostics = await getDiagnostic(uri, 'fatal unknown') + + t.deepEquals(diagnostics, { + uri, + version: 0, + diagnostics: [ + { + range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, + message: 'fatal unknown', + severity: DiagnosticSeverity.Information + } + ] + }) + + t.end() +}) + +test('onDidChangeContent has ruleId', async (t) => { + const uri = String(pathToFileURL('test.md')) + const diagnostics = await getDiagnostic(uri, 'has ruleId') + + t.deepEquals(diagnostics, { + uri, + version: 0, + diagnostics: [ + { + code: 'test-rule', + range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, + message: 'has ruleId', + severity: DiagnosticSeverity.Warning + } + ] + }) + + t.end() +}) + +test('onDidChangeContent has source', async (t) => { + const uri = String(pathToFileURL('test.md')) + const diagnostics = await getDiagnostic(uri, 'has source') + + t.deepEquals(diagnostics, { + uri, + version: 0, + diagnostics: [ + { + range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, + message: 'has source', + source: 'test-source', + severity: DiagnosticSeverity.Warning + } + ] + }) + + t.end() +}) + +test('onDidChangeContent has url', async (t) => { + const uri = String(pathToFileURL('test.md')) + const diagnostics = await getDiagnostic(uri, 'has url') + + t.deepEquals(diagnostics, { + uri, + version: 0, + diagnostics: [ + { + codeDescription: { + href: 'https://example.com' + }, + range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, + message: 'has url', + severity: DiagnosticSeverity.Warning + } + ] + }) + + t.end() +}) + +test('onDidChangeContent has error', async (t) => { + const uri = String(pathToFileURL('test.md')) + const diagnostics = await getDiagnostic(uri, 'has error') + + t.deepEquals(diagnostics, { + uri, + version: 0, + diagnostics: [ + { + range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, + message: 'Test error', + severity: DiagnosticSeverity.Error + } + ] + }) + + t.end() +}) + +test('onDidClose', async (t) => { + const connection = createMockConnection() + const documents = new TextDocuments(TextDocument) + const uri = String(pathToFileURL('test.md')) + const diagnosticsPromise = new Promise((resolve) => { + const sendDiagnostics = /** @type import('sinon').SinonStub */ ( + connection.sendDiagnostics + ) + sendDiagnostics.callsFake(resolve) + }) + const onDidClose = spy() + Object.defineProperty(documents, 'onDidClose', { + value: onDidClose + }) + + configureUnifiedLanguageServer(connection, documents, { + plugins: ['./test/test-plugin.js'] + }) + + onDidClose.firstCall.firstArg({ + document: TextDocument.create(uri, 'text', 0, '') + }) + + const diagnostics = await diagnosticsPromise + + t.deepEquals(diagnostics, { + uri, + version: 0, + diagnostics: [] + }) + + t.end() +}) + +test('onDidChangeWatchedFiles', async (t) => { + const connection = createMockConnection() + const documents = new TextDocuments(TextDocument) + const diagnosticsPromise = new Promise((resolve) => { + const sendDiagnostics = /** @type import('sinon').SinonStub */ ( + connection.sendDiagnostics + ) + sendDiagnostics.callsFake(() => { + if (sendDiagnostics.callCount === 2) { + resolve([ + sendDiagnostics.firstCall.firstArg, + sendDiagnostics.lastCall.firstArg + ]) + } + }) + }) + const uri1 = String(pathToFileURL('test1.md')) + const uri2 = String(pathToFileURL('test2.md')) + + Object.defineProperty(documents, 'all', { + value: () => [ + TextDocument.create(uri1, 'text', 0, 'has ruleId'), + TextDocument.create(uri2, 'text', 0, 'has source') + ] + }) + + configureUnifiedLanguageServer(connection, documents, { + plugins: ['./test/test-plugin.js'] + }) + + const onDidChangeWatchedFiles = /** @type import('sinon').SinonSpy */ ( + connection.onDidChangeWatchedFiles + ) + onDidChangeWatchedFiles.firstCall.firstArg() + const diagnostics = await diagnosticsPromise + + t.deepEquals(diagnostics, [ + { + uri: uri1, + version: 0, + diagnostics: [ + { + range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, + message: 'has ruleId', + code: 'test-rule', + severity: DiagnosticSeverity.Warning + } + ] + }, + { + uri: uri2, + version: 0, + diagnostics: [ + { + range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, + message: 'has source', + source: 'test-source', + severity: DiagnosticSeverity.Warning + } + ] + } + ]) + + t.end() +}) diff --git a/test/test-plugin.js b/test/test-plugin.js new file mode 100644 index 0000000..89c0cda --- /dev/null +++ b/test/test-plugin.js @@ -0,0 +1,63 @@ +/** + * @type import('unified').Plugin + */ +export default function unifiedTestPlugin() { + this.Parser = () => ({type: 'root'}) + this.Compiler = () => 'Formatted output\n' + + return (ast, file) => { + const value = String(file) + if (value.includes('no position')) { + file.message('no position') + } + + if (value.includes('no end')) { + file.message('no end', {line: 1, column: 1}) + } + + if (value.includes('start end')) { + file.message('start end', { + start: {line: 1, column: 1}, + end: {line: 2, column: 10} + }) + } + + if (value.includes('no start')) { + file.message('no start', { + // @ts-expect-error Some plugins report this. The language server should + // handle it. + start: {line: null, column: null}, + end: {line: 2, column: 10} + }) + } + + if (value.includes('fatal true')) { + const message = file.message('fatal true') + message.fatal = true + } + + if (value.includes('fatal unknown')) { + const message = file.message('fatal unknown') + message.fatal = null + } + + if (value.includes('has ruleId')) { + const message = file.message('has ruleId') + message.ruleId = 'test-rule' + } + + if (value.includes('has source')) { + const message = file.message('has source') + message.source = 'test-source' + } + + if (value.includes('has url')) { + const message = file.message('has url') + message.url = 'https://example.com' + } + + if (value.includes('has error')) { + throw new Error('Test error') + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..53e3768 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "include": ["*.js", "test/*.js"], + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "checkJs": true, + "declaration": true, + "emitDeclarationOnly": true, + "lib": ["ES2020"], + "module": "ES2020", + "moduleResolution": "node", + "skipLibCheck": true, + "strict": true, + "target": "ES2020" + } +}