Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/oxlint/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
77 changes: 77 additions & 0 deletions apps/oxlint/test/lsp/init/init.test.ts
Original file line number Diff line number Diff line change
@@ -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,
})),
},
},
]);
},
);
});
160 changes: 160 additions & 0 deletions apps/oxlint/test/lsp/utils.ts
Original file line number Diff line number Diff line change
@@ -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<DocumentDiagnosticReport> {
const result = await connection.sendRequest(DocumentDiagnosticRequest.type, {
textDocument: { uri },
});
return result;
},

async getDynamicRegistration(): Promise<Registration[]> {
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<string> {
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,
}),
);
}
31 changes: 26 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading