mirror of
https://github.com/oxc-project/oxc.git
synced 2026-09-14 19:36:11 +08:00
eabfe016cf
Refs: voidzero-dev/vite-plus#2245 (I had assumed Oxlint didn't have an option for LSP either, but that wasn't the case.)
296 lines
8.8 KiB
TypeScript
296 lines
8.8 KiB
TypeScript
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,
|
|
DocumentFormattingRequest,
|
|
ExitNotification,
|
|
InitializedNotification,
|
|
InitializeRequest,
|
|
RegistrationRequest,
|
|
ShutdownRequest,
|
|
StreamMessageReader,
|
|
StreamMessageWriter,
|
|
WorkspaceFolder,
|
|
} from "vscode-languageserver-protocol/node";
|
|
import { TextDocument } from "vscode-languageserver-textdocument";
|
|
import type {
|
|
ClientCapabilities,
|
|
Registration,
|
|
TextEdit,
|
|
} from "vscode-languageserver-protocol/node";
|
|
|
|
const CLI_PATH = join(import.meta.dirname, "..", "..", "dist", "cli.js");
|
|
|
|
export function createLspConnection(env: Record<string, string> = {}) {
|
|
const proc = spawn("node", [CLI_PATH, "--lsp"], {
|
|
env: { ...process.env, ...env },
|
|
});
|
|
|
|
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[] | null,
|
|
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 format(uri: string) {
|
|
return connection.sendRequest(DocumentFormattingRequest.type, {
|
|
textDocument: { uri },
|
|
// NOTE: These options are required by LSP spec, but our config will take precedence
|
|
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#formattingOptions
|
|
options: { tabSize: 2, insertSpaces: true },
|
|
});
|
|
},
|
|
|
|
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 formatFixture(
|
|
fixturesDir: string,
|
|
fixturePath: string,
|
|
languageId: string,
|
|
clientOrConfig?: OxfmtLSPConfig | ReturnType<typeof createLspConnection>,
|
|
): Promise<string> {
|
|
const filePath = join(fixturesDir, fixturePath);
|
|
const fileUri = pathToFileURL(filePath).href;
|
|
|
|
return await formatFixtureContent(fixturesDir, fixturePath, fileUri, languageId, clientOrConfig);
|
|
}
|
|
|
|
export async function formatSingleFileFixture(
|
|
fixturesDir: string,
|
|
fixturePath: string,
|
|
languageId: string,
|
|
): Promise<string> {
|
|
const filePath = join(fixturesDir, fixturePath);
|
|
const fileUri = pathToFileURL(filePath).href;
|
|
const content = await fs.readFile(filePath, "utf-8");
|
|
|
|
const client = createLspConnection();
|
|
await client.initialize(null);
|
|
await client.didOpen(fileUri, languageId, content);
|
|
const edits = await client.format(fileUri);
|
|
|
|
return `${uriSnapshotHeader(fileUri, fixturesDir)}
|
|
--- BEFORE ---------
|
|
${content}
|
|
--- AFTER ----------
|
|
${applyEdits(content, edits, languageId)}
|
|
--------------------
|
|
`.trim();
|
|
}
|
|
|
|
export async function formatFixtureContent(
|
|
fixturesDir: string,
|
|
fixturePath: string,
|
|
fileUri: string,
|
|
languageId: string,
|
|
clientOrConfig?: OxfmtLSPConfig | ReturnType<typeof createLspConnection>,
|
|
): Promise<string> {
|
|
const filePath = join(fixturesDir, fixturePath);
|
|
const dirPath = dirname(filePath);
|
|
const content = await fs.readFile(filePath, "utf-8");
|
|
|
|
let innerClient: ReturnType<typeof createLspConnection> | undefined;
|
|
|
|
if (clientOrConfig === undefined || !("initialize" in clientOrConfig)) {
|
|
innerClient = createLspConnection();
|
|
|
|
await innerClient.initialize([{ uri: pathToFileURL(dirPath).href, name: "test" }], {}, [
|
|
{
|
|
workspaceUri: pathToFileURL(dirPath).href,
|
|
options: clientOrConfig,
|
|
},
|
|
]);
|
|
|
|
clientOrConfig = innerClient;
|
|
}
|
|
await clientOrConfig.didOpen(fileUri, languageId, content);
|
|
|
|
const edits = await clientOrConfig.format(fileUri);
|
|
|
|
if (innerClient) {
|
|
await innerClient[Symbol.asyncDispose]();
|
|
}
|
|
|
|
return `${uriSnapshotHeader(fileUri, fixturesDir)}
|
|
--- BEFORE ---------
|
|
${content}
|
|
--- AFTER ----------
|
|
${applyEdits(content, edits, languageId)}
|
|
--------------------
|
|
`.trim();
|
|
}
|
|
|
|
export async function formatMultipleFixtures(
|
|
fixturesDir: string,
|
|
workspaceDir: string,
|
|
files: { uri: string; content: string; languageId: string }[],
|
|
clientOrConfig?: OxfmtLSPConfig | ReturnType<typeof createLspConnection>,
|
|
): Promise<string> {
|
|
let innerClient: ReturnType<typeof createLspConnection> | undefined;
|
|
|
|
if (clientOrConfig === undefined || !("initialize" in clientOrConfig)) {
|
|
innerClient = createLspConnection();
|
|
|
|
await innerClient.initialize([{ uri: pathToFileURL(workspaceDir).href, name: "test" }], {}, [
|
|
{
|
|
workspaceUri: pathToFileURL(workspaceDir).href,
|
|
options: clientOrConfig,
|
|
},
|
|
]);
|
|
|
|
clientOrConfig = innerClient;
|
|
}
|
|
|
|
const snapshot = [];
|
|
// oxlint-disable no-await-in-loop
|
|
for (const { uri, content, languageId } of files) {
|
|
await clientOrConfig.didOpen(uri, languageId, content);
|
|
const edits = await clientOrConfig.format(uri);
|
|
|
|
snapshot.push(
|
|
`${uriSnapshotHeader(uri, fixturesDir)}
|
|
--- BEFORE ---------
|
|
${content}
|
|
--- AFTER ----------
|
|
${applyEdits(content, edits, languageId)}
|
|
--------------------
|
|
`.trim(),
|
|
);
|
|
}
|
|
// oxlint-enable no-await-in-loop
|
|
|
|
if (innerClient) {
|
|
await innerClient[Symbol.asyncDispose]();
|
|
}
|
|
|
|
return snapshot.join("\n\n");
|
|
}
|
|
|
|
export async function formatFixtureAfterConfigChange(
|
|
fixturesDir: string,
|
|
fixturePath: string,
|
|
languageId: string,
|
|
initializationOptions: OxfmtLSPConfig,
|
|
configurationChangeOptions: OxfmtLSPConfig,
|
|
): 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();
|
|
|
|
// Initial format with first config
|
|
await client.initialize([{ uri: pathToFileURL(dirPath).href, name: "test" }], {}, [
|
|
{
|
|
workspaceUri: pathToFileURL(dirPath).href,
|
|
options: initializationOptions,
|
|
},
|
|
]);
|
|
await client.didOpen(fileUri, languageId, content);
|
|
const edits1 = await client.format(fileUri);
|
|
const formatted1 = applyEdits(content, edits1, languageId);
|
|
await client.didChange(fileUri, formatted1);
|
|
|
|
// Re-format with second config
|
|
await client.didChangeConfiguration([
|
|
{ workspaceUri: pathToFileURL(dirPath).href, options: configurationChangeOptions },
|
|
]);
|
|
const edits2 = await client.format(fileUri);
|
|
const formatted2 = applyEdits(formatted1, edits2, languageId);
|
|
|
|
return `${uriSnapshotHeader(fileUri, fixturesDir)}
|
|
--- BEFORE ---------
|
|
${content}
|
|
--- AFTER FIRST FORMAT ----------
|
|
${formatted1}
|
|
--- AFTER SECOND FORMAT ----------
|
|
${formatted2}
|
|
--------------------
|
|
`.trim();
|
|
}
|
|
|
|
// ---
|
|
|
|
// aligned with https://github.com/oxc-project/oxc/blob/7e6c15baaebf206ab540191da0e4e103e4fabf06/apps/oxfmt/src/lsp/options.rs
|
|
type OxfmtLSPConfig = {
|
|
"fmt.configPath"?: string | null;
|
|
"fmt.disableNestedConfig"?: boolean;
|
|
};
|
|
|
|
function applyEdits(content: string, edits: TextEdit[] | null, languageId: string): string {
|
|
if (edits === null || edits.length === 0) return content;
|
|
const doc = TextDocument.create("file:///test", languageId, 1, content);
|
|
return TextDocument.applyEdits(doc, edits);
|
|
}
|
|
|
|
function uriSnapshotHeader(fileUri: string, fixtureDir: string): string {
|
|
const fixtureUri = pathToFileURL(fixtureDir).href;
|
|
const safeUri = fileUri.startsWith(fixtureUri)
|
|
? fileUri.replace(fixtureUri, "file://<fixture>")
|
|
: fileUri;
|
|
|
|
return `
|
|
--- URI -----------
|
|
${safeUri}`;
|
|
}
|