{"version":3,"file":"lsp.test.d.ts","sourceRoot":"","sources":["../../../src/core/lsp/lsp.test.ts"],"names":[],"mappings":"","sourcesContent":["import { type ChildProcessWithoutNullStreams, spawn } from \"node:child_process\";\nimport { afterEach, describe, expect, it } from \"vitest\";\nimport { LspClient, toFileUri } from \"./client.js\";\nimport { evaluateDiagnosticGate, getDiagnosticRows, summarizeDiagnostics } from \"./diagnostics.js\";\nimport { detectLanguage, resolveServer } from \"./discovery.js\";\nimport { JsonRpcClient } from \"./jsonrpc.js\";\nimport { computeLineIndex, offsetToPosition, positionToOffset } from \"./position.js\";\nimport { applyTextEdits, extractEditsByPath } from \"./rename.js\";\nimport { LspDiagnosticSeverity } from \"./types.js\";\n\nconst FAKE_SERVER = `\nconst readline = require('readline');\nconst rl = readline.createInterface({ input: process.stdin });\nlet buf = '';\nrl.on('line', (line) => {\n  const m = line.match(/^Content-Length: (\\\\d+)$/i);\n  if (m) { pending = Number(m[1]); }\n});\nlet pending = null;\nprocess.stdin.on('data', (d) => {\n  buf += d.toString('latin1');\n  while (true) {\n    const h = buf.indexOf('\\\\r\\\\n\\\\r\\\\n');\n    if (h === -1) break;\n    const header = buf.slice(0, h);\n    const m = header.match(/Content-Length: (\\\\d+)/i);\n    if (!m) { buf = buf.slice(h + 4); continue; }\n    const len = Number(m[1]);\n    if (buf.length < h + 4 + len) break;\n    const body = buf.slice(h + 4, h + 4 + len);\n    buf = buf.slice(h + 4 + len);\n    const msg = JSON.parse(body);\n    if (msg.method === 'initialize') {\n      send({ id: msg.id, result: { capabilities: { positionEncoding: 'utf-16', definitionProvider: true, referencesProvider: true, hoverProvider: true, renameProvider: { prepareProvider: true }, textDocumentSync: { openClose: true, change: 1 } } } });\n    } else if (msg.method === 'shutdown') {\n      send({ id: msg.id, result: null });\n    } else if (msg.method === 'textDocument/definition') {\n      send({ id: msg.id, result: [{ uri: msg.params.textDocument.uri, range: { start: { line: 1, character: 2 }, end: { line: 1, character: 12 } } }] });\n    } else if (msg.method === 'textDocument/rename') {\n      send({ id: msg.id, result: { changes: { [msg.params.textDocument.uri]: [{ range: { start: { line: 1, character: 2 }, end: { line: 1, character: 5 } }, newText: msg.params.newName }] } } });\n    } else if (msg.method === 'textDocument/prepareRename') {\n      send({ id: msg.id, result: { range: { start: { line: 1, character: 2 }, end: { line: 1, character: 5 } } } });\n    } else if (msg.method === 'textDocument/hover') {\n      send({ id: msg.id, result: { contents: { kind: 'markdown', value: '**Doc**' } } });\n    } else if (msg.method === 'exit') {\n      process.exit(0);\n    } else if (msg.id !== undefined) {\n      send({ id: msg.id, result: null });\n    }\n    if (msg.method === 'textDocument/didOpen') {\n      send({ method: 'textDocument/publishDiagnostics', params: { uri: msg.params.textDocument.uri, diagnostics: [{ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, severity: 1, message: 'boom' }] } });\n    }\n  }\n});\nfunction send(msg) {\n  const body = JSON.stringify(msg);\n  const header = 'Content-Length: ' + Buffer.byteLength(body, 'utf-8') + '\\\\r\\\\n\\\\r\\\\n';\n  process.stdout.write(header + body);\n}\n`;\n\nfunction startFakeServer(): ChildProcessWithoutNullStreams {\n\treturn spawn(process.execPath, [\"-e\", FAKE_SERVER], { stdio: [\"pipe\", \"pipe\", \"pipe\"] });\n}\n\nlet liveProcs: ChildProcessWithoutNullStreams[] = [];\n\nafterEach(() => {\n\tfor (const p of liveProcs) {\n\t\ttry {\n\t\t\tp.kill(\"SIGKILL\");\n\t\t} catch {\n\t\t\t/* noop */\n\t\t}\n\t}\n\tliveProcs = [];\n});\n\ndescribe(\"position conversion\", () => {\n\tit(\"converts UTF-16 offsets and positions\", () => {\n\t\t// Contains a supplementary-plane char (2 UTF-16 code units).\n\t\tconst text = \"ab😀cd\\nsecond line\";\n\t\tconst index = computeLineIndex(text);\n\t\t// 'c' sits after the surrogate pair; its UTF-16 character index is 4\n\t\t// (a,b,high,low → 0,1,2,3) and its string offset is also 4.\n\t\tconst pos = offsetToPosition(text, index, 4);\n\t\texpect(pos.line).toBe(0);\n\t\texpect(pos.character).toBe(4);\n\t\tconst back = positionToOffset(text, index, { line: 0, character: 4 });\n\t\texpect(back).toBe(4);\n\t});\n\n\tit(\"handles CRLF line endings\", () => {\n\t\tconst text = \"line1\\r\\nline2\\r\\nline3\";\n\t\tconst index = computeLineIndex(text);\n\t\tconst pos = offsetToPosition(text, index, text.indexOf(\"line2\"));\n\t\texpect(pos.line).toBe(1);\n\t});\n});\n\ndescribe(\"rename preview / apply text edits\", () => {\n\tit(\"applies edits later-to-earlier preserving offsets\", () => {\n\t\tconst content = \"aaa\\nbbb\\nccc\";\n\t\tconst edits = [\n\t\t\t{ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } }, newText: \"AAA\" },\n\t\t\t{ range: { start: { line: 1, character: 0 }, end: { line: 1, character: 3 } }, newText: \"BBB\" },\n\t\t];\n\t\tconst r = applyTextEdits(content, edits);\n\t\texpect(r.newContent).toBe(\"AAA\\nBBB\\nccc\");\n\t\texpect(r.conflicts).toEqual([]);\n\t});\n\n\tit(\"treats overlapping edits as conflicts\", () => {\n\t\tconst content = \"abcdef\";\n\t\tconst edits = [\n\t\t\t{ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } }, newText: \"X\" },\n\t\t\t{ range: { start: { line: 0, character: 2 }, end: { line: 0, character: 5 } }, newText: \"Y\" },\n\t\t];\n\t\tconst r = applyTextEdits(content, edits);\n\t\texpect(r.conflicts.length).toBeGreaterThanOrEqual(1);\n\t});\n\n\tit(\"preserves a BOM\", () => {\n\t\tconst content = \"\\ufeffhello\";\n\t\tconst edits = [{ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, newText: \"world\" }];\n\t\tconst r = applyTextEdits(content, edits);\n\t\texpect(r.newContent.charCodeAt(0)).toBe(0xfeff);\n\t\texpect(r.newContent).toBe(\"\\ufeffworld\");\n\t});\n\n\tit(\"rejects edits targeting external paths\", () => {\n\t\tconst edit = {\n\t\t\tchanges: {\n\t\t\t\t\"file:///etc/passwd\": [\n\t\t\t\t\t{ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, newText: \"x\" },\n\t\t\t\t],\n\t\t\t},\n\t\t};\n\t\tconst { conflicts } = extractEditsByPath(edit as never, \"/workspace\");\n\t\texpect(conflicts.length).toBeGreaterThan(0);\n\t});\n});\n\ndescribe(\"LSP discovery\", () => {\n\tit(\"detects language from extension\", () => {\n\t\texpect(detectLanguage(\"a.ts\").languageId).toBe(\"typescript\");\n\t\texpect(detectLanguage(\"a.py\").languageId).toBe(\"python\");\n\t\texpect(detectLanguage(\"a.weird\").languageId).toBeNull();\n\t});\n\n\tit(\"reports server not installed deterministically (no server on CI)\", async () => {\n\t\tconst res = await resolveServer(\"typescript\");\n\t\t// Either resolves on PATH or reports unavailable — must be deterministic\n\t\t// and typed, never throws.\n\t\texpect(res.languageId).toBe(\"typescript\");\n\t});\n});\n\ndescribe(\"LSP diagnostics\", () => {\n\tit(\"dedupes, caps and summarises diagnostics\", () => {\n\t\tconst rows = getDiagnosticRows(\n\t\t\t{\n\t\t\t\turi: \"file:///workspace/a.ts\",\n\t\t\t\tdiagnostics: [\n\t\t\t\t\t{\n\t\t\t\t\t\trange: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },\n\t\t\t\t\t\tseverity: LspDiagnosticSeverity.Error,\n\t\t\t\t\t\tmessage: \"e1\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\trange: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },\n\t\t\t\t\t\tseverity: LspDiagnosticSeverity.Error,\n\t\t\t\t\t\tmessage: \"e1\",\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\trange: { start: { line: 1, character: 0 }, end: { line: 1, character: 1 } },\n\t\t\t\t\t\tseverity: LspDiagnosticSeverity.Warning,\n\t\t\t\t\t\tmessage: \"w1\",\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t},\n\t\t\t\"/workspace\",\n\t\t\t\"s1\",\n\t\t\t10,\n\t\t);\n\t\texpect(rows.length).toBe(2);\n\t\texpect(rows[0].workspaceRelativePath).toBe(\"a.ts\");\n\t\tconst summary = summarizeDiagnostics(rows);\n\t\texpect(summary.errors).toBe(1);\n\t\texpect(summary.warnings).toBe(1);\n\t});\n\n\tit(\"diagnostics gate fails on new errors, allows baseline\", () => {\n\t\tconst base = [\n\t\t\t{\n\t\t\t\tworkspaceRelativePath: \"a.ts\",\n\t\t\t\trange: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },\n\t\t\t\tseverity: LspDiagnosticSeverity.Error,\n\t\t\t\tmessage: \"base\",\n\t\t\t},\n\t\t];\n\t\tconst after = [\n\t\t\t...base,\n\t\t\t{\n\t\t\t\tworkspaceRelativePath: \"a.ts\",\n\t\t\t\trange: { start: { line: 2, character: 0 }, end: { line: 2, character: 1 } },\n\t\t\t\tseverity: LspDiagnosticSeverity.Error,\n\t\t\t\tmessage: \"new\",\n\t\t\t},\n\t\t];\n\t\tconst result = evaluateDiagnosticGate(base, after, { allowExistingBaselineErrors: true });\n\t\texpect(result.passed).toBe(false);\n\t\texpect(result.comparison.errorsIntroduced).toBe(1);\n\t});\n});\n\ndescribe(\"LSP lifecycle over fake server\", () => {\n\tit(\"initializes, serves definition, and shuts down cleanly (no leak)\", async () => {\n\t\tconst child = startFakeServer();\n\t\tliveProcs.push(child);\n\t\tconst rpc = new JsonRpcClient({ child });\n\t\tconst client = new LspClient({ rpc, rootUri: toFileUri(\"/workspace\"), workspaceRoot: \"/workspace\" });\n\t\tconst caps = await client.initialize();\n\t\texpect(caps.definitionProvider).toBe(true);\n\t\tconst locations = await client.definition({ uri: toFileUri(\"/workspace/a.ts\"), line: 1, character: 2 });\n\t\texpect(locations.length).toBe(1);\n\t\texpect(locations[0].range.start.line).toBe(1);\n\t\tawait client.shutdown();\n\t\tclient.dispose();\n\t\t// afterAll kills child (afterEach)\n\t});\n\n\tit(\"gathers published diagnostics after didOpen\", async () => {\n\t\tconst child = startFakeServer();\n\t\tliveProcs.push(child);\n\t\tconst received: unknown[] = [];\n\t\tconst rpc = new JsonRpcClient({ child });\n\t\tconst client = new LspClient({\n\t\t\trpc,\n\t\t\trootUri: toFileUri(\"/workspace\"),\n\t\t\tworkspaceRoot: \"/workspace\",\n\t\t\tonDiagnostics: (params) => received.push(params),\n\t\t});\n\t\tawait client.initialize();\n\t\tawait client.openDocument(toFileUri(\"/workspace/a.ts\"), \"const x = 1;\\n\");\n\t\t// Give the fake server a tick to flush the publishDiagnostics.\n\t\tawait new Promise((r) => setTimeout(r, 150));\n\t\texpect(received.length).toBeGreaterThan(0);\n\t\tawait client.shutdown();\n\t\tclient.dispose();\n\t});\n});\n"]}