344 lines
9.2 KiB
JavaScript
344 lines
9.2 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
const logPath = process.env.KANDEV_LSP_E2E_LOG ?? path.join(os.homedir(), "lsp-e2e-events.jsonl");
|
|
const crashOnOpenPath = path.join(os.homedir(), "lsp-e2e-crash-on-open");
|
|
const initializeModePath = path.join(os.homedir(), "lsp-e2e-initialize-mode.json");
|
|
const initializeReleasePath = path.join(os.homedir(), "lsp-e2e-initialize-release");
|
|
let input = Buffer.alloc(0);
|
|
let nextServerRequestId = 10_000;
|
|
const openDocumentUris = new Set();
|
|
const definitionTargetFile = "nested/references/Definition Target # query? 100%.kt";
|
|
|
|
function log(event, details = {}) {
|
|
fs.appendFileSync(
|
|
logPath,
|
|
`${JSON.stringify({ event, pid: process.pid, timestamp: Date.now(), ...details })}\n`,
|
|
);
|
|
}
|
|
|
|
function send(message) {
|
|
const payload = Buffer.from(JSON.stringify({ jsonrpc: "2.0", ...message }), "utf8");
|
|
process.stdout.write(`Content-Length: ${payload.length}\r\n\r\n`);
|
|
process.stdout.write(payload);
|
|
}
|
|
|
|
function diagnostic(uri, message, version) {
|
|
send({
|
|
method: "textDocument/publishDiagnostics",
|
|
params: {
|
|
uri,
|
|
...(Number.isSafeInteger(version) ? { version } : {}),
|
|
diagnostics: [
|
|
{
|
|
range: {
|
|
start: { line: 0, character: 0 },
|
|
end: { line: 0, character: 8 },
|
|
},
|
|
severity: 2,
|
|
source: "kandev-e2e",
|
|
code: "FAKE_KOTLIN",
|
|
message,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
}
|
|
|
|
function location(uri, line = 2) {
|
|
return {
|
|
uri,
|
|
range: {
|
|
start: { line, character: 4 },
|
|
end: { line, character: 13 },
|
|
},
|
|
};
|
|
}
|
|
|
|
function siblingFileUri(uri, fileName) {
|
|
const directoryEnd = uri.lastIndexOf("/") + 1;
|
|
const encodedPath = fileName
|
|
.split("/")
|
|
.map((segment) => encodeURIComponent(segment))
|
|
.join("/");
|
|
return `${uri.slice(0, directoryEnd)}${encodedPath}`;
|
|
}
|
|
|
|
function requestDocumentUri(message) {
|
|
const uri = message.params?.textDocument?.uri;
|
|
if (uri) return uri;
|
|
if (openDocumentUris.size !== 1) return null;
|
|
return openDocumentUris.values().next().value ?? null;
|
|
}
|
|
|
|
function initializeResult(id) {
|
|
return {
|
|
id,
|
|
result: {
|
|
capabilities: {
|
|
textDocumentSync: { openClose: true, change: 2, save: { includeText: true } },
|
|
completionProvider: { triggerCharacters: ["."] },
|
|
hoverProvider: true,
|
|
definitionProvider: true,
|
|
referencesProvider: true,
|
|
signatureHelpProvider: { triggerCharacters: ["(", ","] },
|
|
semanticTokensProvider: {
|
|
legend: { tokenTypes: ["function", "variable"], tokenModifiers: [] },
|
|
full: true,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function progressToken(message) {
|
|
const token = message.params?.workDoneToken;
|
|
return typeof token === "string" || typeof token === "number" ? token : null;
|
|
}
|
|
|
|
function sendInitializeProgress(message, progress) {
|
|
const token = progressToken(message);
|
|
if (token === null || !progress) return token;
|
|
send({
|
|
method: "$/progress",
|
|
params: {
|
|
token,
|
|
value: {
|
|
kind: "begin",
|
|
title: progress.title,
|
|
percentage: progress.beginPercentage,
|
|
},
|
|
},
|
|
});
|
|
send({
|
|
method: "$/progress",
|
|
params: {
|
|
token,
|
|
value: {
|
|
kind: "report",
|
|
message: progress.message,
|
|
percentage: progress.percentage,
|
|
},
|
|
},
|
|
});
|
|
log("initialize progress reported", { token, progress });
|
|
return token;
|
|
}
|
|
|
|
function handleInitialize(message) {
|
|
if (!fs.existsSync(initializeModePath)) {
|
|
send(initializeResult(message.id));
|
|
return;
|
|
}
|
|
|
|
const mode = JSON.parse(fs.readFileSync(initializeModePath, "utf8"));
|
|
const token = sendInitializeProgress(message, mode.progress);
|
|
if (mode.keepProgress) {
|
|
send(initializeResult(message.id));
|
|
log("initialize completed with active progress", { id: message.id, token });
|
|
return;
|
|
}
|
|
log("initialize held", { id: message.id, token });
|
|
const finish = () => {
|
|
if (!fs.existsSync(initializeReleasePath)) return false;
|
|
if (token !== null && mode.progress) {
|
|
send({
|
|
method: "$/progress",
|
|
params: {
|
|
token,
|
|
value: { kind: "end", message: mode.progress.endMessage },
|
|
},
|
|
});
|
|
}
|
|
send(initializeResult(message.id));
|
|
log("initialize released", { id: message.id, token });
|
|
return true;
|
|
};
|
|
if (finish()) return;
|
|
const timer = setInterval(() => {
|
|
if (finish()) clearInterval(timer);
|
|
}, 25);
|
|
}
|
|
|
|
function handleRequest(message) {
|
|
const uri = requestDocumentUri(message);
|
|
switch (message.method) {
|
|
case "initialize":
|
|
handleInitialize(message);
|
|
return;
|
|
case "textDocument/completion":
|
|
send({
|
|
id: message.id,
|
|
result: {
|
|
isIncomplete: true,
|
|
items: [
|
|
{
|
|
label: "fakeGreeting",
|
|
kind: 3,
|
|
detail: "Kandev E2E completion",
|
|
insertText: "fakeGreeting",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
return;
|
|
case "textDocument/hover":
|
|
send({
|
|
id: message.id,
|
|
result: {
|
|
contents: { kind: "markdown", value: "**Fake Kotlin hover**" },
|
|
range: location(uri).range,
|
|
},
|
|
});
|
|
return;
|
|
case "textDocument/definition":
|
|
send({
|
|
id: message.id,
|
|
result: uri ? location(siblingFileUri(uri, definitionTargetFile)) : null,
|
|
});
|
|
return;
|
|
case "textDocument/references":
|
|
send({ id: message.id, result: uri ? [location(uri), location(uri, 3)] : [] });
|
|
return;
|
|
case "textDocument/signatureHelp":
|
|
send({
|
|
id: message.id,
|
|
result: {
|
|
signatures: [
|
|
{
|
|
label: "greeting(name: String): String",
|
|
documentation: "Kandev E2E signature",
|
|
parameters: [{ label: "name: String" }],
|
|
},
|
|
],
|
|
activeSignature: 0,
|
|
activeParameter: 0,
|
|
},
|
|
});
|
|
return;
|
|
case "textDocument/semanticTokens/full":
|
|
send({ id: message.id, result: { resultId: "e2e-1", data: [2, 4, 8, 0, 0] } });
|
|
return;
|
|
case "shutdown":
|
|
send({ id: message.id, result: null });
|
|
return;
|
|
default:
|
|
send({ id: message.id, result: null });
|
|
}
|
|
}
|
|
|
|
function handleDidOpen(message) {
|
|
const uri = message.params?.textDocument?.uri;
|
|
if (uri) {
|
|
openDocumentUris.add(uri);
|
|
diagnostic(uri, "Fake Kotlin diagnostic", message.params?.textDocument?.version);
|
|
}
|
|
if (fs.existsSync(crashOnOpenPath)) {
|
|
log("crashing", { reason: "didOpen" });
|
|
process.exit(23);
|
|
}
|
|
}
|
|
|
|
function handleDidChange(message) {
|
|
const uri = message.params?.textDocument?.uri;
|
|
if (uri && openDocumentUris.has(uri)) {
|
|
diagnostic(uri, "Fake Kotlin diagnostic after edit", message.params?.textDocument?.version);
|
|
}
|
|
}
|
|
|
|
function handleDidClose(message) {
|
|
const uri = message.params?.textDocument?.uri;
|
|
if (!uri) return;
|
|
send({
|
|
method: "textDocument/publishDiagnostics",
|
|
params: { uri, diagnostics: [] },
|
|
});
|
|
openDocumentUris.delete(uri);
|
|
}
|
|
|
|
function handleNotification(message) {
|
|
switch (message.method) {
|
|
case "initialized": {
|
|
const id = nextServerRequestId++;
|
|
log("workspace/configuration requested", { id });
|
|
send({
|
|
id,
|
|
method: "workspace/configuration",
|
|
params: { items: [{ section: "kotlin" }] },
|
|
});
|
|
return;
|
|
}
|
|
case "textDocument/didOpen":
|
|
handleDidOpen(message);
|
|
return;
|
|
case "textDocument/didChange":
|
|
handleDidChange(message);
|
|
return;
|
|
case "textDocument/didClose":
|
|
handleDidClose(message);
|
|
return;
|
|
case "exit":
|
|
log("exit");
|
|
process.exit(0);
|
|
}
|
|
}
|
|
|
|
function handleMessage(message) {
|
|
if (message.method) {
|
|
log("message", { id: message.id, method: message.method, params: message.params });
|
|
if (message.id !== undefined) handleRequest(message);
|
|
else handleNotification(message);
|
|
return;
|
|
}
|
|
|
|
log("response", { id: message.id, result: message.result, error: message.error });
|
|
}
|
|
|
|
function parseInput() {
|
|
while (input.length > 0) {
|
|
const headerEnd = input.indexOf("\r\n\r\n");
|
|
if (headerEnd < 0) return;
|
|
|
|
const header = input.subarray(0, headerEnd).toString("ascii");
|
|
const lengthMatch = /^Content-Length:\s*(\d+)$/im.exec(header);
|
|
if (!lengthMatch) {
|
|
log("protocol error", { header });
|
|
process.exit(2);
|
|
}
|
|
|
|
const contentLength = Number(lengthMatch[1]);
|
|
const payloadStart = headerEnd + 4;
|
|
const payloadEnd = payloadStart + contentLength;
|
|
if (input.length < payloadEnd) return;
|
|
|
|
const payload = input.subarray(payloadStart, payloadEnd).toString("utf8");
|
|
input = input.subarray(payloadEnd);
|
|
handleMessage(JSON.parse(payload));
|
|
}
|
|
}
|
|
|
|
process.stdin.on("data", (chunk) => {
|
|
input = Buffer.concat([input, chunk]);
|
|
parseInput();
|
|
});
|
|
process.stdin.on("end", () => {
|
|
log("stdin ended");
|
|
});
|
|
process.on("SIGTERM", () => {
|
|
log("signal", { signal: "SIGTERM" });
|
|
process.exit(0);
|
|
});
|
|
process.on("SIGINT", () => {
|
|
log("signal", { signal: "SIGINT" });
|
|
process.exit(0);
|
|
});
|
|
process.on("uncaughtException", (error) => {
|
|
log("uncaught exception", { error: String(error), stack: error.stack });
|
|
process.exit(1);
|
|
});
|
|
|
|
log("started", { argv: process.argv.slice(2), cwd: process.cwd(), home: os.homedir() });
|