Files
oxc-project__oxc/apps/oxfmt/test/lsp/utils.ts
leaysgur eabfe016cf feat(oxfmt/lsp): support oxc.fmt.disableNestedConfig (#24965)
Refs: voidzero-dev/vite-plus#2245

(I had assumed Oxlint didn't have an option for LSP either, but that wasn't the case.)
2026-07-27 01:32:09 +00:00

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}`;
}