├── .commitlintrc.json ├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .lintstagedrc.js ├── .ncurc ├── .vscode ├── launch.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── client ├── cache.ts ├── extension.ts ├── fix-all-problems.ts └── status-bar.ts ├── icon.png ├── license ├── media ├── fix.gif └── xo-logo.svg ├── package-lock.json ├── package.json ├── readme.md ├── screenshot.png ├── scripts ├── generate-icon.js └── xo.woff ├── server ├── code-actions-builder.ts ├── fix-builder.ts ├── get-document-config.ts ├── get-document-folder.ts ├── get-document-formatting.ts ├── get-lint-results.ts ├── index.ts ├── lint-document.ts ├── logger.ts ├── modules.d.ts ├── resolve-xo.ts ├── server.ts ├── types.ts └── utils.ts ├── test ├── code-actions-builder.test.ts ├── index.ts ├── lsp │ ├── code-actions.test.ts │ ├── document-sync.test.ts │ └── initialization.test.ts ├── server.test.ts ├── stubs.ts └── tests.md ├── tsconfig.build.json ├── tsconfig.json └── xo.config.ts /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: ["master"] 9 | pull_request: 10 | branches: ["master"] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [20, 22, 23] 19 | name: Node ${{ matrix.node-version }} 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Setup Node 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - run: npm install 27 | - run: npm test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.vsix 4 | 5 | coverage 6 | .history 7 | 8 | .DS_Store 9 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | npx --no-install commitlint --edit $1 3 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | npm run check && npx --no-install lint-staged 3 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.md': 'prettier --write', 3 | 'package.json': 'prettier --write', 4 | '*.ts': 'xo --fix' 5 | }; 6 | -------------------------------------------------------------------------------- /.ncurc: -------------------------------------------------------------------------------- 1 | { 2 | "reject": [ 3 | "auto-bind", 4 | "load-json-file", 5 | "queue" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | { 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "type": "extensionHost", 7 | "request": "launch", 8 | "name": "Launch Extension", 9 | "runtimeExecutable": "${execPath}", 10 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 11 | "sourceMaps": true, 12 | "outFiles": ["${workspaceFolder}/dist/**/*"], 13 | "preLaunchTask": "npm: build:dev" 14 | }, 15 | { 16 | "type": "node", 17 | "request": "attach", 18 | "name": "Attach to Server", 19 | "address": "localhost", 20 | "port": 6004, 21 | "sourceMaps": true, 22 | "outFiles": ["${workspaceFolder}/dist/**/*"] 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "watch", 8 | "dependsOn": [ 9 | "npm: watch:client", "npm: watch:server" 10 | ], 11 | "group": { 12 | "kind": "build", 13 | "isDefault": true 14 | }, 15 | "problemMatcher": [] 16 | }, 17 | { 18 | "type": "npm", 19 | "script": "watch:client", 20 | "isBackground": true, 21 | "group": "build", 22 | "presentation": { 23 | "reveal": "never", 24 | "panel": "dedicated" 25 | }, 26 | "problemMatcher": [ 27 | "$tsc-watch" 28 | ] 29 | }, 30 | { 31 | "type": "npm", 32 | "script": "watch:server", 33 | "isBackground": true, 34 | "group": "build", 35 | "presentation": { 36 | "reveal": "never", 37 | "panel": "dedicated" 38 | }, 39 | "problemMatcher": [ 40 | "$tsc-watch" 41 | ] 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | **/* 2 | !dist 3 | !package.json 4 | !readme.md 5 | !*.png 6 | !CHANGELOG.md 7 | !LICENSE 8 | !scripts 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### v3.22.0 2 | 3 | - support eslint suggestions as quick fixes 4 | 5 | ### v3.21.0 6 | 7 | - Updates deps 8 | - Switches to flat-xo beta linter 9 | - Fixes issues with source.fixAll.xo code actions on save 10 | 11 | ### v3.20.0 12 | 13 | - adds source.fixAll.xo code action 14 | - restarts extension on config changes so all caches can be reset 15 | 16 | ### v3.19.0 17 | 18 | - switched lodash debounce to p-debounce to fix concurrency bugs (hopefully do not see performance regressions from this change) 19 | - added better checking and respect for the enable option (closes #139) 20 | 21 | ### v3.18.0 22 | 23 | - Refactors code actions handlers and support source.fixAll code action 24 | - Adds tests for the server. Finally 🚀. 25 | 26 | ### v3.17.1 27 | 28 | - fix bug for status bar and server starting for non-xo js/ts files. 29 | 30 | ### v3.17.0 31 | 32 | - Client refactor and status bar icon only shows on relevant xo files. Server will not start until a relevant xo file is open, which helps reduce the extension overhead when a non xo repo is open. 33 | - handles validate option change without refresh window hack. 34 | 35 | ### v3.16.0 36 | 37 | - No longer lint node_modules. Resolves [#131](https://github.com/xojs/vscode-linter-xo/issues/131) 38 | 39 | ### v3.15.1 40 | 41 | - fix a bug where an extra line was added when a an ignore-same-line code action was requested 42 | 43 | ### v3.15.0 44 | 45 | - Instead of alerting user for document version mismatch, we send the correct LSP error code to the client for this information, where the language client can decide what to do with the error. Resolves [#128](https://github.com/xojs/vscode-linter-xo/issues/128). 46 | 47 | ### v3.14.0 48 | 49 | - Internally refactored back to typescript since the compilation has been fully supported since 4.7 release. 50 | - Now supports range formatting and `editor.formatOnSaveMode` set to `"modifications"`. 51 | 52 | ### v3.13.0 53 | 54 | - support full formatting to match xo cli output 55 | 56 | ### v3.12.0 57 | 58 | - Refactor and architectural changes to support better logic around xo resolution 59 | - Previously required xo to be in the root folder of the vscode workspace 60 | - Now only requires that xo is a dependency in any parent directory. The extension now looks up from the file it is linting for a package.json with xo as a dependency. 61 | - Caching now happens on a per folder basis and is cleaned up as files are closed and recached when they open. This helps simplify logic and able to remove a lot of supporting code and alleviates problems from stale cache. 62 | - fixes a bug where eslint-plugins/configs without docs would throw an error 63 | 64 | ### v3.11.0 65 | 66 | - Adds validate option to allow formatting more file types 67 | 68 | ### v3.10.0 69 | 70 | - Adds ignore rule Code Actions for both single line or file. 71 | - Adds logic to use metaResults from xo .51+ and fallback to eslint-rule-docs for older versions 72 | - Internal improves fixing logic for overlapping rules 73 | - Move from objects to Maps for rule caching 74 | 75 | ### v3.9.0 76 | 77 | - Adds links to rule documents 78 | 79 | ### v3.8.1 80 | 81 | - Diagnostics now underline the entire diagnostic, rather than only the first character (closes #87) 82 | 83 | ### 3.8 84 | 85 | - If a file is opened without a workspace folder, linter-xo will attempt to resolve the project root and lint appropriately. 86 | 87 | ### 3.7 88 | 89 | - Changes "xo.path" setting from a file uri to a path or relative path. 90 | 91 | ### 3.6 92 | 93 | - Adds a configuration for node runtime (closes #103) 94 | 95 | ### 3.5 96 | 97 | - Adds a configuration for a custom xo path for xo to resolve from. 98 | 99 | ### 3.4 100 | 101 | - Make debounce configurable and default to 0 102 | - Replace internal Queue 103 | - Handle resolution error with more sophistication 104 | - Initial support for quick fix code actions 105 | - Added a status bar item and command to show the extension output 106 | - Handle options better for per folder configurations in multi-root workspaces 107 | 108 | ### 3.3.2 109 | 110 | - patch error message to only show once per session 111 | 112 | ### 3.3.1 113 | 114 | - fix bug with windows path 115 | 116 | ### 3.3.0 117 | 118 | - Support multi-root workspaces completely 119 | - Internal refactoring for a much cleaner and clearer linter class. 120 | - Removes the need for xo to be in package.json and will attempt to resolve xo regardless. 121 | - Handle errors more gracefully 122 | - Adds some debouncing to lint requests which significantly improves large file performance 123 | 124 | ### 3.2.0 125 | 126 | - Add overrideSeverity option 127 | 128 | ### 3.1.2 129 | 130 | - Update docs 131 | - Remove internal options from being printed 132 | 133 | ### 3.0.0 134 | 135 | - massive refactor 136 | - drop TS 137 | - fix XO compatibility issues 138 | -------------------------------------------------------------------------------- /client/cache.ts: -------------------------------------------------------------------------------- 1 | import {type LogOutputChannel} from 'vscode'; 2 | import {findXoRoot} from '../server/utils'; 3 | 4 | /** 5 | * Cache for file fspaths that have an xo root 6 | * in their directory tree. 7 | */ 8 | export class XoRootCache { 9 | logger?: LogOutputChannel; 10 | 11 | private readonly cache: Map; 12 | 13 | constructor({logger}: {logger?: LogOutputChannel} = {}) { 14 | this.cache = new Map(); 15 | this.logger = logger; 16 | } 17 | 18 | async get(uri?: string) { 19 | try { 20 | if (!uri) { 21 | return; 22 | } 23 | 24 | const cached = this.cache.get(uri); 25 | if (cached) { 26 | return cached; 27 | } 28 | 29 | const xoRoot = await findXoRoot(uri); 30 | 31 | const isXoFile = Boolean(xoRoot?.pkgPath); 32 | 33 | if (xoRoot) { 34 | this.cache.set(uri, isXoFile); 35 | } 36 | 37 | return isXoFile; 38 | } catch (error) { 39 | if (error instanceof Error) { 40 | this.logger?.error(error); 41 | } 42 | } 43 | } 44 | 45 | delete(uri: string) { 46 | this.cache.delete(uri); 47 | } 48 | } 49 | 50 | export const xoRootCache = new XoRootCache(); 51 | -------------------------------------------------------------------------------- /client/extension.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import { 3 | type ConfigurationChangeEvent, 4 | type ExtensionContext, 5 | workspace, 6 | window, 7 | commands 8 | } from 'vscode'; 9 | import { 10 | LanguageClient, 11 | TransportKind, 12 | type LanguageClientOptions, 13 | type ServerOptions 14 | } from 'vscode-languageclient/node'; 15 | import Queue from 'queue'; 16 | import pkg from '../package.json'; 17 | import {updateStatusBar} from './status-bar'; 18 | import {xoRootCache} from './cache'; 19 | import {fixAllProblems} from './fix-all-problems'; 20 | 21 | const languageClient: LanguageClient | undefined = undefined; 22 | 23 | const queue = new Queue({autostart: true, concurrency: 1}); 24 | 25 | export async function activate(context: ExtensionContext) { 26 | const logger = window.createOutputChannel('xo', {log: true}); 27 | xoRootCache.logger = logger; 28 | 29 | logger.info(`[client] Activating XO extension v${pkg.version}`); 30 | 31 | const xoConfig = workspace.getConfiguration('xo'); 32 | const runtime = xoConfig.get('runtime'); 33 | const hasValidXoRoot = await xoRootCache.get(window.activeTextEditor?.document.uri.fsPath); 34 | 35 | const serverModule = context.asAbsolutePath('dist/server.js'); 36 | 37 | const serverOptions: ServerOptions = { 38 | run: { 39 | module: serverModule, 40 | runtime, 41 | transport: TransportKind.ipc, 42 | options: {cwd: process.cwd()} 43 | }, 44 | debug: { 45 | module: serverModule, 46 | runtime, 47 | transport: TransportKind.ipc, 48 | options: { 49 | execArgv: ['--nolazy', '--inspect=6004'], 50 | cwd: process.cwd() 51 | } 52 | } 53 | }; 54 | 55 | const clientOptions: LanguageClientOptions = { 56 | documentSelector: xoConfig.get('validate', []).flatMap((language) => [ 57 | {language, scheme: 'file'}, 58 | {language, scheme: 'untitled'} 59 | ]), 60 | outputChannel: logger, 61 | synchronize: { 62 | configurationSection: 'xo' 63 | } 64 | }; 65 | const languageClient = new LanguageClient('xo', serverOptions, clientOptions); 66 | 67 | const restart = async () => { 68 | try { 69 | logger.info('[client] Restarting client'); 70 | await languageClient.restart(); 71 | logger.info('[client] Restarting client success'); 72 | } catch (error) { 73 | languageClient.error(`[client] Restarting client failed`, error, 'force'); 74 | throw error; 75 | } 76 | }; 77 | 78 | /** 79 | * Update status bar on activation, and dispose of the status bar when the extension is deactivated 80 | */ 81 | const statusBar = await updateStatusBar(window.activeTextEditor?.document); 82 | 83 | context.subscriptions.push( 84 | /** 85 | * register xo extensions provided commands 86 | */ 87 | commands.registerCommand('xo.fix', async () => fixAllProblems(languageClient)), 88 | commands.registerCommand('xo.showOutputChannel', () => { 89 | logger.show(); 90 | }), 91 | commands.registerCommand('xo.restart', restart), 92 | ...[ 93 | // we relint all open textDocuments whenever a config changes 94 | // that may possibly affect the options xo should be using 95 | workspace.createFileSystemWatcher('**/.eslintignore'), 96 | workspace.createFileSystemWatcher('**/.xo-confi{g.cjs,g.json,g.js,g}'), 97 | workspace.createFileSystemWatcher('**/xo.confi{g.cjs,g.js,g.ts,g.cts,g.mts}'), 98 | workspace.createFileSystemWatcher('**/package.json') 99 | ].map((watcher) => watcher.onDidChange(restart)), 100 | /** 101 | * react to config changes - if the `xo.validate` setting changes, we need to restart the client 102 | */ 103 | workspace.onDidChangeConfiguration((configChange: ConfigurationChangeEvent) => { 104 | queue.push(async () => { 105 | try { 106 | logger.debug('[client] Configuration change detected'); 107 | 108 | const isValidateChanged = configChange.affectsConfiguration('xo.validate'); 109 | 110 | if (isValidateChanged) { 111 | logger.info( 112 | '[client] xo.validate change detected, restarting client with new options.' 113 | ); 114 | 115 | statusBar.text = '$(gear~spin)'; 116 | statusBar.show(); 117 | 118 | languageClient.clientOptions.documentSelector = xoConfig 119 | .get('validate', []) 120 | .flatMap((language) => [ 121 | {language, scheme: 'file'}, 122 | {language, scheme: 'untitled'} 123 | ]); 124 | 125 | await restart(); 126 | 127 | statusBar.text = '$(xo-logo)'; 128 | 129 | statusBar.hide(); 130 | logger.info('[client] Restarted client with new xo.validate options.'); 131 | } 132 | } catch (error) { 133 | if (error instanceof Error) { 134 | logger.error(`[client] There was a problem handling the configuration change.`); 135 | logger.error(error); 136 | } 137 | } 138 | }); 139 | }), 140 | /** 141 | * Only show status bar on relevant files where xo is set up to lint 142 | * updated on every active editor change, also check if we should start the 143 | * server for the first time if xo wasn't originally in the workspace 144 | */ 145 | window.onDidChangeActiveTextEditor((textEditor) => { 146 | queue.push(async () => { 147 | try { 148 | const {document: textDocument} = textEditor ?? {}; 149 | 150 | logger.debug('[client] onDidChangeActiveTextEditor', textDocument?.uri.fsPath); 151 | 152 | const isEnabled = workspace 153 | .getConfiguration('xo', textDocument) 154 | .get('enable', true); 155 | 156 | if (!isEnabled) { 157 | logger.debug('[client] onDidChangeActiveTextEditor > XO is not enabled'); 158 | return; 159 | } 160 | 161 | await updateStatusBar(textDocument); 162 | 163 | if ( 164 | isEnabled && 165 | textDocument && 166 | languageClient.needsStart() && 167 | (await xoRootCache.get(textDocument.uri.fsPath)) 168 | ) { 169 | logger.debug('[client] Starting Language Client'); 170 | await languageClient.start(); 171 | } 172 | } catch (error) { 173 | if (error instanceof Error) { 174 | statusBar.text = '$(xo-logo)'; 175 | logger.error(`[client] There was a problem handling the active text editor change.`); 176 | logger.error(error); 177 | } 178 | } 179 | }); 180 | }), 181 | /** 182 | * Check again whether or not we need a server instance 183 | * if folders are added are removed from the workspace 184 | */ 185 | workspace.onDidCloseTextDocument((textDocument) => { 186 | queue.push(async () => { 187 | xoRootCache.delete(textDocument.uri.fsPath); 188 | }); 189 | }), 190 | /** 191 | * Dispose of the status bar when the extension is deactivated 192 | */ 193 | statusBar 194 | ); 195 | 196 | if (hasValidXoRoot) { 197 | logger.info('[client] XO is enabled and is needed for linting file, server is now starting.'); 198 | await languageClient.start(); 199 | context.subscriptions.push(languageClient); 200 | return; 201 | } 202 | 203 | if (!hasValidXoRoot) { 204 | logger.info('[client] XO is enabled and server will start when a relevant file is opened.'); 205 | } 206 | } 207 | 208 | export async function deactivate() { 209 | if (!languageClient) { 210 | return undefined; 211 | } 212 | 213 | return languageClient.stop(); 214 | } 215 | -------------------------------------------------------------------------------- /client/fix-all-problems.ts: -------------------------------------------------------------------------------- 1 | import {type LanguageClient, type TextEdit, RequestType} from 'vscode-languageclient/node'; 2 | import * as vscode from 'vscode'; 3 | import {type DocumentFix} from '../server/types'; 4 | 5 | // eslint-disable-next-line @typescript-eslint/naming-convention 6 | const AllFixesRequest = { 7 | type: new RequestType('textDocument/xo/allFixes') 8 | }; 9 | 10 | export async function fixAllProblems(client: LanguageClient) { 11 | const textEditor = vscode.window.activeTextEditor; 12 | if (!textEditor) { 13 | return; 14 | } 15 | 16 | const uri = textEditor.document.uri.toString(); 17 | 18 | const result = (await client.sendRequest(AllFixesRequest.type, { 19 | textDocument: {uri} 20 | })) as DocumentFix; 21 | 22 | try { 23 | await applyTextEdits(uri, Number(result.documentVersion), result.edits, client); 24 | } catch { 25 | await vscode.window.showErrorMessage( 26 | 'Failed to apply xo fixes to the document. Please consider opening an issue with steps to reproduce.' 27 | ); 28 | } 29 | } 30 | 31 | async function applyTextEdits( 32 | uri: string, 33 | documentVersion: number, 34 | edits: TextEdit[], 35 | client: LanguageClient 36 | ) { 37 | const textEditor = vscode.window.activeTextEditor; 38 | if (textEditor?.document.uri.toString() === uri) { 39 | if (textEditor.document.version !== documentVersion) { 40 | await vscode.window.showInformationMessage( 41 | "xo fixes are outdated and can't be applied to the document." 42 | ); 43 | } 44 | 45 | const success = await textEditor.edit((mutator) => { 46 | for (const edit of edits) { 47 | mutator.replace(client.protocol2CodeConverter.asRange(edit.range), edit.newText); 48 | } 49 | }); 50 | 51 | if (!success) { 52 | await vscode.window.showErrorMessage( 53 | 'Failed to apply xo fixes to the document. Please consider opening an issue with steps to reproduce.' 54 | ); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /client/status-bar.ts: -------------------------------------------------------------------------------- 1 | import {workspace, window, type StatusBarItem, type TextDocument} from 'vscode'; 2 | import {xoRootCache} from './cache'; 3 | 4 | const statusBar = window.createStatusBarItem('xoStatusBarItem', 2, 0); 5 | statusBar.name = 'xo'; 6 | statusBar.text = '$(xo-logo)'; 7 | statusBar.command = 'xo.showOutputChannel'; 8 | statusBar.tooltip = 'Show XO output channel'; 9 | 10 | export async function updateStatusBar(textDocument?: TextDocument): Promise { 11 | try { 12 | const xoConfig = workspace.getConfiguration('xo', textDocument); 13 | 14 | const statusBarOption = 15 | xoConfig.get<'Always' | 'Never' | 'Relevant'>('statusBar') ?? 'Relevant'; 16 | if (statusBarOption === 'Never') { 17 | statusBar.hide(); 18 | return statusBar; 19 | } 20 | 21 | statusBar.text = '$(gear~spin)'; 22 | statusBar.show(); 23 | 24 | if (statusBarOption === 'Always') { 25 | statusBar.show(); 26 | return statusBar; 27 | } 28 | 29 | if (!textDocument) { 30 | statusBar.hide(); 31 | return statusBar; 32 | } 33 | 34 | const languages = xoConfig.get('validate', [ 35 | 'javascript', 36 | 'javascriptreact', 37 | 'typescript', 38 | 'typescriptreact', 39 | 'vue' 40 | ]); 41 | const isXoOutputChannel = textDocument.uri.fsPath === 'samverschueren.linter-xo.xo'; 42 | const isRelevantLanguage = languages.includes(textDocument.languageId); 43 | const hasXoRoot = await xoRootCache.get(textDocument.uri.fsPath); 44 | 45 | const isRelevant = isXoOutputChannel || (isRelevantLanguage && hasXoRoot); 46 | 47 | if (isRelevant) statusBar.show(); 48 | else statusBar.hide(); 49 | 50 | statusBar.text = '$(xo-logo)'; 51 | return statusBar; 52 | } catch { 53 | return statusBar; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xojs/vscode-linter-xo/413516ef46e10ba8a8cc82c3f3e0f446d551f036/icon.png -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Sam Verschueren 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /media/fix.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xojs/vscode-linter-xo/413516ef46e10ba8a8cc82c3f3e0f446d551f036/media/fix.gif -------------------------------------------------------------------------------- /media/xo-logo.svg: -------------------------------------------------------------------------------- 1 | XO -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "linter-xo", 3 | "displayName": "xo", 4 | "version": "3.22.1", 5 | "description": "Linter for XO", 6 | "categories": [ 7 | "Linters", 8 | "Formatters" 9 | ], 10 | "keywords": [ 11 | "code style", 12 | "eslint", 13 | "formatter", 14 | "linter", 15 | "xo", 16 | "xojs", 17 | "vscode", 18 | "lsp" 19 | ], 20 | "homepage": "https://github.com/xojs/xo", 21 | "bugs": { 22 | "url": "https://github.com/xojs/vscode-linter-xo/issues" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/xojs/vscode-linter-xo" 27 | }, 28 | "license": "MIT", 29 | "author": { 30 | "name": "Spencer Snyder", 31 | "email": "sasnyde2@gmail.com", 32 | "url": "https://spencersnyder.io" 33 | }, 34 | "publisher": "samverschueren", 35 | "type": "commonjs", 36 | "main": "./dist/client.js", 37 | "scripts": { 38 | "build": "npm run clean && npm run build:client -- --minify && npm run build:server -- --minify", 39 | "build:client": "esbuild ./client/extension.ts --bundle --outfile=dist/client.js --external:vscode --format=cjs --platform=node", 40 | "build:dev": "npm run clean && npm run build:client -- --sourcemap && npm run build:server -- --sourcemap", 41 | "build:server": "esbuild ./server/index.ts --bundle --outfile=dist/server.js --external:vscode --format=cjs --platform=node", 42 | "check": "tsc --project ./tsconfig.json", 43 | "clean": "rimraf dist & rimraf server/dist & rimraf client/dist", 44 | "lint": "xo && npm run lint:md", 45 | "lint:md": "prettier --parser markdown '**/*.md' --check", 46 | "package": "rimraf *.vsix && vsce package", 47 | "prepare": "husky", 48 | "test": "node --require tsx/cjs --test test/index.ts", 49 | "test:coverage": "c8 node --require tsx/cjs --test test/index.ts", 50 | "test:watch": "node --require tsx/cjs --watch --test test/index.ts", 51 | "vscode:prepublish": "npm run check && npm run build" 52 | }, 53 | "contributes": { 54 | "commands": [ 55 | { 56 | "title": "Fix all auto-fixable problems", 57 | "category": "xo", 58 | "command": "xo.fix" 59 | }, 60 | { 61 | "title": "Show Output Channel", 62 | "category": "xo", 63 | "command": "xo.showOutputChannel" 64 | }, 65 | { 66 | "title": "Restart XO linter", 67 | "category": "xo", 68 | "command": "xo.restart" 69 | } 70 | ], 71 | "configuration": { 72 | "type": "object", 73 | "title": "xo", 74 | "properties": { 75 | "xo.enable": { 76 | "scope": "resource", 77 | "type": "boolean", 78 | "default": true, 79 | "description": "Control whether xo is enabled or not." 80 | }, 81 | "xo.options": { 82 | "scope": "resource", 83 | "type": "object", 84 | "default": {}, 85 | "description": "The xo options object to provide args to the xo command." 86 | }, 87 | "xo.format.enable": { 88 | "scope": "resource", 89 | "type": "boolean", 90 | "default": false, 91 | "description": "Enable 'xo --fix' as formatter" 92 | }, 93 | "xo.overrideSeverity": { 94 | "scope": "resource", 95 | "type": "string", 96 | "default": "off", 97 | "enum": [ 98 | "off", 99 | "info", 100 | "warn", 101 | "error" 102 | ], 103 | "description": "Override the severity of found issues." 104 | }, 105 | "xo.debounce": { 106 | "scope": "window", 107 | "type": "number", 108 | "default": 0, 109 | "minimum": 0, 110 | "maximum": 350, 111 | "description": "A number between 0 and 350 to debounce lint requests for xo. A higher number can improve performance on large files but may make performance worse on small files." 112 | }, 113 | "xo.trace.server": { 114 | "scope": "window", 115 | "type": "string", 116 | "enum": [ 117 | "off", 118 | "messages", 119 | "verbose" 120 | ], 121 | "default": "off", 122 | "description": "Traces the communication between VS Code and the xo server." 123 | }, 124 | "xo.path": { 125 | "scope": "resource", 126 | "type": [ 127 | "string", 128 | null 129 | ], 130 | "default": null, 131 | "pattern": "^(.{0,2}/).*(.js)$", 132 | "description": "An absolute or relative path to resolve xo from. Relative paths resolve with respect to the workspace folder." 133 | }, 134 | "xo.runtime": { 135 | "scope": "window", 136 | "type": [ 137 | "string", 138 | null 139 | ], 140 | "default": null, 141 | "description": "Absolute path to a node binary to run the xo server, defaults to VSCode's internally bundled node version." 142 | }, 143 | "xo.validate": { 144 | "scope": "resource", 145 | "type": "array", 146 | "items": { 147 | "type": "string" 148 | }, 149 | "default": [ 150 | "javascript", 151 | "javascriptreact", 152 | "typescript", 153 | "typescriptreact" 154 | ], 155 | "description": "An array of languages with which to activate the plugin. Defaults: [ \"javascript\", \"javascriptreact\", \"typescript\", \"typescriptreact\" ]" 156 | }, 157 | "xo.statusBar": { 158 | "scope": "resource", 159 | "type": "string", 160 | "enum": [ 161 | "Relevant", 162 | "Always", 163 | "Never" 164 | ], 165 | "default": "Relevant", 166 | "description": "When to show status bar item" 167 | } 168 | } 169 | }, 170 | "icons": { 171 | "xo-logo": { 172 | "description": "xo logo", 173 | "default": { 174 | "fontPath": "./scripts/xo.woff", 175 | "fontCharacter": "\\EA01" 176 | } 177 | } 178 | } 179 | }, 180 | "activationEvents": [ 181 | "onStartupFinished" 182 | ], 183 | "prettier": { 184 | "plugins": [ 185 | "prettier-plugin-packagejson" 186 | ], 187 | "printWidth": 100, 188 | "trailingComma": "none" 189 | }, 190 | "dependencies": { 191 | "auto-bind": "^4.0.0", 192 | "endent": "^2.1.0", 193 | "eslint-rule-docs": "^1.1.235", 194 | "find-up": "^7.0.0", 195 | "load-json-file": "^6.2.0", 196 | "p-debounce": "^4.0.0", 197 | "queue": "^6.0.2", 198 | "vscode-languageclient": "^9.0.1", 199 | "vscode-languageserver": "^9.0.1", 200 | "vscode-languageserver-textdocument": "^1.0.12", 201 | "vscode-uri": "^3.1.0" 202 | }, 203 | "devDependencies": { 204 | "@commitlint/cli": "19.7.1", 205 | "@commitlint/config-conventional": "19.7.1", 206 | "@types/node": "^22.13.1", 207 | "@types/vscode": "^1.97.0", 208 | "c8": "^10.1.3", 209 | "esbuild": "^0.25.0", 210 | "husky": "^9.1.7", 211 | "lint-staged": "15.4.3", 212 | "prettier": "^3.5.0", 213 | "prettier-plugin-packagejson": "^2.5.8", 214 | "rimraf": "^6.0.1", 215 | "tsx": "^4.19.2", 216 | "typescript": "^5.7.3", 217 | "vsce": "^2.15.0", 218 | "webfont": "^11.2.26", 219 | "xo": "npm:@spence-s/flat-xo@^0.0.10" 220 | }, 221 | "engines": { 222 | "node": ">=16", 223 | "vscode": "^1.97.0" 224 | }, 225 | "icon": "icon.png" 226 | } 227 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # vscode-linter-xo 2 | 3 | ![Github Actions Status](https://github.com/xojs/vscode-linter-xo/actions/workflows/node.js.yml/badge.svg) 4 | 5 | > Linter for [XO](https://github.com/sindresorhus/xo) 6 | 7 | ## Usage 8 | 9 | Install [XO](https://github.com/sindresorhus/xo) like you normally would in your project. The extension will pickup the configuration in your workspace just like running [XO](https://github.com/sindresorhus/xo) in your terminal would. You will be able to see your linter work as you type and easily format your code. 10 | 11 | ```shell 12 | $ npm install --save-dev xo 13 | ``` 14 | 15 | or 16 | 17 | ```shell 18 | $ yarn add -D xo 19 | ``` 20 | 21 | ## How it works 22 | 23 | The xo extension searches up when you open a file for a package.json with `xo` listed as a dependency. 24 | 25 | ## Auto Format JS/TS Files XO 26 | 27 | You can enable XO as a formatter for TypeScript and JavaScript. 28 | 29 | In either your workspace or user settings add the following settings. Linter xo now supports `editor.formatOnSaveMode` set to `"modifications"` :tada:. 30 | 31 | > optionally turn on "editor.formatOnSave" 32 | 33 | ```json 34 | { 35 | "editor.formatOnSave": true, 36 | "xo.enable": true, 37 | "xo.format.enable": true, 38 | "[javascript]": { 39 | "editor.defaultFormatter": "samverschueren.linter-xo" 40 | }, 41 | "[javascriptreact]": { 42 | "editor.defaultFormatter": "samverschueren.linter-xo" 43 | }, 44 | "[typescript]": { 45 | "editor.defaultFormatter": "samverschueren.linter-xo" 46 | }, 47 | "[typescriptreact]": { 48 | "editor.defaultFormatter": "samverschueren.linter-xo" 49 | } 50 | } 51 | ``` 52 | 53 | ### Code Actions on Save 54 | 55 | As of v3.18, vscode-linter-xo will respect the 'source.fixAll' code action request and auto format code on save. If you choose to auto format by this method, it is best to turn "editor.formatOnSave" off. You can also use 'source.fixAll.xo' code action on save to avoid running other formatters which support code actions on save. 56 | 57 | ```jsonc 58 | { 59 | "editor.formatOnSave": false, 60 | "xo.format.enable": true, 61 | "editor.codeActionsOnSave": { 62 | "source.fixAll": "explicit", // or you can use the xo specific code action on save 63 | "source.fixAll.xo": "explicit" 64 | } 65 | } 66 | ``` 67 | 68 | ## Commands 69 | 70 | To use: pull up the command pallete (usually `F1` or `Ctrl + Shift + P`) and start typing `xo`. 71 | 72 | ![](media/fix.gif) 73 | 74 | #### Fix all fixable problems 75 | 76 | Fixes all fixable problems in the open document, regardless of configuration. 77 | 78 | #### Restart Server 79 | 80 | Reloads XO server. 81 | 82 | ## Settings 83 | 84 | | Setting | Type | Default | Description | 85 | | --------------------- | ------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 86 | | `xo.enable` | `boolean` | `true` | Turn the `xo` extension on and off in your workspace | 87 | | `xo.format.enable` | `boolean` | `false` | Enable the `xo` extension to format documents. Requires `xo.enable` to be turned on. | 88 | | `xo.validate` | `string[]` | "javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue" | By default, the XO extension is configured to activate for Javascript, Javascript + React, Typescript, and Typescript + React. You may add more languages in the VS Code Settings. | 89 | | `xo.options` | `object` | `null` | Supply any [xo option](https://github.com/xojs/xo#config). The options set here will override any configurations found by `xo` in your local workspace | 90 | | `xo.overrideSeverity` | `info\|warning\|error` | `null` | XO extension will report all diagnostics in VSCode as the desired severity type. By default `xo` reports the severity type based on the linting rules set up in the local workspace | 91 | | `xo.debounce` | `number` | 0 | You can adjust a debounce (in milliseconds) that helps optimize performance for large files. If you notice that lint results are jumping all over the place, or a long delay in fixing files, turn this up. The max is 350ms. | 92 | | `xo.path` | `string` | `null` | If you want to resolve xo from a custom path - such as a global node_modules folder, supply an absolute or relative path (with respect to the workspace folder directory). Could use with Deno, yarn pnp, or to have the xo library lint itself. By default xo is resolved from the workspace folders node_modules directory.

examples:
`"xo.path": "/path/to/node_modules/xo/index.js"`
`"xo.path": "./node_modules/xo/index.js"` | 93 | | `xo.runtime` | `string` | `null` | By default, VSCode starts xo with its own bundled nodejs version. This may cause different results from the cli if you are using a different version of node. You can set a runtime path so that you are always using the same node version.

example:
`"xo.runtime": "/usr/local/bin/node"` | 94 | | `xo.statusBar` | `Relevant\|Always\|Never` | `"Relevant"` | When to show the status bar icon. | 95 | 96 | ## Known Issues 97 | 98 | - Turning on the setting "files.trimTrailingWhitespace" to true can cause a race condition with xo that causes code to get erroneously trimmed when formatting on save. This typically only occurs when debounce is turned (above 0 ms). Avoid using both "files.trimTrailingWhitespace" and "xo.debounce" options at the same time. 99 | 100 | ## License 101 | 102 | MIT © [Sam Verschueren](http://github.com/SamVerschueren) 103 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xojs/vscode-linter-xo/413516ef46e10ba8a8cc82c3f3e0f446d551f036/screenshot.png -------------------------------------------------------------------------------- /scripts/generate-icon.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs'); 2 | const path = require('node:path'); 3 | const webfont = require('webfont'); 4 | 5 | async function generateFont() { 6 | try { 7 | const result = await webfont.webfont({ 8 | files: [path.join(__dirname, '..', 'media', 'xo-logo.svg')], 9 | formats: ['woff'], 10 | verbose: true, 11 | fontHeight: 5000, 12 | normalize: true, 13 | fontName: 'xo', 14 | fontStyle: 'normal', 15 | fontWeight: 400 16 | }); 17 | const dest = path.join(__dirname, 'xo.woff'); 18 | fs.writeFileSync(dest, result.woff, 'binary'); 19 | console.log(`Font created at ${dest}`); 20 | } catch (error) { 21 | console.error('Font creation failed.', error); 22 | } 23 | } 24 | 25 | // eslint-disable-next-line unicorn/prefer-top-level-await 26 | generateFont(); 27 | -------------------------------------------------------------------------------- /scripts/xo.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xojs/vscode-linter-xo/413516ef46e10ba8a8cc82c3f3e0f446d551f036/scripts/xo.woff -------------------------------------------------------------------------------- /server/code-actions-builder.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TextEdit, 3 | uinteger, 4 | Range, 5 | Position, 6 | CodeActionKind, 7 | type Diagnostic, 8 | type CodeAction 9 | } from 'vscode-languageserver/node'; 10 | import {type TextDocument} from 'vscode-languageserver-textdocument'; 11 | import {type XoFix, type Linter} from './types'; 12 | import * as utils from './utils.js'; 13 | 14 | export class QuickFixCodeActionsBuilder { 15 | constructor( 16 | private readonly textDocument: TextDocument, 17 | private readonly diagnostics: Diagnostic[], 18 | private readonly fixCache: Map | undefined, 19 | private readonly suggestionsCache: Map | undefined 20 | ) {} 21 | 22 | build(): CodeAction[] { 23 | return this.diagnostics 24 | .filter((diagnostic) => diagnostic.source === 'XO') 25 | .flatMap((diagnostic) => { 26 | const diagnosticCodeActions: CodeAction[] = []; 27 | 28 | const fix = this.getFix(diagnostic); 29 | if (fix) diagnosticCodeActions.push(fix); 30 | 31 | const suggestions = this.getSuggestions(diagnostic); 32 | if (suggestions) diagnosticCodeActions.push(...suggestions); 33 | 34 | const disableSameLineCodeAction = this.getDisableSameLine(diagnostic); 35 | if (disableSameLineCodeAction) diagnosticCodeActions.push(disableSameLineCodeAction); 36 | 37 | const disableNextLineCodeAction = this.getDisableNextLine(diagnostic); 38 | if (disableNextLineCodeAction) diagnosticCodeActions.push(disableNextLineCodeAction); 39 | 40 | const disableFileCodeAction = this.getDisableEntireFile(diagnostic); 41 | if (disableFileCodeAction) diagnosticCodeActions.push(disableFileCodeAction); 42 | 43 | return diagnosticCodeActions; 44 | }); 45 | } 46 | 47 | getDisableSameLine(diagnostic: Diagnostic): CodeAction | undefined { 48 | let changes = []; 49 | 50 | const startPosition: Position = { 51 | line: diagnostic.range.start.line, 52 | character: uinteger.MAX_VALUE 53 | }; 54 | 55 | const lineText = this.textDocument.getText({ 56 | start: Position.create(diagnostic.range.start.line, 0), 57 | end: Position.create(diagnostic.range.start.line, uinteger.MAX_VALUE) 58 | }); 59 | 60 | const matchedForIgnoreComment = lineText && /\/\/ eslint-disable-line/.exec(lineText); 61 | 62 | if (matchedForIgnoreComment && matchedForIgnoreComment.length > 0) { 63 | const textEdit = TextEdit.insert(startPosition, `, ${diagnostic.code}`); 64 | 65 | changes.push(textEdit); 66 | } 67 | 68 | if (changes.length === 0) { 69 | const newedit: TextEdit = { 70 | range: { 71 | start: startPosition, 72 | end: startPosition 73 | }, 74 | newText: ` // eslint-disable-line ${diagnostic.code}` 75 | }; 76 | 77 | changes = [newedit]; 78 | } 79 | 80 | const ignoreAction: CodeAction = { 81 | title: `Ignore Rule ${diagnostic.code}: Same Line`, 82 | kind: CodeActionKind.QuickFix, 83 | diagnostics: [diagnostic], 84 | edit: { 85 | changes: { 86 | [this.textDocument.uri]: changes 87 | } 88 | } 89 | }; 90 | 91 | return ignoreAction; 92 | } 93 | 94 | getDisableNextLine(diagnostic: Diagnostic): CodeAction | undefined { 95 | let changes = []; 96 | 97 | const ignoreRange = { 98 | line: diagnostic.range.start.line, 99 | character: 0 100 | }; 101 | 102 | const lineText = this.textDocument.getText({ 103 | start: Position.create(diagnostic.range.start.line, 0), 104 | end: Position.create(diagnostic.range.start.line, uinteger.MAX_VALUE) 105 | }); 106 | 107 | const lineAboveText = this.textDocument.getText({ 108 | start: Position.create(diagnostic.range.start.line - 1, 0), 109 | end: Position.create(diagnostic.range.start.line - 1, uinteger.MAX_VALUE) 110 | }); 111 | 112 | const matchedForIgnoreComment = 113 | lineAboveText && /\/\/ eslint-disable-next-line/.exec(lineAboveText); 114 | 115 | if (matchedForIgnoreComment && matchedForIgnoreComment.length > 0) { 116 | const textEdit = TextEdit.insert( 117 | Position.create(diagnostic.range.start.line - 1, uinteger.MAX_VALUE), 118 | `, ${diagnostic.code}` 119 | ); 120 | 121 | changes.push(textEdit); 122 | } 123 | 124 | if (changes.length === 0) { 125 | const matches = /^([ |\t]*)/.exec(lineText); 126 | 127 | const indentation = Array.isArray(matches) && matches.length > 0 ? matches[0] : ''; 128 | 129 | const newedit = { 130 | range: { 131 | start: ignoreRange, 132 | end: ignoreRange 133 | }, 134 | newText: `${indentation}// eslint-disable-next-line ${diagnostic.code}\n` 135 | }; 136 | 137 | changes = [newedit]; 138 | } 139 | 140 | const ignoreAction: CodeAction = { 141 | title: `Ignore Rule ${diagnostic.code}: Line Above`, 142 | kind: CodeActionKind.QuickFix, 143 | diagnostics: [diagnostic], 144 | edit: { 145 | changes: { 146 | [this.textDocument.uri]: changes 147 | } 148 | } 149 | }; 150 | 151 | return ignoreAction; 152 | } 153 | 154 | getDisableEntireFile(diagnostic: Diagnostic): CodeAction | undefined { 155 | const shebang = this.textDocument.getText( 156 | Range.create(Position.create(0, 0), Position.create(0, 2)) 157 | ); 158 | 159 | const line = shebang === '#!' ? 1 : 0; 160 | 161 | const ignoreFileAction = { 162 | title: `Ignore Rule ${diagnostic.code}: Entire File`, 163 | kind: CodeActionKind.QuickFix, 164 | diagnostics: [diagnostic], 165 | edit: { 166 | changes: { 167 | [this.textDocument.uri]: [ 168 | TextEdit.insert(Position.create(line, 0), `/* eslint-disable ${diagnostic.code} */\n`) 169 | ] 170 | } 171 | } 172 | }; 173 | 174 | return ignoreFileAction; 175 | } 176 | 177 | getFix(diagnostic: Diagnostic): CodeAction | undefined { 178 | const edit = this.fixCache?.get(utils.computeKey(diagnostic)); 179 | 180 | if (!edit) return; 181 | 182 | return { 183 | title: `Fix ${diagnostic.code} with XO`, 184 | kind: CodeActionKind.QuickFix, 185 | diagnostics: [diagnostic], 186 | edit: { 187 | changes: { 188 | [this.textDocument.uri]: [ 189 | TextEdit.replace( 190 | Range.create( 191 | this.textDocument.positionAt(edit?.edit?.range?.[0]), 192 | this.textDocument.positionAt(edit?.edit?.range?.[1]) 193 | ), 194 | edit.edit.text || '' 195 | ) 196 | ] 197 | } 198 | } 199 | }; 200 | } 201 | 202 | getSuggestions(diagnostic: Diagnostic): CodeAction[] | undefined { 203 | const suggestions = this.suggestionsCache?.get(utils.computeKey(diagnostic)); 204 | 205 | if (!suggestions) return; 206 | 207 | return suggestions.map((suggestion) => { 208 | return { 209 | title: `Suggestion ${diagnostic.code}: ${suggestion.desc}`, 210 | kind: CodeActionKind.QuickFix, 211 | diagnostics: [diagnostic], 212 | edit: { 213 | changes: { 214 | [this.textDocument.uri]: [ 215 | TextEdit.replace( 216 | Range.create( 217 | this.textDocument.positionAt(suggestion?.fix?.range?.[0]), 218 | this.textDocument.positionAt(suggestion?.fix?.range?.[1]) 219 | ), 220 | suggestion.fix.text || '' 221 | ) 222 | ] 223 | } 224 | } 225 | }; 226 | }); 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /server/fix-builder.ts: -------------------------------------------------------------------------------- 1 | import {Range, TextEdit} from 'vscode-languageserver/node'; 2 | import {type TextDocument} from 'vscode-languageserver-textdocument'; 3 | import {type XoFix} from './types'; 4 | 5 | class Fix { 6 | static overlaps(lastEdit: XoFix, newEdit: XoFix) { 7 | return Boolean(lastEdit) && lastEdit.edit.range[1] > newEdit.edit.range[0]; 8 | } 9 | 10 | static sameRange(a: XoFix, b: XoFix) { 11 | return a.edit.range[0] === b.edit.range[0] && a.edit.range[1] === b.edit.range[1]; 12 | } 13 | 14 | edits: Map; 15 | textDocument: TextDocument; 16 | hasOverlaps: boolean; 17 | range?: Range; 18 | 19 | constructor(_textDocument: TextDocument, _edits: Map, _range?: Range) { 20 | this.hasOverlaps = false; 21 | this.edits = _edits; 22 | this.textDocument = _textDocument; 23 | this.range = _range; 24 | } 25 | 26 | isEmpty() { 27 | return this.edits.size === 0; 28 | } 29 | 30 | getDocumentVersion(): string | number { 31 | if (this.isEmpty()) { 32 | throw new Error('No edits recorded.'); 33 | } 34 | 35 | const {documentVersion} = [...this.edits.values()][0]; 36 | 37 | return documentVersion; 38 | } 39 | 40 | /** 41 | * getAllSorted 42 | * 43 | * gets all the edits sorted by location in an array 44 | */ 45 | getAllSorted() { 46 | const result = []; 47 | 48 | for (const edit of this.edits.values()) { 49 | let isInRange = true; 50 | 51 | if (this.range !== undefined) { 52 | const startOffset = this.textDocument.offsetAt(this.range.start); 53 | const endOffset = this.textDocument.offsetAt(this.range.end); 54 | 55 | const [startEditPosition, endEditPosition] = edit.edit.range; 56 | 57 | isInRange = 58 | // if given range completey covers the edit 59 | (startOffset <= startEditPosition && endOffset >= endEditPosition) || 60 | // if edit start offset only is in range 61 | (startOffset >= startEditPosition && startOffset <= endEditPosition) || 62 | // if edit end offset only is in range 63 | (endOffset >= startEditPosition && endOffset <= endEditPosition); 64 | } 65 | 66 | if (edit.edit !== undefined && isInRange) result.push(edit); 67 | } 68 | 69 | return result.sort((a, b) => { 70 | const d = a.edit.range[0] - b.edit.range[0]; 71 | if (d !== 0) return d; 72 | const aLen = a.edit.range[1] - a.edit.range[0]; 73 | const bLen = b.edit.range[1] - b.edit.range[0]; 74 | if (aLen !== bLen) return 0; 75 | if (aLen === 0) return -1; 76 | if (bLen === 0) return 1; 77 | return aLen - bLen; 78 | }); 79 | } 80 | 81 | /** 82 | * getOverlapFree 83 | * 84 | * returns the sorted results in an array 85 | * with all illegal overlapping results filtered out 86 | */ 87 | getOverlapFree() { 88 | const sorted = this.getAllSorted(); 89 | if (sorted.length <= 1) { 90 | return sorted; 91 | } 92 | 93 | const result = []; 94 | let last = sorted[0]; 95 | result.push(last); 96 | for (let i = 1; i < sorted.length; i++) { 97 | const current = sorted[i]; 98 | if (!Fix.overlaps(last, current) && !Fix.sameRange(last, current)) { 99 | this.hasOverlaps = true; 100 | result.push(current); 101 | last = current; 102 | } 103 | } 104 | 105 | return result; 106 | } 107 | 108 | /** 109 | * getTextEdits 110 | * 111 | * get the vscode array of text edits 112 | */ 113 | getTextEdits() { 114 | const overlapFree = this.getOverlapFree(); 115 | 116 | return overlapFree.map((editInfo) => 117 | TextEdit.replace( 118 | Range.create( 119 | this.textDocument.positionAt(editInfo.edit.range[0]), 120 | this.textDocument.positionAt(editInfo.edit.range[1]) 121 | ), 122 | editInfo.edit.text || '' 123 | ) 124 | ); 125 | } 126 | } 127 | 128 | export default Fix; 129 | -------------------------------------------------------------------------------- /server/get-document-config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import {type TextDocumentIdentifier} from 'vscode-languageserver'; 3 | import {type XoConfig} from './types'; 4 | import type LintServer from './server'; 5 | 6 | /** 7 | * Gets document config 8 | * and caches it if needed 9 | */ 10 | async function getDocumentConfig( 11 | this: LintServer, 12 | document: TextDocumentIdentifier 13 | ): Promise { 14 | if (this.configurationCache.has(document.uri)) { 15 | const config: XoConfig = this.configurationCache.get(document.uri)!; 16 | 17 | if (config !== undefined) return config; 18 | 19 | return {}; 20 | } 21 | 22 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 23 | const config: XoConfig = await this.connection.workspace.getConfiguration({ 24 | scopeUri: document.uri, 25 | section: 'xo' 26 | }); 27 | 28 | this.configurationCache.set(document.uri, config); 29 | 30 | return config; 31 | } 32 | 33 | export default getDocumentConfig; 34 | -------------------------------------------------------------------------------- /server/get-document-folder.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import {type TextDocument} from 'vscode-languageserver-textdocument'; 3 | import {findXoRoot, pathToUri, uriToPath} from './utils'; 4 | import type LintServer from './server'; 5 | 6 | /** 7 | * get the root folder document from a document 8 | * caches workspace folders if needed 9 | */ 10 | async function getDocumentFolder(this: LintServer, document: TextDocument) { 11 | const documentDirUri = path.dirname(document.uri); 12 | // check for cached folder 13 | if (this.foldersCache.has(documentDirUri)) { 14 | return this.foldersCache.get(documentDirUri); 15 | } 16 | 17 | const documentPath = uriToPath(document.uri); 18 | const documentDir = path.dirname(documentPath); 19 | const {pkgPath} = (await findXoRoot(documentDir)) ?? {}; 20 | 21 | if (pkgPath) { 22 | const packageDirUri = pathToUri(path.dirname(pkgPath)); 23 | this.foldersCache.set(documentDirUri, {uri: packageDirUri}); 24 | } else { 25 | this.foldersCache.set(documentDirUri, {}); 26 | } 27 | 28 | return this.foldersCache.get(documentDirUri); 29 | } 30 | 31 | export default getDocumentFolder; 32 | -------------------------------------------------------------------------------- /server/get-document-formatting.ts: -------------------------------------------------------------------------------- 1 | import {TextDocument} from 'vscode-languageserver-textdocument'; 2 | import {TextEdit, Range} from 'vscode-languageserver/node'; 3 | import Fix from './fix-builder'; 4 | import type LintServer from './server'; 5 | import {type DocumentFix} from './types'; 6 | 7 | /** 8 | * Computes the TextEdits for a text document uri 9 | */ 10 | async function getDocumentFormatting( 11 | this: LintServer, 12 | uri: string, 13 | range?: Range 14 | ): Promise { 15 | const cachedTextDocument = this.documents.get(uri); 16 | 17 | const defaultResponse = { 18 | documentVersion: cachedTextDocument?.version, 19 | edits: [] 20 | }; 21 | 22 | if (cachedTextDocument === undefined) return defaultResponse; 23 | 24 | const documentFixCache = this.documentFixCache.get(uri); 25 | 26 | if (documentFixCache === undefined || documentFixCache.size === 0) { 27 | return defaultResponse; 28 | } 29 | 30 | const documentFix = new Fix(cachedTextDocument, documentFixCache, range); 31 | 32 | if (documentFix.isEmpty()) { 33 | return defaultResponse; 34 | } 35 | 36 | const edits = documentFix.getTextEdits(); 37 | 38 | const documentVersion = documentFix.getDocumentVersion(); 39 | 40 | /** 41 | * We only need to run the second pass lint if the 42 | * document fixes have overlaps. Otherwise, all fixes can be applied. 43 | */ 44 | if (!documentFix.hasOverlaps) { 45 | return { 46 | documentVersion, 47 | edits 48 | }; 49 | } 50 | 51 | const originalText = cachedTextDocument.getText(); 52 | 53 | // clone the cached document 54 | const textDocument = TextDocument.create( 55 | cachedTextDocument.uri, 56 | cachedTextDocument.languageId, 57 | cachedTextDocument.version, 58 | originalText 59 | ); 60 | 61 | // apply the edits to the copy and get the edits that would be 62 | // further needed for all the fixes to work. 63 | const editedContent = TextDocument.applyEdits(textDocument, edits); 64 | 65 | const report = await this.getLintResults(textDocument, editedContent, true); 66 | 67 | if (report.results[0].output && report.results[0].output !== editedContent) { 68 | this.log('Experimental replace triggered'); 69 | const string0 = originalText; 70 | const string1 = report.results[0].output; 71 | 72 | let i = 0; 73 | while (i < string0.length && i < string1.length && string0[i] === string1[i]) { 74 | ++i; 75 | } 76 | 77 | // length of common suffix 78 | let j = 0; 79 | while ( 80 | i + j < string0.length && 81 | i + j < string1.length && 82 | string0[string0.length - j - 1] === string1[string1.length - j - 1] 83 | ) { 84 | ++j; 85 | } 86 | 87 | // eslint-disable-next-line unicorn/prefer-string-slice 88 | const newText = string1.substring(i, string1.length - j); 89 | const pos0 = cachedTextDocument.positionAt(i); 90 | const pos1 = cachedTextDocument.positionAt(string0.length - j); 91 | 92 | return { 93 | documentVersion: documentFix.getDocumentVersion(), 94 | edits: [TextEdit.replace(Range.create(pos0, pos1), newText)] 95 | }; 96 | } 97 | 98 | return { 99 | documentVersion, 100 | edits: documentFix.getTextEdits() 101 | }; 102 | } 103 | 104 | export default getDocumentFormatting; 105 | -------------------------------------------------------------------------------- /server/get-lint-results.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import {URI} from 'vscode-uri'; 3 | import {type TextDocument} from 'vscode-languageserver-textdocument'; 4 | import type LintServer from './server'; 5 | import {type XoResult, type LintTextOptions} from './types'; 6 | 7 | async function getLintResults( 8 | this: LintServer, 9 | document: TextDocument, 10 | _contents?: string, 11 | fix?: boolean 12 | ): Promise { 13 | // first we resolve all the configs we need 14 | const [{uri: folderUri = ''} = {}, {options = {}}] = await Promise.all([ 15 | this.getDocumentFolder(document), 16 | this.getDocumentConfig(document) 17 | ]); 18 | 19 | // if we can't find a valid folder, then the user 20 | // has likely opened a JS file from another location 21 | // so we will just bail out of linting early 22 | if (!folderUri) { 23 | const error = new Error('No valid xo folder could be found for this file. Skipping linting.'); 24 | this.logError(error); 25 | return { 26 | cwd: folderUri, 27 | results: [], 28 | warningCount: 0, 29 | errorCount: 0, 30 | fixableErrorCount: 0, 31 | fixableWarningCount: 0, 32 | rulesMeta: {} 33 | }; 34 | } 35 | 36 | const xo = await this.resolveXo(document); 37 | 38 | const {fsPath: documentFsPath} = URI.parse(document.uri); 39 | const {fsPath: folderFsPath} = URI.parse(folderUri); 40 | const contents = _contents ?? document.getText(); 41 | 42 | const lintTextOptions: LintTextOptions = { 43 | ...options, 44 | // set the options needed for internal xo config resolution 45 | cwd: folderFsPath, 46 | filePath: documentFsPath, 47 | warnIgnored: false, 48 | fix 49 | }; 50 | 51 | /** 52 | * Changing the current working directory to the folder 53 | */ 54 | const cwd = process.cwd(); 55 | 56 | process.chdir(lintTextOptions.cwd); 57 | 58 | const report = await xo.lintText(contents, lintTextOptions); 59 | 60 | if (cwd !== process.cwd()) { 61 | process.chdir(cwd); 62 | } 63 | 64 | return report; 65 | } 66 | 67 | export default getLintResults; 68 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import LintServer from './server'; 2 | 3 | const server = new LintServer(); 4 | 5 | server.listen(); 6 | -------------------------------------------------------------------------------- /server/lint-document.ts: -------------------------------------------------------------------------------- 1 | import {DiagnosticSeverity} from 'vscode-languageserver/node'; 2 | import getRuleUrl from 'eslint-rule-docs'; 3 | import {type TextDocument} from 'vscode-languageserver-textdocument'; 4 | import * as utils from './utils'; 5 | import type LintServer from './server'; 6 | 7 | /** 8 | * lintDocument 9 | * 10 | * first lints and sends diagnostics for a single file 11 | */ 12 | export async function lintDocument(this: LintServer, document: TextDocument): Promise { 13 | try { 14 | const currentDocument = this.documents.get(document.uri); 15 | 16 | if (!currentDocument) return; 17 | 18 | if (document.version !== currentDocument.version) return; 19 | 20 | const {overrideSeverity, enable} = await this.getDocumentConfig(document); 21 | 22 | if (!enable) return; 23 | 24 | const {results, rulesMeta} = await this.getLintResults(document); 25 | 26 | // Clean previously computed code actions. 27 | this.documentFixCache.delete(document.uri); 28 | this.documentSuggestionsCache.delete(document.uri); 29 | 30 | if (results?.length === 0 || !results?.[0]?.messages) return; 31 | 32 | // eslint-disable-next-line complexity 33 | const diagnostics = results[0].messages.map((problem) => { 34 | const diagnostic = utils.makeDiagnostic(problem); 35 | 36 | if (overrideSeverity) { 37 | const mapSeverity = { 38 | off: diagnostic.severity, 39 | info: DiagnosticSeverity.Information, 40 | warn: DiagnosticSeverity.Warning, 41 | error: DiagnosticSeverity.Error 42 | }; 43 | diagnostic.severity = mapSeverity[overrideSeverity] ?? diagnostic.severity; 44 | } 45 | 46 | if ( 47 | typeof rulesMeta === 'object' && 48 | typeof diagnostic.code === 'string' && 49 | typeof rulesMeta[diagnostic.code] === 'object' && 50 | rulesMeta?.[diagnostic.code]?.docs?.url 51 | ) { 52 | const href = rulesMeta?.[diagnostic.code].docs?.url; 53 | 54 | if (typeof href === 'string') 55 | diagnostic.codeDescription = { 56 | href 57 | }; 58 | } else { 59 | try { 60 | const href = getRuleUrl(diagnostic.code?.toString())?.url; 61 | if (typeof href === 'string') 62 | diagnostic.codeDescription = { 63 | href 64 | }; 65 | } catch {} 66 | } 67 | 68 | /** 69 | * record a code action for applying fixes 70 | */ 71 | if (problem.fix && problem.ruleId) { 72 | const {uri} = document; 73 | 74 | let edits = this.documentFixCache.get(uri); 75 | 76 | if (!edits) { 77 | edits = new Map(); 78 | this.documentFixCache.set(uri, edits); 79 | } 80 | 81 | edits.set(utils.computeKey(diagnostic), { 82 | label: `Fix this ${problem.ruleId} problem`, 83 | documentVersion: document.version, 84 | ruleId: problem.ruleId, 85 | edit: problem.fix 86 | }); 87 | } 88 | 89 | // cache any suggestions for quick fix code actions 90 | if (problem.suggestions && problem.suggestions.length > 0) { 91 | const {uri} = document; 92 | 93 | let edits = this.documentSuggestionsCache.get(uri); 94 | 95 | if (!edits) { 96 | edits = new Map(); 97 | this.documentSuggestionsCache.set(uri, edits); 98 | } 99 | 100 | edits.set(utils.computeKey(diagnostic), problem.suggestions); 101 | } 102 | 103 | return diagnostic; 104 | }); 105 | 106 | await this.connection.sendDiagnostics({ 107 | uri: document.uri, 108 | version: document.version, 109 | diagnostics 110 | }); 111 | } catch (error: unknown) { 112 | if (error instanceof Error) { 113 | if (error.message?.includes('Failed to resolve module')) { 114 | error.message += '. Ensure that xo has been installed.'; 115 | } 116 | 117 | this.connection.window.showErrorMessage(error?.message ?? 'Unknown Error'); 118 | this.logError(error); 119 | } 120 | } 121 | } 122 | 123 | /** 124 | * helper to lint and sends diagnostics for multiple files 125 | */ 126 | export async function lintDocuments(this: LintServer, documents: TextDocument[]): Promise { 127 | for (const document of documents) { 128 | const lintDocument = async () => { 129 | if (document.version !== this.documents.get(document.uri)?.version) return; 130 | 131 | await this.lintDocument(document); 132 | }; 133 | 134 | this.queue.push(lintDocument); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /server/logger.ts: -------------------------------------------------------------------------------- 1 | import type LintServer from './server'; 2 | 3 | /** 4 | * log a message to the client console 5 | * in a console.log type of way - primarily used 6 | * for development and debugging 7 | */ 8 | export function log(this: LintServer, ...messages: unknown[]): void { 9 | this.connection.console.log( 10 | // eslint-disable-next-line unicorn/no-array-reduce 11 | messages.reduce((acc: string, message: unknown) => { 12 | if (message instanceof Map) 13 | message = `Map(${JSON.stringify([...message.entries()], null, 2)})`; 14 | if (typeof message === 'object') message = JSON.stringify(message, null, 2); 15 | // eslint-disable-next-line unicorn/prefer-spread 16 | return acc.concat(`${message as string} `); 17 | }, '[server] ') 18 | ); 19 | } 20 | 21 | export function logError(this: LintServer, error: Error): void { 22 | this.log(error?.message ?? 'Unknown Error'); 23 | this.log(error?.stack); 24 | } 25 | -------------------------------------------------------------------------------- /server/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'eslint-rule-docs' { 2 | interface RuleDocResult { 3 | url?: string; 4 | } 5 | // eslint-disable-next-line @typescript-eslint/naming-convention 6 | type getRuleUrl = (ruleId?: string) => RuleDocResult; 7 | 8 | const getRuleUrl: getRuleUrl; 9 | 10 | export default getRuleUrl; 11 | } 12 | -------------------------------------------------------------------------------- /server/resolve-xo.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import {Files} from 'vscode-languageserver/node'; 3 | import {URI} from 'vscode-uri'; 4 | import endent from 'endent'; 5 | import loadJsonFile from 'load-json-file'; 6 | import {type TextDocument} from 'vscode-languageserver-textdocument'; 7 | import {type Xo} from './types'; 8 | import {uriToPath, pathToUri} from './utils'; 9 | import type LintServer from './server'; 10 | 11 | /** 12 | * Get xo from cache if it is there. 13 | * Attempt to resolve from node_modules relative 14 | * to the current working directory if it is not 15 | */ 16 | async function resolveXo(this: LintServer, document: TextDocument): Promise { 17 | const [{uri: folderUri = ''} = {}, {path: customPath = ''}] = await Promise.all([ 18 | this.getDocumentFolder(document), 19 | this.getDocumentConfig(document) 20 | ]); 21 | 22 | const xoCacheKey = path.dirname(document.uri); 23 | 24 | let xo = this.xoCache.get(xoCacheKey); 25 | 26 | if (typeof xo?.lintText === 'function') return xo; 27 | 28 | const folderPath = uriToPath(folderUri); 29 | 30 | let xoUri; 31 | let xoFilePath; 32 | const useCustomPath = typeof customPath === 'string'; 33 | 34 | if (!useCustomPath) { 35 | xoFilePath = await Files.resolve('xo', undefined, folderPath, this.connection.tracer.log); 36 | xoUri = URI.file(xoFilePath).toString(); 37 | } else if (useCustomPath && customPath.startsWith('file://')) { 38 | xoUri = customPath; 39 | } else if (useCustomPath && path.isAbsolute(customPath)) { 40 | xoUri = pathToUri(customPath); 41 | } else if (useCustomPath && !path.isAbsolute(customPath)) { 42 | xoUri = pathToUri(path.join(folderPath, customPath)); 43 | } else { 44 | throw new Error(`Unknown path format “${customPath}”: Needs to start with “/”, “./”, or "../"`); 45 | } 46 | 47 | let version: string; 48 | 49 | [{default: xo}, {version = ''} = {}] = await Promise.all([ 50 | import(xoUri) as Promise<{default: Xo}>, 51 | xoFilePath 52 | ? loadJsonFile<{version: string}>( 53 | path.join( 54 | xoFilePath.includes('dist') 55 | ? path.dirname(path.resolve(xoFilePath, '..')) 56 | : path.dirname(xoFilePath), 57 | 'package.json' 58 | ) 59 | ) 60 | : {version: 'custom'} 61 | ]); 62 | 63 | if (typeof xo?.lintText !== 'function') 64 | throw new Error("The XO library doesn't export a lintText method."); 65 | 66 | this.log( 67 | endent` 68 | XO Library ${version} Loaded 69 | Resolved in Workspace ${folderPath} 70 | Cached for Folder ${uriToPath(xoCacheKey)} 71 | ` 72 | ); 73 | this.xoCache.set(xoCacheKey, xo); 74 | 75 | return xo; 76 | } 77 | 78 | export default resolveXo; 79 | -------------------------------------------------------------------------------- /server/server.ts: -------------------------------------------------------------------------------- 1 | import * as process from 'node:process'; 2 | import * as path from 'node:path'; 3 | import { 4 | type CodeAction, 5 | createConnection, 6 | ProposedFeatures, 7 | TextDocuments, 8 | RequestType, 9 | TextDocumentSyncKind, 10 | ResponseError, 11 | LSPErrorCodes, 12 | CodeActionKind, 13 | type TextEdit, 14 | type Connection, 15 | type InitializeResult, 16 | type DocumentFormattingParams, 17 | type CancellationToken, 18 | type TextDocumentIdentifier, 19 | type DidChangeConfigurationParams, 20 | type TextDocumentChangeEvent, 21 | type DocumentRangeFormattingParams 22 | } from 'vscode-languageserver/node'; 23 | import {TextDocument} from 'vscode-languageserver-textdocument'; 24 | import autoBind from 'auto-bind'; 25 | import Queue from 'queue'; 26 | import {type CodeActionParams} from 'vscode-languageclient'; 27 | import {QuickFixCodeActionsBuilder} from './code-actions-builder.js'; 28 | import getDocumentConfig from './get-document-config.js'; 29 | import getDocumentFormatting from './get-document-formatting.js'; 30 | import getDocumentFolder from './get-document-folder'; 31 | import getLintResults from './get-lint-results.js'; 32 | import {lintDocument, lintDocuments} from './lint-document.js'; 33 | import {log, logError} from './logger'; 34 | import resolveXo from './resolve-xo'; 35 | import {type XoConfig, type DocumentFix, type Xo, type XoFix, type Linter} from './types'; 36 | 37 | interface ChangeConfigurationParams extends DidChangeConfigurationParams { 38 | settings: {xo: XoConfig}; 39 | } 40 | 41 | class LintServer { 42 | readonly connection: Connection; 43 | readonly documents: TextDocuments; 44 | readonly queue: Queue; 45 | log: typeof log; 46 | logError: typeof logError; 47 | getDocumentConfig: typeof getDocumentConfig; 48 | getDocumentFormatting: typeof getDocumentFormatting; 49 | getDocumentFolder: typeof getDocumentFolder; 50 | getLintResults: typeof getLintResults; 51 | lintDocument: typeof lintDocument; 52 | lintDocuments: typeof lintDocuments; 53 | resolveXo: typeof resolveXo; 54 | /** A mapping of folders to the location of their package.json */ 55 | foldersCache: Map>; 56 | /** A mapping of document uri strings to configuration options */ 57 | configurationCache: Map; 58 | /** A mapping of folderPaths to the resolved XO module */ 59 | xoCache: Map; 60 | /** A mapping of document uri strings to their last calculated fixes */ 61 | documentFixCache: Map>; 62 | /** A mapping of document uri strings to their last calculated suggestions */ 63 | documentSuggestionsCache: Map>; 64 | /** Only show resolution errors one time per session */ 65 | hasShownResolutionError: boolean; 66 | /** flag which helps gracefully stopping and shutting down */ 67 | hasReceivedShutdownRequest?: boolean; 68 | 69 | constructor({isTest}: {isTest?: boolean} = {}) { 70 | /** 71 | * Bind all imported methods 72 | */ 73 | this.getDocumentConfig = getDocumentConfig.bind(this); 74 | 75 | this.getDocumentFormatting = getDocumentFormatting.bind(this); 76 | 77 | this.getDocumentFolder = getDocumentFolder.bind(this); 78 | 79 | this.getLintResults = getLintResults.bind(this); 80 | 81 | this.lintDocument = lintDocument.bind(this); 82 | 83 | this.lintDocuments = lintDocuments.bind(this); 84 | 85 | this.resolveXo = resolveXo.bind(this); 86 | 87 | this.log = log.bind(this); 88 | this.logError = logError.bind(this); 89 | 90 | /** 91 | * Bind all methods 92 | */ 93 | autoBind(this); 94 | 95 | /** 96 | * Connection 97 | */ 98 | this.connection = isTest 99 | ? createConnection(process.stdin, process.stdout) 100 | : createConnection(ProposedFeatures.all); 101 | 102 | /** 103 | * Documents 104 | */ 105 | this.documents = new TextDocuments(TextDocument); 106 | 107 | /** 108 | * A message queue which allows for async cancellations and 109 | * processing notifications and requests in order 110 | */ 111 | this.queue = new Queue({concurrency: 1, autostart: true}); 112 | 113 | // let id = 0; 114 | 115 | // // get notified when jobs complete 116 | // this.queue.on('success', (result, job) => { 117 | // this.log('job finished processing:', job.name, job.id); 118 | // }); 119 | 120 | // this.queue.on('error', (error, job) => { 121 | // this.logError(error as Error); 122 | // }); 123 | 124 | // this.queue.on('start', (job) => { 125 | // job.id = id++; 126 | // this.log('job started processing:', job.name, job.id); 127 | // }); 128 | 129 | /** 130 | * setup documents listeners 131 | */ 132 | this.documents.onDidChangeContent(this.handleDocumentsOnDidChangeContent); 133 | this.documents.onDidClose(this.handleDocumentsOnDidClose); 134 | 135 | /** 136 | * setup connection lifecycle listeners 137 | */ 138 | this.connection.onInitialize(this.handleInitialize); 139 | this.connection.onShutdown(this.handleShutdown); 140 | this.connection.onExit(this.exit); 141 | 142 | /** 143 | * handle xo configuration changes 144 | */ 145 | this.connection.onDidChangeConfiguration(this.handleDidChangeConfiguration); 146 | 147 | /** 148 | * handle document formatting requests 149 | * - the built in "allFixes" request does not depend on configuration 150 | * - the formatting request requires user to enable xo as formatter 151 | */ 152 | this.connection.onRequest( 153 | new RequestType('textDocument/xo/allFixes').method, 154 | this.handleAllFixesRequest 155 | ); 156 | this.connection.onDocumentFormatting(this.handleDocumentFormattingRequest); 157 | this.connection.onDocumentRangeFormatting(this.handleDocumentFormattingRequest); 158 | this.connection.onCodeAction(this.handleCodeActionRequest); 159 | 160 | /** Caches */ 161 | this.xoCache = new Map(); 162 | this.configurationCache = new Map(); 163 | this.foldersCache = new Map(); 164 | this.documentFixCache = new Map(); 165 | this.documentSuggestionsCache = new Map(); 166 | 167 | /** Internal state */ 168 | this.hasShownResolutionError = false; 169 | } 170 | 171 | /** 172 | * listen 173 | * 174 | * Start the language server connection and document event listeners 175 | */ 176 | listen() { 177 | this.connection.listen(); 178 | // Listen for text document create, change 179 | this.documents.listen(this.connection); 180 | this.log(`XO Language Server Starting in Node ${process.version}`); 181 | } 182 | 183 | /** 184 | * check if document is open 185 | * 186 | */ 187 | isDocumentOpen(document: TextDocument | TextDocumentIdentifier): boolean { 188 | return Boolean(document?.uri && this.documents.get(document.uri)); 189 | } 190 | 191 | /** 192 | * handle connection.onInitialize 193 | */ 194 | async handleInitialize(): Promise { 195 | return { 196 | capabilities: { 197 | workspace: { 198 | workspaceFolders: { 199 | supported: true 200 | } 201 | }, 202 | textDocumentSync: { 203 | openClose: true, 204 | change: TextDocumentSyncKind.Incremental 205 | }, 206 | documentFormattingProvider: true, 207 | documentRangeFormattingProvider: true, 208 | codeActionProvider: { 209 | codeActionKinds: [ 210 | CodeActionKind.QuickFix, 211 | CodeActionKind.SourceFixAll, 212 | `${CodeActionKind.SourceFixAll}.xo` 213 | ] 214 | } 215 | } 216 | }; 217 | } 218 | 219 | /** 220 | * Handle connection.onDidChangeConfiguration 221 | */ 222 | async handleDidChangeConfiguration(params: ChangeConfigurationParams) { 223 | // recache each folder config 224 | this.configurationCache.clear(); 225 | return this.lintDocuments(this.documents.all()); 226 | } 227 | 228 | /** 229 | * Handle custom all fixes request 230 | */ 231 | async handleAllFixesRequest(params: { 232 | textDocument: TextDocumentIdentifier; 233 | }): Promise { 234 | return new Promise((resolve, reject) => { 235 | this.queue.push(async () => { 236 | try { 237 | const documentFix = await this.getDocumentFormatting(params.textDocument.uri); 238 | 239 | if (documentFix === undefined) { 240 | resolve(); 241 | return; 242 | } 243 | 244 | resolve(documentFix); 245 | } catch (error: unknown) { 246 | if (error instanceof Error) { 247 | reject(error); 248 | } 249 | } 250 | }); 251 | }); 252 | } 253 | 254 | /** 255 | * Handle LSP document formatting request 256 | */ 257 | async handleDocumentFormattingRequest( 258 | params: DocumentFormattingParams | DocumentRangeFormattingParams, 259 | token: CancellationToken 260 | ): Promise { 261 | return new Promise((resolve, reject) => { 262 | const documentFormattingRequestHandler = async () => { 263 | try { 264 | if (!this.isDocumentOpen(params.textDocument)) return; 265 | 266 | if (token.isCancellationRequested) { 267 | reject(new ResponseError(LSPErrorCodes.RequestCancelled, 'Request was cancelled')); 268 | return; 269 | } 270 | 271 | const cachedTextDocument = this.documents.get(params.textDocument.uri); 272 | 273 | if (cachedTextDocument === undefined) { 274 | resolve([]); 275 | return; 276 | } 277 | 278 | const config = await this.getDocumentConfig(params.textDocument); 279 | 280 | if (config === undefined || !config?.format?.enable || !config.enable) { 281 | resolve([]); 282 | return; 283 | } 284 | 285 | const {edits, documentVersion} = await this.getDocumentFormatting( 286 | params.textDocument.uri, 287 | 'range' in params ? params.range : undefined 288 | ); 289 | 290 | if (documentVersion !== cachedTextDocument.version) { 291 | throw new ResponseError( 292 | LSPErrorCodes.ContentModified, 293 | 'Document version mismatch detected' 294 | ); 295 | } 296 | 297 | resolve(edits); 298 | } catch (error: unknown) { 299 | if (error instanceof Error) { 300 | this.logError(error); 301 | reject(error); 302 | } 303 | } 304 | }; 305 | 306 | this.queue.push(documentFormattingRequestHandler); 307 | }); 308 | } 309 | 310 | /** 311 | * handleCodeActionRequest 312 | * 313 | * Handle LSP code action requests 314 | * that happen at the time of an error/warning hover 315 | * or code action menu request 316 | */ 317 | async handleCodeActionRequest( 318 | params: CodeActionParams, 319 | token?: CancellationToken 320 | ): Promise { 321 | return new Promise((resolve, reject) => { 322 | // eslint-disable-next-line complexity 323 | const codeActionRequestHandler = async () => { 324 | try { 325 | const {context} = params; 326 | if (!context?.diagnostics?.length) { 327 | resolve(undefined); 328 | return; 329 | } 330 | 331 | if (!params?.textDocument?.uri) { 332 | resolve(undefined); 333 | return; 334 | } 335 | 336 | if (token?.isCancellationRequested) { 337 | reject(new ResponseError(LSPErrorCodes.RequestCancelled, 'Request got cancelled')); 338 | return; 339 | } 340 | 341 | const document = this.documents.get(params.textDocument.uri); 342 | 343 | if (!document) { 344 | resolve(undefined); 345 | return; 346 | } 347 | 348 | const config = await this.getDocumentConfig(document); 349 | 350 | if (!config?.enable) { 351 | resolve(undefined); 352 | return; 353 | } 354 | 355 | const codeActions: CodeAction[] = []; 356 | 357 | const isFixAll = context.only?.includes(CodeActionKind.SourceFixAll); 358 | const isFixAllXo = context.only?.includes(`${CodeActionKind.SourceFixAll}.xo`); 359 | // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing 360 | if (isFixAll || isFixAllXo) { 361 | const fixes = await this.getDocumentFormatting(params.textDocument.uri); 362 | const codeAction: CodeAction = { 363 | title: 'Fix all XO auto-fixable problems', 364 | kind: isFixAllXo ? `${CodeActionKind.SourceFixAll}.xo` : CodeActionKind.SourceFixAll, 365 | edit: { 366 | changes: { 367 | [document.uri]: fixes.edits 368 | } 369 | } 370 | }; 371 | codeActions.push(codeAction); 372 | } 373 | 374 | if (!context.only || context.only?.includes(CodeActionKind.QuickFix)) { 375 | const codeActionBuilder = new QuickFixCodeActionsBuilder( 376 | document, 377 | context.diagnostics, 378 | this.documentFixCache.get(document.uri), 379 | this.documentSuggestionsCache.get(document.uri) 380 | ); 381 | codeActions.push(...codeActionBuilder.build()); 382 | } 383 | 384 | resolve(codeActions); 385 | } catch (error: unknown) { 386 | if (error instanceof Error) { 387 | this.logError(error); 388 | reject(error); 389 | } 390 | } 391 | }; 392 | 393 | this.queue.push(codeActionRequestHandler); 394 | }); 395 | } 396 | 397 | /** 398 | * Handle documents.onDidChangeContent 399 | * queues document content linting 400 | * @param {TextDocumentChangeEvent} event 401 | */ 402 | handleDocumentsOnDidChangeContent(event: TextDocumentChangeEvent) { 403 | const onDidChangeContentHandler = async () => { 404 | try { 405 | if (event.document.version !== this.documents.get(event.document.uri)?.version) return; 406 | 407 | if (event.document.uri.includes('node_modules')) { 408 | this.log('skipping node_modules file'); 409 | return; 410 | } 411 | 412 | const config = await this.getDocumentConfig(event.document); 413 | const {default: debounce} = await import('p-debounce'); 414 | await debounce(this.lintDocument, config.debounce ?? 0)(event.document); 415 | } catch (error: unknown) { 416 | if (error instanceof Error) this.logError(error); 417 | } 418 | }; 419 | 420 | this.queue.push(onDidChangeContentHandler); 421 | } 422 | 423 | /** 424 | * Handle documents.onDidClose 425 | * Clears the diagnostics when document is closed and 426 | * cleans up cached folders that no longer have open documents 427 | */ 428 | async handleDocumentsOnDidClose(event: TextDocumentChangeEvent): Promise { 429 | const folders = new Set( 430 | [...this.documents.all()].map((document) => path.dirname(document.uri)) 431 | ); 432 | 433 | this.documentFixCache.delete(event.document.uri); 434 | for (const folder of this.foldersCache.keys()) { 435 | if (!folders.has(folder)) { 436 | this.foldersCache.delete(folder); 437 | this.xoCache.delete(folder); 438 | this.configurationCache.delete(folder); 439 | } 440 | } 441 | 442 | await this.connection?.sendDiagnostics({ 443 | uri: event.document.uri.toString(), 444 | diagnostics: [] 445 | }); 446 | } 447 | 448 | /** 449 | * handle shutdown request according to LSP spec 450 | * https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#shutdown 451 | */ 452 | async handleShutdown() { 453 | this.log('XO Server recieved shut down request'); 454 | this.hasReceivedShutdownRequest = true; 455 | 456 | if (this.queue.length > 0) { 457 | await new Promise((resolve) => { 458 | this.queue.on('end', () => { 459 | this.queue.stop(); 460 | resolve(undefined); 461 | }); 462 | }); 463 | } 464 | 465 | this.queue.end(); 466 | } 467 | 468 | /** 469 | * exit 470 | * 471 | * Handle server exit notification according to LSP spec 472 | * https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#exit 473 | */ 474 | async exit() { 475 | if (this.hasReceivedShutdownRequest) { 476 | this.log('XO Server exiting'); 477 | // eslint-disable-next-line unicorn/no-process-exit 478 | process.exit(0); 479 | } else { 480 | this.log('XO Server exiting with error'); 481 | } 482 | } 483 | } 484 | 485 | export default LintServer; 486 | -------------------------------------------------------------------------------- /server/types.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error this is fine since its just types imported from ESM module 2 | import {type XoConfigOptions, type XoLintResult, type LinterOptions} from 'xo'; 3 | // eslint-disable-next-line import-x/no-extraneous-dependencies, n/no-extraneous-import 4 | import {type ESLint, type Rule} from 'eslint'; 5 | import {type TextEdit} from 'vscode-languageserver/node'; 6 | 7 | export enum SeverityOption { 8 | off = 'off', 9 | warn = 'warn', 10 | error = 'error', 11 | info = 'info' 12 | } 13 | 14 | export type XoResult = XoLintResult & ESLint.LintResultData; 15 | 16 | export interface LintTextOptions extends XoConfigOptions, LinterOptions { 17 | warnIgnored?: boolean; 18 | filePath?: string; 19 | } 20 | 21 | export interface Xo { 22 | lintText(text: string, options?: LintTextOptions): Promise; 23 | } 24 | 25 | export interface FormatOption { 26 | enable: boolean; 27 | } 28 | 29 | export interface XoConfig { 30 | enable?: boolean; 31 | options?: XoConfigOptions; 32 | overrideSeverity?: SeverityOption; 33 | debounce?: number; 34 | path?: string; 35 | runtime?: string; 36 | validate?: string; 37 | statusBar?: string; 38 | format?: FormatOption; 39 | } 40 | 41 | export interface XoFix { 42 | label: string; 43 | documentVersion: string | number; 44 | ruleId: string; 45 | edit: Rule.Fix; 46 | } 47 | 48 | export interface DocumentFix { 49 | documentVersion?: string | number; 50 | edits: TextEdit[]; 51 | } 52 | 53 | // eslint-disable-next-line import-x/no-extraneous-dependencies, n/no-extraneous-import 54 | export * from 'eslint'; 55 | -------------------------------------------------------------------------------- /server/utils.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | import * as node from 'vscode-languageserver/node'; 3 | import loadJsonFile from 'load-json-file'; 4 | import {URI} from 'vscode-uri'; 5 | // eslint-disable-next-line import-x/no-extraneous-dependencies, n/no-extraneous-import 6 | import {type Linter} from 'eslint'; 7 | 8 | export interface XoResult { 9 | xo?: string; 10 | pkgPath?: string; 11 | pkgJson?: any; 12 | } 13 | 14 | interface PkgJson { 15 | dependencies: Record; 16 | devDependencies: Record; 17 | } 18 | 19 | type Deps = Record; 20 | 21 | export function parseSeverity(severity: number): node.DiagnosticSeverity { 22 | switch (severity) { 23 | case 1: { 24 | return node.DiagnosticSeverity.Warning; 25 | } 26 | 27 | case 2: { 28 | return node.DiagnosticSeverity.Error; 29 | } 30 | 31 | default: { 32 | return node.DiagnosticSeverity.Error; 33 | } 34 | } 35 | } 36 | 37 | export function makeDiagnostic(problem: Linter.LintMessage): node.Diagnostic { 38 | const message = 39 | problem.ruleId === null ? `${problem.message}` : `${problem.message} (${problem.ruleId})`; 40 | return { 41 | message, 42 | severity: parseSeverity(problem.severity), 43 | code: problem.ruleId ?? '', 44 | source: 'XO', 45 | range: { 46 | start: {line: problem.line - 1, character: problem.column - 1}, 47 | end: { 48 | line: typeof problem.endLine === 'number' ? problem.endLine - 1 : problem.line - 1, 49 | character: 50 | typeof problem.endColumn === 'number' ? problem.endColumn - 1 : problem.column - 1 51 | } 52 | } 53 | }; 54 | } 55 | 56 | export function computeKey(diagnostic: node.Diagnostic): string { 57 | const {range} = diagnostic; 58 | return `[${range.start.line},${range.start.character},${range.end.line},${range.end.character}]-${ 59 | diagnostic.code?.toString() ?? '' 60 | }`; 61 | } 62 | 63 | /** 64 | * Shortcut to convert a uri string to a path 65 | * URI.parse(uri).fsPath 66 | * 67 | * @param uri 68 | * @returns the uri fsPath 69 | */ 70 | export function uriToPath(uri: string): string { 71 | return URI.parse(uri).fsPath; 72 | } 73 | 74 | /** 75 | * Shortcut to convert a path to a uri string 76 | * URI.file(path).toString() 77 | * 78 | * @param path 79 | * @returns uri string 80 | */ 81 | export function pathToUri(path: string): string { 82 | return URI.file(path).toString(); 83 | } 84 | 85 | /** 86 | * recursively searches up the directory tree to 87 | * find the nearest directory with a package json with an xo 88 | * dependency. Returns an empty object if none can be found. 89 | */ 90 | export async function findXoRoot(cwd: string): Promise { 91 | const {findUp} = await import('find-up'); 92 | const pkgPath = await findUp('package.json', {cwd}); 93 | 94 | if (!pkgPath) return {}; 95 | 96 | const pkgJson: PkgJson = await loadJsonFile(pkgPath); 97 | 98 | const deps: Deps = { 99 | ...pkgJson?.dependencies, 100 | ...pkgJson?.devDependencies 101 | }; 102 | 103 | if (deps?.xo) { 104 | return { 105 | xo: deps?.xo, 106 | pkgPath, 107 | pkgJson 108 | }; 109 | } 110 | 111 | return findXoRoot(path.resolve(path.dirname(pkgPath), '..')); 112 | } 113 | -------------------------------------------------------------------------------- /test/code-actions-builder.test.ts: -------------------------------------------------------------------------------- 1 | import {test, describe} from 'node:test'; 2 | import assert from 'node:assert'; 3 | import {TextDocument} from 'vscode-languageserver-textdocument'; 4 | import { 5 | CodeAction, 6 | Diagnostic, 7 | Range, 8 | Position, 9 | DiagnosticSeverity, 10 | CodeActionKind 11 | } from 'vscode-languageserver'; 12 | import {QuickFixCodeActionsBuilder} from '../server/code-actions-builder'; 13 | 14 | const testTextDocument: TextDocument = TextDocument.create( 15 | 'file:///test.js', 16 | 'javascript', 17 | 1, 18 | 'const foo = 1;\nconst bar = 2;\n' 19 | ); 20 | 21 | describe('QuickFixCodeActionsBuilder:', () => { 22 | test('Server is a function', (t) => { 23 | assert.strictEqual(typeof QuickFixCodeActionsBuilder, 'function'); 24 | }); 25 | 26 | test('ignores non xo code actions', (t) => { 27 | const diagnostic = Diagnostic.create( 28 | Range.create(Position.create(0, 0), Position.create(0, 0)), 29 | 'test message', 30 | DiagnosticSeverity.Error, 31 | 'test', 32 | 'non-xo' 33 | ); 34 | 35 | const builder = new QuickFixCodeActionsBuilder( 36 | testTextDocument, 37 | [diagnostic], 38 | undefined, 39 | undefined 40 | ); 41 | 42 | const codeAction = builder.build(); 43 | 44 | assert.deepStrictEqual(codeAction, []); 45 | }); 46 | 47 | describe('Disable rule actions:', () => { 48 | const diagnostic = Diagnostic.create( 49 | Range.create(Position.create(0, 0), Position.create(0, 0)), 50 | 'test message', 51 | DiagnosticSeverity.Error, 52 | 'test-rule', 53 | 'XO' 54 | ); 55 | 56 | const builder = new QuickFixCodeActionsBuilder( 57 | testTextDocument, 58 | [diagnostic], 59 | undefined, 60 | undefined 61 | ); 62 | test('Creates ignore same line code action', (t) => { 63 | const codeActions = builder.build(); 64 | assert.equal(Array.isArray(codeActions) && codeActions.length === 3, true); 65 | const codeAction = codeActions.find( 66 | (action) => action.title === `Ignore Rule ${diagnostic.code}: Same Line` 67 | ); 68 | assert.strictEqual(codeAction?.kind, CodeActionKind.QuickFix); 69 | assert.strictEqual( 70 | codeAction?.edit?.changes?.[testTextDocument.uri]?.[0].newText, 71 | ` // eslint-disable-line ${diagnostic.code}` 72 | ); 73 | }); 74 | 75 | test('Creates ignore line above code action', (t) => { 76 | const codeActions = builder.build(); 77 | const codeAction = codeActions.find( 78 | (action) => action.title === `Ignore Rule ${diagnostic.code}: Line Above` 79 | ); 80 | assert.strictEqual(codeAction?.kind, CodeActionKind.QuickFix); 81 | assert.strictEqual( 82 | codeAction?.edit?.changes?.[testTextDocument.uri]?.[0].newText, 83 | `// eslint-disable-next-line ${diagnostic.code}\n` 84 | ); 85 | }); 86 | 87 | test('Creates ignore entire file code action', (t) => { 88 | const codeActions = builder.build(); 89 | const codeAction = codeActions.find( 90 | (action) => action.title === `Ignore Rule ${diagnostic.code}: Entire File` 91 | ); 92 | assert.strictEqual(codeAction?.kind, CodeActionKind.QuickFix); 93 | assert.strictEqual( 94 | codeAction?.edit?.changes?.[testTextDocument.uri]?.[0].newText, 95 | `/* eslint-disable ${diagnostic.code} */\n` 96 | ); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import-x/no-unassigned-import */ 2 | // since globs are not fully supported in node v18 and v20 we import the files manually here 3 | import process from 'node:process'; 4 | // TODO: remove this file once node v21 is LTS 5 | import './server.test.js'; 6 | import './lsp/document-sync.test.js'; 7 | import './lsp/initialization.test.js'; 8 | import './lsp/code-actions.test.js'; 9 | import './code-actions-builder.test.js'; 10 | 11 | process.on('unhandledRejection', (error) => { 12 | console.error(error); 13 | process.exit(1); 14 | }); 15 | -------------------------------------------------------------------------------- /test/lsp/code-actions.test.ts: -------------------------------------------------------------------------------- 1 | import {test, describe, mock, type Mock} from 'node:test'; 2 | import {setTimeout} from 'node:timers/promises'; 3 | import assert from 'node:assert'; 4 | import {TextDocument} from 'vscode-languageserver-textdocument'; 5 | import { 6 | Position, 7 | Diagnostic, 8 | CodeActionKind, 9 | type CodeActionParams, 10 | type Range, 11 | type TextDocumentIdentifier 12 | // type Connection 13 | } from 'vscode-languageserver'; 14 | import Server from '../../server/server.js'; 15 | import { 16 | getCodeActionParams, 17 | getIgnoreSameLineCodeAction, 18 | getIgnoreNextLineCodeAction, 19 | getIgnoreFileCodeAction, 20 | getTextDocument 21 | } from '../stubs.js'; 22 | 23 | // eslint-disable-next-line @typescript-eslint/no-empty-function 24 | const noop = () => {}; 25 | 26 | describe('Server code actions', async () => { 27 | let server: Omit & { 28 | log: Mock; 29 | getDocumentFormatting: Mock; 30 | documents: Map & {all?: typeof Map.prototype.values}; 31 | getDocumentConfig: Mock; 32 | }; 33 | 34 | test.beforeEach((t) => { 35 | const documents: Map & {all?: typeof Map.prototype.values} = new Map([ 36 | ['uri', TextDocument.create('uri', 'javascript', 1, 'content')], 37 | ['uri/node_modules', TextDocument.create('uri/node_modules', 'javascript', 1, 'content')] 38 | ]); 39 | documents.all = documents.values; 40 | // @ts-expect-error readonly 41 | Server.prototype.documents = documents; 42 | // @ts-expect-error painfully difficult to type, but the declaration is correct 43 | server = new Server({isTest: true}); 44 | server.documents = documents; 45 | mock.method(server, 'log', noop); 46 | mock.method(server, 'getDocumentFormatting'); 47 | mock.method(server, 'getDocumentConfig', async () => ({enable: true})); 48 | }); 49 | 50 | test.afterEach(async () => { 51 | await server.handleShutdown(); 52 | // @ts-expect-error this helps cleanup and keep types clean 53 | server = undefined; 54 | mock.restoreAll(); 55 | }); 56 | 57 | test('Server.handleCodeActionRequest is a function', (t) => { 58 | assert.equal(typeof server.handleCodeActionRequest, 'function'); 59 | }); 60 | 61 | await test('Server.handleCodeActionRequest returns an empty array if no code actions are available', async (t) => { 62 | const textDocument: TextDocumentIdentifier = {uri: 'uri'}; 63 | const range: Range = {start: Position.create(0, 0), end: Position.create(0, 0)}; 64 | const mockCodeActionParams: CodeActionParams = { 65 | textDocument, 66 | range, 67 | context: {diagnostics: [Diagnostic.create(range, 'test message', 1, 'test', 'test')]} 68 | }; 69 | const codeActions = await server.handleCodeActionRequest(mockCodeActionParams); 70 | assert.equal(server.getDocumentConfig.mock.callCount(), 1); 71 | assert.deepEqual(codeActions, []); 72 | }); 73 | 74 | await test('codeActionKind source.fixAll calls getDocumentFormatting for the document', async (t) => { 75 | const textDocument: TextDocumentIdentifier = {uri: 'uri'}; 76 | const range: Range = {start: Position.create(0, 0), end: Position.create(0, 0)}; 77 | const mockCodeActionParams: CodeActionParams = { 78 | textDocument, 79 | range, 80 | context: { 81 | diagnostics: [Diagnostic.create(range, 'test message', 1, 'test', 'test')], 82 | only: ['source.fixAll'] 83 | } 84 | }; 85 | const codeActions = await server.handleCodeActionRequest(mockCodeActionParams); 86 | assert.equal(server.getDocumentConfig.mock.callCount(), 1); 87 | assert.deepEqual(codeActions, [ 88 | { 89 | title: 'Fix all XO auto-fixable problems', 90 | kind: 'source.fixAll', 91 | edit: {changes: {uri: []}} 92 | } 93 | ]); 94 | assert.equal(server.getDocumentConfig.mock.callCount(), 1); 95 | assert.equal(server.getDocumentFormatting.mock.callCount(), 1); 96 | assert.deepEqual(server.getDocumentFormatting.mock.calls[0].arguments, ['uri']); 97 | }); 98 | 99 | await test('codeActionKind source.fixAll.xo calls getDocumentFormatting for the document', async (t) => { 100 | const textDocument: TextDocumentIdentifier = {uri: 'uri'}; 101 | const range: Range = {start: Position.create(0, 0), end: Position.create(0, 0)}; 102 | const mockCodeActionParams: CodeActionParams = { 103 | textDocument, 104 | range, 105 | context: { 106 | diagnostics: [Diagnostic.create(range, 'test message', 1, 'test', 'test')], 107 | only: ['source.fixAll.xo'] 108 | } 109 | }; 110 | const codeActions = await server.handleCodeActionRequest(mockCodeActionParams); 111 | assert.equal(server.getDocumentConfig.mock.callCount(), 1); 112 | assert.deepEqual(codeActions, [ 113 | { 114 | title: 'Fix all XO auto-fixable problems', 115 | kind: 'source.fixAll.xo', 116 | edit: {changes: {uri: []}} 117 | } 118 | ]); 119 | assert.equal(server.getDocumentConfig.mock.callCount(), 1); 120 | assert.equal(server.getDocumentFormatting.mock.callCount(), 1); 121 | assert.deepEqual(server.getDocumentFormatting.mock.calls[0].arguments, ['uri']); 122 | }); 123 | 124 | await test('codeActionKind only source.quickfix does not call getDocumentFormatting for the document', async (t) => { 125 | const textDocument: TextDocumentIdentifier = {uri: 'uri'}; 126 | const range: Range = {start: Position.create(0, 0), end: Position.create(0, 0)}; 127 | const mockCodeActionParams: CodeActionParams = { 128 | textDocument, 129 | range, 130 | context: { 131 | diagnostics: [Diagnostic.create(range, 'test message', 1, 'test', 'test')], 132 | only: ['source.quickfix'] 133 | } 134 | }; 135 | const codeActions = await server.handleCodeActionRequest(mockCodeActionParams); 136 | assert.equal(server.getDocumentConfig.mock.callCount(), 1); 137 | assert.deepEqual(codeActions, []); 138 | assert.equal(server.getDocumentFormatting.mock.callCount(), 0); 139 | }); 140 | 141 | await test('codeAction without "only" does not call getDocumentFormatting for the document', async (t) => { 142 | const textDocument: TextDocumentIdentifier = {uri: 'uri'}; 143 | const range: Range = {start: Position.create(0, 0), end: Position.create(0, 0)}; 144 | const mockCodeActionParams: CodeActionParams = { 145 | textDocument, 146 | range, 147 | context: { 148 | diagnostics: [Diagnostic.create(range, 'test message', 1, 'test', 'test')] 149 | } 150 | }; 151 | const codeActions = await server.handleCodeActionRequest(mockCodeActionParams); 152 | assert.equal(server.getDocumentConfig.mock.callCount(), 1); 153 | assert.deepEqual(codeActions, []); 154 | assert.equal(server.getDocumentFormatting.mock.callCount(), 0); 155 | }); 156 | 157 | await test('codeAction without "only" produces quickfix code actions', async (t) => { 158 | const codeActions = await server.handleCodeActionRequest(getCodeActionParams()); 159 | 160 | assert.deepStrictEqual(codeActions, [ 161 | getIgnoreSameLineCodeAction(), 162 | getIgnoreNextLineCodeAction(), 163 | getIgnoreFileCodeAction() 164 | ]); 165 | }); 166 | 167 | await test('codeAction with only quickfix produces quickfix code actions', async (t) => { 168 | const params = getCodeActionParams(); 169 | params.context.only = [CodeActionKind.QuickFix]; 170 | const codeActions = await server.handleCodeActionRequest(params); 171 | 172 | assert.deepStrictEqual(codeActions, [ 173 | getIgnoreSameLineCodeAction(), 174 | getIgnoreNextLineCodeAction(), 175 | getIgnoreFileCodeAction() 176 | ]); 177 | }); 178 | 179 | await test('codeAction with only quickfix produces quickfix code actions', async (t) => { 180 | const params = getCodeActionParams(); 181 | params.context.only = [CodeActionKind.QuickFix]; 182 | mock.method(server, 'getDocumentConfig', async () => ({enable: false})); 183 | const codeActions = await server.handleCodeActionRequest(params); 184 | 185 | assert.deepStrictEqual(codeActions, undefined); 186 | }); 187 | }); 188 | -------------------------------------------------------------------------------- /test/lsp/document-sync.test.ts: -------------------------------------------------------------------------------- 1 | import {test, describe, mock, type Mock} from 'node:test'; 2 | import assert from 'node:assert'; 3 | import {TextDocument} from 'vscode-languageserver-textdocument'; 4 | import {type Connection} from 'vscode-languageserver'; 5 | import Server from '../../server/server.js'; 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-empty-function 8 | const noop = () => {}; 9 | 10 | describe('Server documents syncing', () => { 11 | let server: Omit< 12 | Server, 13 | 'lintDocument' | 'documents' | 'connection' | 'log' | 'getDocumentConfig' 14 | > & { 15 | lintDocument: Mock; 16 | log: Mock; 17 | documents: Map & {all?: typeof Map.prototype.values}; 18 | getDocumentConfig: Mock; 19 | connection: Omit & { 20 | sendDiagnostics: Mock; 21 | }; 22 | }; 23 | 24 | test.beforeEach((t) => { 25 | const documents: Map & {all?: typeof Map.prototype.values} = new Map([ 26 | ['uri', TextDocument.create('uri', 'javascript', 1, 'content')], 27 | ['uri/node_modules', TextDocument.create('uri/node_modules', 'javascript', 1, 'content')] 28 | ]); 29 | documents.all = documents.values; 30 | // @ts-expect-error readonly headaches 31 | Server.prototype.documents = documents; 32 | // @ts-expect-error this is just too hard to type with mock and not worth it 33 | server = new Server({isTest: true}); 34 | server.documents = documents; 35 | mock.method(server, 'log', noop); 36 | mock.method(server, 'lintDocument', noop); 37 | mock.method(server, 'getDocumentConfig', async () => ({enable: true})); 38 | mock.method(server.connection, 'sendDiagnostics', noop); 39 | }); 40 | 41 | test.afterEach(async () => { 42 | await server.handleShutdown(); 43 | // @ts-expect-error this helps cleanup and keep types clean 44 | server = undefined; 45 | mock.restoreAll(); 46 | }); 47 | 48 | test('Server.handleDocumentsOnDidChangeContent is a function', (t) => { 49 | assert.equal(typeof server.handleDocumentsOnDidChangeContent, 'function'); 50 | }); 51 | 52 | test('Server.handleDocumentsOnDidChangeContent calls lintDocument', async (t) => { 53 | server.handleDocumentsOnDidChangeContent({ 54 | document: TextDocument.create('uri', 'javascript', 1, 'content') 55 | }); 56 | await new Promise((resolve) => { 57 | server.queue.once('end', () => { 58 | resolve(undefined); 59 | }); 60 | }); 61 | assert.equal(server.getDocumentConfig.mock.callCount(), 1); 62 | assert.equal(server.lintDocument.mock.callCount(), 1); 63 | }); 64 | 65 | test('Server.handleDocumentsOnDidChangeContent does not lint document if version is mismatched', async (t) => { 66 | server.handleDocumentsOnDidChangeContent({ 67 | document: TextDocument.create('uri', 'javascript', 2, 'content') 68 | }); 69 | await new Promise((resolve) => { 70 | server.queue.once('end', () => { 71 | resolve(undefined); 72 | }); 73 | }); 74 | assert.equal(server.lintDocument.mock.callCount(), 0); 75 | }); 76 | 77 | test('Server.handleDocumentsOnDidChangeContent does not lint document if document is in node_modules and logs message', async (t) => { 78 | server.handleDocumentsOnDidChangeContent({ 79 | document: TextDocument.create('uri/node_modules', 'javascript', 1, 'content') 80 | }); 81 | await new Promise((resolve) => { 82 | server.queue.once('end', () => { 83 | resolve(undefined); 84 | }); 85 | }); 86 | assert.equal(server.log.mock.callCount(), 1); 87 | assert.equal(server.log.mock.calls[0].arguments[0], 'skipping node_modules file'); 88 | assert.equal(server.lintDocument.mock.callCount(), 0); 89 | }); 90 | 91 | test('Server.handleDocumentsOnDidClose sends empty diagnostics for closed file', async (t) => { 92 | await server.handleDocumentsOnDidClose({ 93 | document: TextDocument.create('uri', 'javascript', 1, 'content') 94 | }); 95 | assert.equal(server.connection.sendDiagnostics.mock.callCount(), 1); 96 | assert.deepEqual(server.connection.sendDiagnostics.mock.calls[0].arguments[0], { 97 | uri: 'uri', 98 | diagnostics: [] 99 | }); 100 | }); 101 | 102 | test('Server.handleDocumentOnDidChangeContent does not send diagnostics if xo is disabled', async (t) => { 103 | mock.method(server, 'getDocumentConfig', async () => ({enable: false})); 104 | server.handleDocumentsOnDidChangeContent({ 105 | document: TextDocument.create('uri', 'javascript', 1, 'content') 106 | }); 107 | await new Promise((resolve) => { 108 | server.queue.once('end', () => { 109 | resolve(undefined); 110 | }); 111 | }); 112 | assert.equal(server.getDocumentConfig.mock.callCount(), 1); 113 | assert.equal(server.connection.sendDiagnostics.mock.callCount(), 0); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /test/lsp/initialization.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, test} from 'node:test'; 2 | import assert from 'node:assert'; 3 | import Server from '../../server/server.js'; 4 | 5 | describe('Server.handleInitialize', async () => { 6 | const server = new Server({isTest: true}); 7 | 8 | await test('Server.handleInitialize is a function', (t) => { 9 | assert.equal(typeof server.handleInitialize, 'function'); 10 | }); 11 | 12 | await test('InitializeResult matches snapshot', async (t) => { 13 | const result = await server.handleInitialize(); 14 | assert.deepEqual(result, { 15 | capabilities: { 16 | workspace: {workspaceFolders: {supported: true}}, 17 | textDocumentSync: {openClose: true, change: 2}, 18 | documentFormattingProvider: true, 19 | documentRangeFormattingProvider: true, 20 | codeActionProvider: {codeActionKinds: ['quickfix', 'source.fixAll', 'source.fixAll.xo']} 21 | } 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/server.test.ts: -------------------------------------------------------------------------------- 1 | import {test, describe} from 'node:test'; 2 | import assert from 'node:assert'; 3 | import Server from '../server/server.js'; 4 | 5 | describe('Server', () => { 6 | test('Server is a function', (t) => { 7 | assert.strictEqual(typeof Server, 'function'); 8 | }); 9 | 10 | test('Server can instantiate', (t) => { 11 | const server = new Server({isTest: true}); 12 | assert.strictEqual(typeof server, 'object'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /test/stubs.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CodeAction, 3 | Diagnostic, 4 | Range, 5 | Position, 6 | DiagnosticSeverity, 7 | CodeActionKind, 8 | uinteger, 9 | type CodeActionParams 10 | } from 'vscode-languageserver'; 11 | 12 | export const getTextDocument = () => ({uri: 'uri'}); 13 | export const getZeroPosition = () => Position.create(0, 0); 14 | export const getZeroRange = () => Range.create(getZeroPosition(), getZeroPosition()); 15 | export const getXoDiagnostic = () => 16 | Diagnostic.create(getZeroRange(), 'test message', DiagnosticSeverity.Error, 'test', 'XO'); 17 | export const getCodeActionParams = (): CodeActionParams => ({ 18 | textDocument: getTextDocument(), 19 | range: getZeroRange(), 20 | context: {diagnostics: [getXoDiagnostic()]} 21 | }); 22 | 23 | export const getIgnoreSameLineCodeAction = () => ({ 24 | ...CodeAction.create( 25 | 'Ignore Rule test: Same Line', 26 | { 27 | changes: { 28 | uri: [ 29 | { 30 | range: Range.create( 31 | Position.create(0, uinteger.MAX_VALUE), 32 | Position.create(0, uinteger.MAX_VALUE) 33 | ), 34 | newText: ' // eslint-disable-line test' 35 | } 36 | ] 37 | } 38 | }, 39 | CodeActionKind.QuickFix 40 | ), 41 | diagnostics: [getXoDiagnostic()] 42 | }); 43 | 44 | export const getIgnoreNextLineCodeAction = () => ({ 45 | ...CodeAction.create( 46 | 'Ignore Rule test: Line Above', 47 | { 48 | changes: { 49 | uri: [ 50 | { 51 | range: getZeroRange(), 52 | newText: '// eslint-disable-next-line test\n' 53 | } 54 | ] 55 | } 56 | }, 57 | CodeActionKind.QuickFix 58 | ), 59 | diagnostics: [getXoDiagnostic()] 60 | }); 61 | 62 | export const getIgnoreFileCodeAction = () => ({ 63 | ...CodeAction.create( 64 | 'Ignore Rule test: Entire File', 65 | { 66 | changes: { 67 | uri: [ 68 | { 69 | range: getZeroRange(), 70 | newText: '/* eslint-disable test */\n' 71 | } 72 | ] 73 | } 74 | }, 75 | CodeActionKind.QuickFix 76 | ), 77 | diagnostics: [getXoDiagnostic()] 78 | }); 79 | -------------------------------------------------------------------------------- /test/tests.md: -------------------------------------------------------------------------------- 1 | # VSCode Linter XO testing 2 | 3 | Tests focus on integration at the server level, most unit test cases are covered by the server integration tests. 4 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules"], 4 | "include": ["server/**/*.ts", "client/**/*.ts"] 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2021", 4 | "module": "Node16", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "lib": ["es2021"], 8 | "strict": true, 9 | "noEmit": true, 10 | "resolveJsonModule": true, 11 | "skipLibCheck": true 12 | }, 13 | "exclude": ["node_modules", "dist"] 14 | } 15 | -------------------------------------------------------------------------------- /xo.config.ts: -------------------------------------------------------------------------------- 1 | const xoConfig = [ 2 | { 3 | prettier: true, 4 | rules: { 5 | 'unicorn/prefer-module': 'off', 6 | 'unicorn/prevent-abbreviations': 'off', 7 | 'import-x/extensions': 'off', 8 | 'capitalized-comments': 'off', 9 | 'no-warning-comments': 'off' 10 | } 11 | }, 12 | { 13 | files: '**/*.ts', 14 | rules: { 15 | '@typescript-eslint/consistent-type-definitions': ['error', 'interface'] 16 | } 17 | }, 18 | { 19 | files: 'test/**/*.ts', 20 | rules: { 21 | '@typescript-eslint/no-floating-promises': 'off' 22 | } 23 | } 24 | ]; 25 | 26 | export default xoConfig; 27 | --------------------------------------------------------------------------------