diff --git a/apps/oxlint/package.json b/apps/oxlint/package.json index 35d8928680845..e25b88f7088a4 100644 --- a/apps/oxlint/package.json +++ b/apps/oxlint/package.json @@ -37,7 +37,9 @@ }, "devDependencies": { "@arethetypeswrong/core": "catalog:", + "@babel/code-frame": "^7.28.6", "@napi-rs/cli": "catalog:", + "@types/babel__code-frame": "^7.27.0", "@types/esquery": "^1.5.4", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", @@ -59,7 +61,8 @@ "tsx": "^4.21.0", "type-fest": "^5.2.0", "typescript": "catalog:", - "vitest": "catalog:" + "vitest": "catalog:", + "vscode-languageserver-protocol": "^3.17.5" }, "napi": { "binaryName": "oxlint", diff --git a/apps/oxlint/test/lsp/init/init.test.ts b/apps/oxlint/test/lsp/init/init.test.ts new file mode 100644 index 0000000000000..375628e082096 --- /dev/null +++ b/apps/oxlint/test/lsp/init/init.test.ts @@ -0,0 +1,77 @@ +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; +import { createLspConnection } from "../utils"; +import { WatchKind } from "vscode-languageserver-protocol/node"; + +describe("LSP initialization", () => { + it("should start LSP server and respond to initialize request", async () => { + const dirPath = import.meta.dirname; + await using client = createLspConnection(); + const initResult = await client.initialize([ + { uri: pathToFileURL(dirPath).href, name: "test" }, + ]); + + expect(initResult.capabilities.diagnosticProvider).toBeUndefined(); + expect(initResult.serverInfo?.name).toBe("oxlint"); + }); + + it("should start LSP server with diagnostics provider", async () => { + const dirPath = import.meta.dirname; + await using client = createLspConnection(); + const initResult = await client.initialize( + [{ uri: pathToFileURL(dirPath).href, name: "test" }], + { + textDocument: { + diagnostic: {}, + }, + workspace: { + diagnostics: { + refreshSupport: true, + }, + }, + }, + ); + + expect(initResult.capabilities.diagnosticProvider).toBeDefined(); + expect(initResult.serverInfo?.name).toBe("oxlint"); + }); + + it.each([ + [undefined, ["**/.oxlintrc.json", "**/oxlint.config.ts"]], + [{ configPath: "" }, ["**/.oxlintrc.json", "**/oxlint.config.ts"]], + [{ configPath: "./custom-config.json" }, ["./custom-config.json"]], + ])( + "should send correct dynamic watch pattern registration for config: %s", + async (lspConfig, expectedPatterns) => { + const dirUri = pathToFileURL(import.meta.dirname).href; + await using client = createLspConnection(); + await client.initialize( + [{ uri: dirUri, name: "test" }], + { + workspace: { + didChangeWatchedFiles: { + dynamicRegistration: true, + }, + }, + }, + [{ workspaceUri: dirUri, options: lspConfig }], + ); + const registrations = await client.getDynamicRegistration(); + expect(registrations).toEqual([ + { + id: `watcher-linter-${dirUri}`, + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: expectedPatterns.map((pattern) => ({ + globPattern: { + baseUri: dirUri, + pattern, + }, + kind: WatchKind.Create | WatchKind.Change | WatchKind.Delete, + })), + }, + }, + ]); + }, + ); +}); diff --git a/apps/oxlint/test/lsp/utils.ts b/apps/oxlint/test/lsp/utils.ts new file mode 100644 index 0000000000000..4b0425da945f7 --- /dev/null +++ b/apps/oxlint/test/lsp/utils.ts @@ -0,0 +1,160 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + createMessageConnection, + DidChangeConfigurationNotification, + DidChangeTextDocumentNotification, + DidOpenTextDocumentNotification, + DocumentDiagnosticRequest, + ExitNotification, + InitializedNotification, + InitializeRequest, + RegistrationRequest, + ShutdownRequest, + StreamMessageReader, + StreamMessageWriter, + WorkspaceFolder, +} from "vscode-languageserver-protocol/node"; +import type { + ClientCapabilities, + DocumentDiagnosticReport, + Registration, +} from "vscode-languageserver-protocol/node"; +import { codeFrameColumns } from "@babel/code-frame"; + +const CLI_PATH = join(import.meta.dirname, "..", "..", "dist", "cli.js"); + +export function createLspConnection() { + const proc = spawn(process.execPath, [CLI_PATH, "--lsp"]); + + const connection = createMessageConnection( + new StreamMessageReader(proc.stdout), + new StreamMessageWriter(proc.stdin), + ); + connection.listen(); + + return { + // NOTE: Config and ignore files are searched from `workspaceFolders[].uri` upward + // Or, provide a custom config path via `initializationOptions` + async initialize( + workspaceFolders: WorkspaceFolder[], + capabilities: ClientCapabilities = {}, + initializationOptions?: unknown, + ) { + const result = await connection.sendRequest(InitializeRequest.type, { + processId: process.pid, + capabilities, + workspaceFolders, + rootUri: null, + initializationOptions, + }); + await connection.sendNotification(InitializedNotification.type, {}); + return result; + }, + + async didChangeConfiguration(settings: unknown) { + await connection.sendNotification(DidChangeConfigurationNotification.type, { settings }); + }, + + async didOpen(uri: string, languageId: string, text: string) { + await connection.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: { uri, languageId, version: 1, text }, + }); + }, + + async didChange(uri: string, text: string) { + await connection.sendNotification(DidChangeTextDocumentNotification.type, { + textDocument: { uri, version: 2 }, + contentChanges: [{ text }], + }); + }, + + async diagnostic(uri: string): Promise { + const result = await connection.sendRequest(DocumentDiagnosticRequest.type, { + textDocument: { uri }, + }); + return result; + }, + + async getDynamicRegistration(): Promise { + return await new Promise((resolve) => { + const disposer = connection.onRequest(RegistrationRequest.type, (params) => { + resolve(params.registrations); + disposer.dispose(); + }); + }); + }, + + async [Symbol.asyncDispose]() { + await connection.sendRequest(ShutdownRequest.type); + await connection.sendNotification(ExitNotification.type); + connection.dispose(); + proc.kill(); + }, + }; +} + +// --- + +export async function lintFixture( + fixturesDir: string, + fixturePath: string, + languageId: string, + initializationOptions?: OxlintLSPConfig, +): Promise { + const filePath = join(fixturesDir, fixturePath); + const dirPath = dirname(filePath); + const fileUri = pathToFileURL(filePath).href; + const content = await fs.readFile(filePath, "utf-8"); + + await using client = createLspConnection(); + + await client.initialize( + [{ uri: pathToFileURL(dirPath).href, name: "test" }], + { + textDocument: { + diagnostic: {}, + }, + workspace: { + diagnostics: { + refreshSupport: true, + }, + }, + }, + [ + { + workspaceUri: pathToFileURL(dirPath).href, + options: initializationOptions, + }, + ], + ); + await client.didOpen(fileUri, languageId, content); + + const diagnostics = await client.diagnostic(fileUri); + + return ` +--- FILE ----------- +${fixturePath} +--- Diagnostics --------- +${applyDiagnostics(content, diagnostics).join("\n--------------------")} +-------------------- +`.trim(); +} + +// --- + +type OxlintLSPConfig = {}; + +function applyDiagnostics(content: string, report: DocumentDiagnosticReport): string[] { + if (report.kind !== "full") { + throw new Error("Only full reports are supported by oxlint lsp"); + } + + return report.items.map((diagnostic) => + codeFrameColumns(content, diagnostic.range, { + message: diagnostic.message, + }), + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 355d1347010fa..dd1ff641fb1b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,9 +104,15 @@ importers: '@arethetypeswrong/core': specifier: 'catalog:' version: 0.18.2 + '@babel/code-frame': + specifier: ^7.28.6 + version: 7.28.6 '@napi-rs/cli': specifier: 'catalog:' version: 3.5.1(@emnapi/runtime@1.8.1)(@types/node@24.1.0) + '@types/babel__code-frame': + specifier: ^7.27.0 + version: 7.27.0 '@types/esquery': specifier: ^1.5.4 version: 1.5.4 @@ -173,6 +179,9 @@ importers: vitest: specifier: 'catalog:' version: 4.0.18(@types/node@24.1.0)(@vitest/browser-playwright@4.0.18)(happy-dom@20.0.11)(jiti@2.6.1)(terser@5.44.1)(tsx@4.21.0) + vscode-languageserver-protocol: + specifier: ^3.17.5 + version: 3.17.5 napi/minify: devDependencies: @@ -2421,6 +2430,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/babel__code-frame@7.27.0': + resolution: {integrity: sha512-Dwlo+LrxDx/0SpfmJ/BKveHf7QXWvLBLc+x03l5sbzykj3oB9nHygCpSECF1a+s+QIxbghe+KHqC90vGtxLRAA==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -3270,8 +3282,11 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} - get-tsconfig@4.13.1: - resolution: {integrity: sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==} + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + + get-tsconfig@4.13.3: + resolution: {integrity: sha512-vp8Cj/+9Q/ibZUrq1rhy8mCTQpCk31A3uu9wc1C50yAb3x2pFHOsGdAZQ7jD86ARayyxZUViYeIztW+GE8dcrg==} glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} @@ -6273,6 +6288,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@types/babel__code-frame@7.27.0': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -7332,7 +7349,11 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 - get-tsconfig@4.13.1: + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + get-tsconfig@4.13.3: dependencies: resolve-pkg-maps: 1.0.0 @@ -7824,7 +7845,7 @@ snapshots: ast-kit: 3.0.0-beta.1 birpc: 4.0.0 dts-resolver: 2.1.3 - get-tsconfig: 4.13.1 + get-tsconfig: 4.13.3 obug: 2.1.1 rolldown: 1.0.0-rc.3 optionalDependencies: @@ -8150,7 +8171,7 @@ snapshots: tsx@4.21.0: dependencies: esbuild: 0.27.2 - get-tsconfig: 4.13.1 + get-tsconfig: 4.13.0 optionalDependencies: fsevents: 2.3.3