pi config update

This commit is contained in:
Jonas H
2026-03-19 07:58:49 +01:00
parent a3c9183485
commit 871caa5adc
24 changed files with 6198 additions and 555 deletions

View File

@@ -0,0 +1,178 @@
# LSP Extension
Language Server Protocol integration for pi-coding-agent.
## Highlights
- **Hook** (`lsp.ts`): Auto-diagnostics (default at agent end; optional per `write`/`edit`)
- **Tool** (`lsp-tool.ts`): On-demand LSP queries (definitions, references, hover, symbols, diagnostics, signatures)
- Manages one LSP server per project root and reuses them across turns
- **Efficient**: Bounded memory usage via LRU cache and idle file cleanup
- Supports TypeScript/JavaScript, Vue, Svelte, Dart/Flutter, Python, Go, Kotlin, Swift, and Rust
## Supported Languages
| Language | Server | Detection |
|----------|--------|-----------|
| TypeScript/JavaScript | `typescript-language-server` | `package.json`, `tsconfig.json` |
| Vue | `vue-language-server` | `package.json`, `vite.config.ts` |
| Svelte | `svelteserver` | `svelte.config.js` |
| Dart/Flutter | `dart language-server` | `pubspec.yaml` |
| Python | `pyright-langserver` | `pyproject.toml`, `requirements.txt` |
| Go | `gopls` | `go.mod` |
| Kotlin | `kotlin-ls` | `settings.gradle(.kts)`, `build.gradle(.kts)`, `pom.xml` |
| Swift | `sourcekit-lsp` | `Package.swift`, Xcode (`*.xcodeproj` / `*.xcworkspace`) |
| Rust | `rust-analyzer` | `Cargo.toml` |
### Known Limitations
**rust-analyzer**: Very slow to initialize (30-60+ seconds) because it compiles the entire Rust project before returning diagnostics. This is a known rust-analyzer behavior, not a bug in this extension. For quick feedback, consider using `cargo check` directly.
## Usage
### Installation
Install the package and enable extensions:
```bash
pi install npm:lsp-pi
pi config
```
Dependencies are installed automatically during `pi install`.
### Prerequisites
Install the language servers you need:
```bash
# TypeScript/JavaScript
npm i -g typescript-language-server typescript
# Vue
npm i -g @vue/language-server
# Svelte
npm i -g svelte-language-server
# Python
npm i -g pyright
# Go (install gopls via go install)
go install golang.org/x/tools/gopls@latest
# Kotlin (kotlin-ls)
brew install JetBrains/utils/kotlin-lsp
# Swift (sourcekit-lsp; macOS)
# Usually available via Xcode / Command Line Tools
xcrun sourcekit-lsp --help
# Rust (install via rustup)
rustup component add rust-analyzer
```
The extension spawns binaries from your PATH.
## How It Works
### Hook (auto-diagnostics)
1. On `session_start`, warms up LSP for detected project type
2. Tracks files touched by `write`/`edit`
3. Default (`agent_end`): at agent end, sends touched files to LSP and posts a diagnostics message
4. Optional (`edit_write`): per `write`/`edit`, appends diagnostics to the tool result
5. Shows notification with diagnostic summary
6. **Memory Management**: Keeps up to 30 files open per LSP server (LRU eviction), automatically closes idle files (> 60s), and shuts down all LSP servers after 2 minutes of post-agent inactivity (servers restart lazily when files are read again).
7. **Robustness**: Reuses cached diagnostics if a server doesn't re-publish them for unchanged files, avoiding false timeouts on re-analysis.
### Tool (on-demand queries)
The `lsp` tool provides these actions:
| Action | Description | Requires |
|--------|-------------|----------|
| `definition` | Jump to definition | `file` + (`line`/`column` or `query`) |
| `references` | Find all references | `file` + (`line`/`column` or `query`) |
| `hover` | Get type/docs info | `file` + (`line`/`column` or `query`) |
| `symbols` | List symbols in file | `file`, optional `query` filter |
| `diagnostics` | Get single file diagnostics | `file`, optional `severity` filter |
| `workspace-diagnostics` | Get diagnostics for multiple files | `files` array, optional `severity` filter |
| `signature` | Get function signature | `file` + (`line`/`column` or `query`) |
| `rename` | Rename symbol across files | `file` + (`line`/`column` or `query`) + `newName` |
| `codeAction` | Get available quick fixes/refactors | `file` + `line`/`column`, optional `endLine`/`endColumn` |
**Query resolution**: For position-based actions, you can provide a `query` (symbol name) instead of `line`/`column`. The tool will find the symbol in the file and use its position.
**Severity filtering**: For `diagnostics` and `workspace-diagnostics` actions, use the `severity` parameter to filter results:
- `all` (default): Show all diagnostics
- `error`: Only errors
- `warning`: Errors and warnings
- `info`: Errors, warnings, and info
- `hint`: All including hints
**Workspace diagnostics**: The `workspace-diagnostics` action analyzes multiple files at once. Pass an array of file paths in the `files` parameter. Each file will be opened, analyzed by the appropriate LSP server, and diagnostics returned. Files are cleaned up after analysis to prevent memory bloat.
```bash
# Find all TypeScript files and check for errors
find src -name "*.ts" -type f | xargs ...
# Example tool call
lsp action=workspace-diagnostics files=["src/index.ts", "src/utils.ts"] severity=error
```
Example questions the LLM can answer using this tool:
- "Where is `handleSessionStart` defined in `lsp-hook.ts`?"
- "Find all references to `getManager`"
- "What type does `getDefinition` return?"
- "List symbols in `lsp-core.ts`"
- "Check all TypeScript files in src/ for errors"
- "Get only errors from `index.ts`"
- "Rename `oldFunction` to `newFunction`"
- "What quick fixes are available at line 10?"
## Settings
Use `/lsp` to configure the auto diagnostics hook:
- Mode: default at agent end; can run after each edit/write or be disabled
- Scope: session-only or global (`~/.pi/agent/settings.json`)
To disable auto diagnostics, choose "Disabled" in `/lsp` or set in `~/.pi/agent/settings.json`:
```json
{
"lsp": {
"hookMode": "disabled"
}
}
```
Other values: `"agent_end"` (default) and `"edit_write"`.
Agent-end mode analyzes files touched during the full agent response (after all tool calls complete) and posts a diagnostics message only once. Disabling the hook does not disable the `/lsp` tool.
## File Structure
| File | Purpose |
|------|---------|
| `lsp.ts` | Hook extension (auto-diagnostics; default at agent end) |
| `lsp-tool.ts` | Tool extension (on-demand LSP queries) |
| `lsp-core.ts` | LSPManager class, server configs, singleton manager |
| `package.json` | Declares both extensions via "pi" field |
## Testing
```bash
# Unit tests (root detection, configuration)
npm test
# Tool tests
npm run test:tool
# Integration tests (spawns real language servers)
npm run test:integration
# Run rust-analyzer tests (slow, disabled by default)
RUST_LSP_TEST=1 npm run test:integration
```
## License
MIT

View File

@@ -0,0 +1,12 @@
/**
* Combined lsp-pi extension entry point.
* Loads both the hook extension (lsp.ts) and the tool extension (lsp-tool.ts).
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import lspHook from "./lsp.js";
import lspTool from "./lsp-tool.js";
export default function (pi: ExtensionAPI) {
lspHook(pi);
lspTool(pi);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,382 @@
/**
* LSP Tool Extension for pi-coding-agent
*
* Provides Language Server Protocol tool for:
* - definitions, references, hover, signature help
* - document symbols, diagnostics, workspace diagnostics
* - rename, code actions
*
* Supported languages:
* - Dart/Flutter (dart language-server)
* - TypeScript/JavaScript (typescript-language-server)
* - Vue (vue-language-server)
* - Svelte (svelteserver)
* - Python (pyright-langserver)
* - Go (gopls)
* - Kotlin (kotlin-ls)
* - Swift (sourcekit-lsp)
* - Rust (rust-analyzer)
*
* Usage:
* pi --extension ./lsp-tool.ts
*
* Or use the combined lsp.ts extension for both hook and tool functionality.
*/
import * as path from "node:path";
import { Type, type Static } from "@sinclair/typebox";
import { StringEnum } from "@mariozechner/pi-ai";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Text } from "@mariozechner/pi-tui";
import { getOrCreateManager, formatDiagnostic, filterDiagnosticsBySeverity, uriToPath, resolvePosition, type SeverityFilter } from "./lsp-core.js";
const PREVIEW_LINES = 10;
const DIAGNOSTICS_WAIT_MS_DEFAULT = 3000;
function diagnosticsWaitMsForFile(filePath: string): number {
const ext = path.extname(filePath).toLowerCase();
if (ext === ".kt" || ext === ".kts") return 30000;
if (ext === ".swift") return 20000;
if (ext === ".rs") return 20000;
return DIAGNOSTICS_WAIT_MS_DEFAULT;
}
const ACTIONS = ["definition", "references", "hover", "symbols", "diagnostics", "workspace-diagnostics", "signature", "rename", "codeAction"] as const;
const SEVERITY_FILTERS = ["all", "error", "warning", "info", "hint"] as const;
const LspParams = Type.Object({
action: StringEnum(ACTIONS),
file: Type.Optional(Type.String({ description: "File path (required for most actions)" })),
files: Type.Optional(Type.Array(Type.String(), { description: "File paths for workspace-diagnostics" })),
line: Type.Optional(Type.Number({ description: "Line (1-indexed). Required for position-based actions unless query provided." })),
column: Type.Optional(Type.Number({ description: "Column (1-indexed). Required for position-based actions unless query provided." })),
endLine: Type.Optional(Type.Number({ description: "End line for range-based actions (codeAction)" })),
endColumn: Type.Optional(Type.Number({ description: "End column for range-based actions (codeAction)" })),
query: Type.Optional(Type.String({ description: "Symbol name filter (for symbols) or to resolve position (for definition/references/hover/signature)" })),
newName: Type.Optional(Type.String({ description: "New name for rename action" })),
severity: Type.Optional(StringEnum(SEVERITY_FILTERS, { description: 'Filter diagnostics: "all"|"error"|"warning"|"info"|"hint"' })),
});
type LspParamsType = Static<typeof LspParams>;
function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) return promise;
if (signal.aborted) return Promise.reject(new Error("aborted"));
return new Promise<T>((resolve, reject) => {
const onAbort = () => {
cleanup();
reject(new Error("aborted"));
};
const cleanup = () => {
signal.removeEventListener("abort", onAbort);
};
signal.addEventListener("abort", onAbort, { once: true });
promise.then(
(value) => {
cleanup();
resolve(value);
},
(err) => {
cleanup();
reject(err);
},
);
});
}
function isAbortedError(e: unknown): boolean {
return e instanceof Error && e.message === "aborted";
}
function cancelledToolResult() {
return {
content: [{ type: "text" as const, text: "Cancelled" }],
details: { cancelled: true },
};
}
type ExecuteArgs = {
signal: AbortSignal | undefined;
onUpdate: ((update: { content: Array<{ type: "text"; text: string }>; details?: Record<string, unknown> }) => void) | undefined;
ctx: { cwd: string };
};
function isAbortSignalLike(value: unknown): value is AbortSignal {
return !!value
&& typeof value === "object"
&& "aborted" in value
&& typeof (value as any).aborted === "boolean"
&& typeof (value as any).addEventListener === "function";
}
function isContextLike(value: unknown): value is { cwd: string } {
return !!value && typeof value === "object" && typeof (value as any).cwd === "string";
}
function normalizeExecuteArgs(onUpdateArg: unknown, ctxArg: unknown, signalArg: unknown): ExecuteArgs {
// Runtime >= 0.51: (signal, onUpdate, ctx)
if (isContextLike(signalArg)) {
return {
signal: isAbortSignalLike(onUpdateArg) ? onUpdateArg : undefined,
onUpdate: typeof ctxArg === "function" ? ctxArg as ExecuteArgs["onUpdate"] : undefined,
ctx: signalArg,
};
}
// Runtime <= 0.50: (onUpdate, ctx, signal)
if (isContextLike(ctxArg)) {
return {
signal: isAbortSignalLike(signalArg) ? signalArg : undefined,
onUpdate: typeof onUpdateArg === "function" ? onUpdateArg as ExecuteArgs["onUpdate"] : undefined,
ctx: ctxArg,
};
}
throw new Error("Invalid tool execution context");
}
function formatLocation(loc: { uri: string; range?: { start?: { line: number; character: number } } }, cwd?: string): string {
const abs = uriToPath(loc.uri);
const display = cwd && path.isAbsolute(abs) ? path.relative(cwd, abs) : abs;
const { line, character: col } = loc.range?.start ?? {};
return typeof line === "number" && typeof col === "number" ? `${display}:${line + 1}:${col + 1}` : display;
}
function formatHover(contents: unknown): string {
if (typeof contents === "string") return contents;
if (Array.isArray(contents)) return contents.map(c => typeof c === "string" ? c : (c as any)?.value ?? "").filter(Boolean).join("\n\n");
if (contents && typeof contents === "object" && "value" in contents) return String((contents as any).value);
return "";
}
function formatSignature(help: any): string {
if (!help?.signatures?.length) return "No signature help available.";
const sig = help.signatures[help.activeSignature ?? 0] ?? help.signatures[0];
let text = sig.label ?? "Signature";
if (sig.documentation) text += `\n${typeof sig.documentation === "string" ? sig.documentation : sig.documentation?.value ?? ""}`;
if (sig.parameters?.length) {
const params = sig.parameters.map((p: any) => typeof p.label === "string" ? p.label : Array.isArray(p.label) ? p.label.join("-") : "").filter(Boolean);
if (params.length) text += `\nParameters: ${params.join(", ")}`;
}
return text;
}
function collectSymbols(symbols: any[], depth = 0, lines: string[] = [], query?: string): string[] {
for (const sym of symbols) {
const name = sym?.name ?? "<unknown>";
if (query && !name.toLowerCase().includes(query.toLowerCase())) {
if (sym.children?.length) collectSymbols(sym.children, depth + 1, lines, query);
continue;
}
const loc = sym?.range?.start ? `${sym.range.start.line + 1}:${sym.range.start.character + 1}` : "";
lines.push(`${" ".repeat(depth)}${name}${loc ? ` (${loc})` : ""}`);
if (sym.children?.length) collectSymbols(sym.children, depth + 1, lines, query);
}
return lines;
}
function formatWorkspaceEdit(edit: any, cwd?: string): string {
const lines: string[] = [];
if (edit.documentChanges?.length) {
for (const change of edit.documentChanges) {
if (change.textDocument?.uri) {
const fp = uriToPath(change.textDocument.uri);
const display = cwd && path.isAbsolute(fp) ? path.relative(cwd, fp) : fp;
lines.push(`${display}:`);
for (const e of change.edits || []) {
const loc = `${e.range.start.line + 1}:${e.range.start.character + 1}`;
lines.push(` [${loc}] → "${e.newText}"`);
}
}
}
}
if (edit.changes) {
for (const [uri, edits] of Object.entries(edit.changes)) {
const fp = uriToPath(uri);
const display = cwd && path.isAbsolute(fp) ? path.relative(cwd, fp) : fp;
lines.push(`${display}:`);
for (const e of edits as any[]) {
const loc = `${e.range.start.line + 1}:${e.range.start.character + 1}`;
lines.push(` [${loc}] → "${e.newText}"`);
}
}
}
return lines.length ? lines.join("\n") : "No edits.";
}
function formatCodeActions(actions: any[]): string[] {
return actions.map((a, i) => {
const title = a.title || a.command?.title || "Untitled action";
const kind = a.kind ? ` (${a.kind})` : "";
const isPreferred = a.isPreferred ? " ★" : "";
return `${i + 1}. ${title}${kind}${isPreferred}`;
});
}
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "lsp",
label: "LSP",
description: `Query language server for definitions, references, types, symbols, diagnostics, rename, and code actions.
Actions: definition, references, hover, signature, rename (require file + line/column or query), symbols (file, optional query), diagnostics (file), workspace-diagnostics (files array), codeAction (file + position).
Use bash to find files: find src -name "*.ts" -type f`,
parameters: LspParams,
async execute(_toolCallId, params, onUpdateArg, ctxArg, signalArg) {
const { signal, onUpdate, ctx } = normalizeExecuteArgs(onUpdateArg, ctxArg, signalArg);
if (signal?.aborted) return cancelledToolResult();
if (onUpdate) {
onUpdate({ content: [{ type: "text", text: "Working..." }], details: { status: "working" } });
}
const manager = getOrCreateManager(ctx.cwd);
const { action, file, files, line, column, endLine, endColumn, query, newName, severity } = params as LspParamsType;
const sevFilter: SeverityFilter = severity || "all";
const needsFile = action !== "workspace-diagnostics";
const needsPos = ["definition", "references", "hover", "signature", "rename", "codeAction"].includes(action);
try {
if (needsFile && !file) throw new Error(`Action "${action}" requires a file path.`);
let rLine = line, rCol = column, fromQuery = false;
if (needsPos && (rLine === undefined || rCol === undefined) && query && file) {
const resolved = await abortable(resolvePosition(manager, file, query), signal);
if (resolved) { rLine = resolved.line; rCol = resolved.column; fromQuery = true; }
}
if (needsPos && (rLine === undefined || rCol === undefined)) {
throw new Error(`Action "${action}" requires line/column or a query matching a symbol.`);
}
const qLine = query ? `query: ${query}\n` : "";
const sevLine = sevFilter !== "all" ? `severity: ${sevFilter}\n` : "";
const posLine = fromQuery && rLine && rCol ? `resolvedPosition: ${rLine}:${rCol}\n` : "";
switch (action) {
case "definition": {
const results = await abortable(manager.getDefinition(file!, rLine!, rCol!), signal);
const locs = results.map(l => formatLocation(l, ctx?.cwd));
const payload = locs.length ? locs.join("\n") : fromQuery ? `${file}:${rLine}:${rCol}` : "No definitions found.";
return { content: [{ type: "text", text: `action: definition\n${qLine}${posLine}${payload}` }], details: results };
}
case "references": {
const results = await abortable(manager.getReferences(file!, rLine!, rCol!), signal);
const locs = results.map(l => formatLocation(l, ctx?.cwd));
return { content: [{ type: "text", text: `action: references\n${qLine}${posLine}${locs.length ? locs.join("\n") : "No references found."}` }], details: results };
}
case "hover": {
const result = await abortable(manager.getHover(file!, rLine!, rCol!), signal);
const payload = result ? formatHover(result.contents) || "No hover information." : "No hover information.";
return { content: [{ type: "text", text: `action: hover\n${qLine}${posLine}${payload}` }], details: result ?? null };
}
case "symbols": {
const symbols = await abortable(manager.getDocumentSymbols(file!), signal);
const lines = collectSymbols(symbols, 0, [], query);
const payload = lines.length ? lines.join("\n") : query ? `No symbols matching "${query}".` : "No symbols found.";
return { content: [{ type: "text", text: `action: symbols\n${qLine}${payload}` }], details: symbols };
}
case "diagnostics": {
const result = await abortable(manager.touchFileAndWait(file!, diagnosticsWaitMsForFile(file!)), signal);
const filtered = filterDiagnosticsBySeverity(result.diagnostics, sevFilter);
const payload = (result as any).unsupported
? `Unsupported: ${(result as any).error || "No LSP for this file."}`
: !result.receivedResponse
? "Timeout: LSP server did not respond. Try again."
: filtered.length ? filtered.map(formatDiagnostic).join("\n") : "No diagnostics.";
return { content: [{ type: "text", text: `action: diagnostics\n${sevLine}${payload}` }], details: { ...result, diagnostics: filtered } };
}
case "workspace-diagnostics": {
if (!files?.length) throw new Error('Action "workspace-diagnostics" requires a "files" array.');
const waitMs = Math.max(...files.map(diagnosticsWaitMsForFile));
const result = await abortable(manager.getDiagnosticsForFiles(files, waitMs), signal);
const out: string[] = [];
let errors = 0, warnings = 0, filesWithIssues = 0;
for (const item of result.items) {
const display = ctx?.cwd && path.isAbsolute(item.file) ? path.relative(ctx.cwd, item.file) : item.file;
if (item.status !== 'ok') { out.push(`${display}: ${item.error || item.status}`); continue; }
const filtered = filterDiagnosticsBySeverity(item.diagnostics, sevFilter);
if (filtered.length) {
filesWithIssues++;
out.push(`${display}:`);
for (const d of filtered) {
if (d.severity === 1) errors++; else if (d.severity === 2) warnings++;
out.push(` ${formatDiagnostic(d)}`);
}
}
}
const summary = `Analyzed ${result.items.length} file(s): ${errors} error(s), ${warnings} warning(s) in ${filesWithIssues} file(s)`;
return { content: [{ type: "text", text: `action: workspace-diagnostics\n${sevLine}${summary}\n\n${out.length ? out.join("\n") : "No diagnostics."}` }], details: result };
}
case "signature": {
const result = await abortable(manager.getSignatureHelp(file!, rLine!, rCol!), signal);
return { content: [{ type: "text", text: `action: signature\n${qLine}${posLine}${formatSignature(result)}` }], details: result ?? null };
}
case "rename": {
if (!newName) throw new Error('Action "rename" requires a "newName" parameter.');
const result = await abortable(manager.rename(file!, rLine!, rCol!, newName), signal);
if (!result) return { content: [{ type: "text", text: `action: rename\n${qLine}${posLine}No rename available at this position.` }], details: null };
const edits = formatWorkspaceEdit(result, ctx?.cwd);
return { content: [{ type: "text", text: `action: rename\n${qLine}${posLine}newName: ${newName}\n\n${edits}` }], details: result };
}
case "codeAction": {
const result = await abortable(manager.getCodeActions(file!, rLine!, rCol!, endLine, endColumn), signal);
const actions = formatCodeActions(result);
return { content: [{ type: "text", text: `action: codeAction\n${qLine}${posLine}${actions.length ? actions.join("\n") : "No code actions available."}` }], details: result };
}
}
} catch (e) {
if (signal?.aborted || isAbortedError(e)) return cancelledToolResult();
throw e;
}
},
renderCall(args, theme) {
const params = args as LspParamsType;
let text = theme.fg("toolTitle", theme.bold("lsp ")) + theme.fg("accent", params.action || "...");
if (params.file) text += " " + theme.fg("muted", params.file);
else if (params.files?.length) text += " " + theme.fg("muted", `${params.files.length} file(s)`);
if (params.query) text += " " + theme.fg("dim", `query="${params.query}"`);
else if (params.line !== undefined && params.column !== undefined) text += theme.fg("warning", `:${params.line}:${params.column}`);
if (params.severity && params.severity !== "all") text += " " + theme.fg("dim", `[${params.severity}]`);
return new Text(text, 0, 0);
},
renderResult(result, options, theme) {
if (options.isPartial) return new Text(theme.fg("warning", "Working..."), 0, 0);
const textContent = (result.content?.find((c: any) => c.type === "text") as any)?.text || "";
const lines = textContent.split("\n");
let headerEnd = 0;
for (let i = 0; i < lines.length; i++) {
if (/^(action|query|severity|resolvedPosition):/.test(lines[i])) headerEnd = i + 1;
else break;
}
const header = lines.slice(0, headerEnd);
const content = lines.slice(headerEnd);
const maxLines = options.expanded ? content.length : PREVIEW_LINES;
const display = content.slice(0, maxLines);
const remaining = content.length - maxLines;
let out = header.map((l: string) => theme.fg("muted", l)).join("\n");
if (display.length) {
if (out) out += "\n";
out += display.map((l: string) => theme.fg("toolOutput", l)).join("\n");
}
if (remaining > 0) out += theme.fg("dim", `\n... (${remaining} more lines)`);
return new Text(out, 0, 0);
},
});
}

View File

@@ -0,0 +1,604 @@
/**
* LSP Hook Extension for pi-coding-agent
*
* Provides automatic diagnostics feedback (default: agent end).
* Can run after each write/edit or once per agent response.
*
* Usage:
* pi --extension ./lsp.ts
*
* Or load the directory to get both hook and tool:
* pi --extension ./lsp/
*/
import * as path from "node:path";
import * as fs from "node:fs";
import * as os from "node:os";
import { type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Text } from "@mariozechner/pi-tui";
import { type Diagnostic } from "vscode-languageserver-protocol";
import { LSP_SERVERS, formatDiagnostic, getOrCreateManager, shutdownManager } from "./lsp-core.js";
type HookScope = "session" | "global";
type HookMode = "edit_write" | "agent_end" | "disabled";
const DIAGNOSTICS_WAIT_MS_DEFAULT = 3000;
function diagnosticsWaitMsForFile(filePath: string): number {
const ext = path.extname(filePath).toLowerCase();
if (ext === ".kt" || ext === ".kts") return 30000;
if (ext === ".swift") return 20000;
if (ext === ".rs") return 20000;
return DIAGNOSTICS_WAIT_MS_DEFAULT;
}
const DIAGNOSTICS_PREVIEW_LINES = 10;
const LSP_IDLE_SHUTDOWN_MS = 2 * 60 * 1000;
const DIM = "\x1b[2m", GREEN = "\x1b[32m", YELLOW = "\x1b[33m", RESET = "\x1b[0m";
const DEFAULT_HOOK_MODE: HookMode = "agent_end";
const SETTINGS_NAMESPACE = "lsp";
const LSP_CONFIG_ENTRY = "lsp-hook-config";
const WARMUP_MAP: Record<string, string> = {
"pubspec.yaml": ".dart",
"package.json": ".ts",
"pyproject.toml": ".py",
"go.mod": ".go",
"Cargo.toml": ".rs",
"settings.gradle": ".kt",
"settings.gradle.kts": ".kt",
"build.gradle": ".kt",
"build.gradle.kts": ".kt",
"pom.xml": ".kt",
"gradlew": ".kt",
"gradle.properties": ".kt",
"Package.swift": ".swift",
};
const MODE_LABELS: Record<HookMode, string> = {
edit_write: "After each edit/write",
agent_end: "At agent end",
disabled: "Disabled",
};
function normalizeHookMode(value: unknown): HookMode | undefined {
if (value === "edit_write" || value === "agent_end" || value === "disabled") return value;
if (value === "turn_end") return "agent_end";
return undefined;
}
interface HookConfigEntry {
scope: HookScope;
hookMode?: HookMode;
}
export default function (pi: ExtensionAPI) {
type LspActivity = "idle" | "loading" | "working";
let activeClients: Set<string> = new Set();
let statusUpdateFn: ((key: string, text: string | undefined) => void) | null = null;
let hookMode: HookMode = DEFAULT_HOOK_MODE;
let hookScope: HookScope = "global";
let activity: LspActivity = "idle";
let diagnosticsAbort: AbortController | null = null;
let shuttingDown = false;
let idleShutdownTimer: NodeJS.Timeout | null = null;
const touchedFiles: Map<string, boolean> = new Map();
const globalSettingsPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
function readSettingsFile(filePath: string): Record<string, unknown> {
try {
if (!fs.existsSync(filePath)) return {};
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : {};
} catch {
return {};
}
}
function getGlobalHookMode(): HookMode | undefined {
const settings = readSettingsFile(globalSettingsPath);
const lspSettings = settings[SETTINGS_NAMESPACE];
const hookValue = (lspSettings as { hookMode?: unknown; hookEnabled?: unknown } | undefined)?.hookMode;
const normalized = normalizeHookMode(hookValue);
if (normalized) return normalized;
const legacyEnabled = (lspSettings as { hookEnabled?: unknown } | undefined)?.hookEnabled;
if (typeof legacyEnabled === "boolean") return legacyEnabled ? "edit_write" : "disabled";
return undefined;
}
function setGlobalHookMode(mode: HookMode): boolean {
try {
const settings = readSettingsFile(globalSettingsPath);
const existing = settings[SETTINGS_NAMESPACE];
const nextNamespace = (existing && typeof existing === "object")
? { ...(existing as Record<string, unknown>), hookMode: mode }
: { hookMode: mode };
settings[SETTINGS_NAMESPACE] = nextNamespace;
fs.mkdirSync(path.dirname(globalSettingsPath), { recursive: true });
fs.writeFileSync(globalSettingsPath, JSON.stringify(settings, null, 2), "utf-8");
return true;
} catch {
return false;
}
}
function getLastHookEntry(ctx: ExtensionContext): HookConfigEntry | undefined {
const branchEntries = ctx.sessionManager.getBranch();
let latest: HookConfigEntry | undefined;
for (const entry of branchEntries) {
if (entry.type === "custom" && entry.customType === LSP_CONFIG_ENTRY) {
latest = entry.data as HookConfigEntry | undefined;
}
}
return latest;
}
function restoreHookState(ctx: ExtensionContext): void {
const entry = getLastHookEntry(ctx);
if (entry?.scope === "session") {
const normalized = normalizeHookMode(entry.hookMode);
if (normalized) {
hookMode = normalized;
hookScope = "session";
return;
}
const legacyEnabled = (entry as { hookEnabled?: unknown }).hookEnabled;
if (typeof legacyEnabled === "boolean") {
hookMode = legacyEnabled ? "edit_write" : "disabled";
hookScope = "session";
return;
}
}
const globalSetting = getGlobalHookMode();
hookMode = globalSetting ?? DEFAULT_HOOK_MODE;
hookScope = "global";
}
function persistHookEntry(entry: HookConfigEntry): void {
pi.appendEntry<HookConfigEntry>(LSP_CONFIG_ENTRY, entry);
}
function labelForMode(mode: HookMode): string {
return MODE_LABELS[mode];
}
function messageContentToText(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((item) => (item && typeof item === "object" && "type" in item && (item as any).type === "text")
? String((item as any).text ?? "")
: "")
.filter(Boolean)
.join("\n");
}
return "";
}
function formatDiagnosticsForDisplay(text: string): string {
return text
.replace(/\n?This file has errors, please fix\n/gi, "\n")
.replace(/<\/?file_diagnostics>\n?/gi, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function setActivity(next: LspActivity): void {
activity = next;
updateLspStatus();
}
function clearIdleShutdownTimer(): void {
if (!idleShutdownTimer) return;
clearTimeout(idleShutdownTimer);
idleShutdownTimer = null;
}
async function shutdownLspServersForIdle(): Promise<void> {
diagnosticsAbort?.abort();
diagnosticsAbort = null;
setActivity("idle");
await shutdownManager();
activeClients.clear();
updateLspStatus();
}
function scheduleIdleShutdown(): void {
clearIdleShutdownTimer();
idleShutdownTimer = setTimeout(() => {
idleShutdownTimer = null;
if (shuttingDown) return;
void shutdownLspServersForIdle();
}, LSP_IDLE_SHUTDOWN_MS);
(idleShutdownTimer as any).unref?.();
}
function updateLspStatus(): void {
if (!statusUpdateFn) return;
const clients = activeClients.size > 0 ? [...activeClients].join(", ") : "";
const clientsText = clients ? `${DIM}${clients}${RESET}` : "";
const activityHint = activity === "idle" ? "" : `${DIM}${RESET}`;
if (hookMode === "disabled") {
const text = clientsText
? `${YELLOW}LSP${RESET} ${DIM}(tool)${RESET}: ${clientsText}`
: `${YELLOW}LSP${RESET} ${DIM}(tool)${RESET}`;
statusUpdateFn("lsp", text);
return;
}
let text = `${GREEN}LSP${RESET}`;
if (activityHint) text += ` ${activityHint}`;
if (clientsText) text += ` ${clientsText}`;
statusUpdateFn("lsp", text);
}
function normalizeFilePath(filePath: string, cwd: string): string {
return path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
}
pi.registerMessageRenderer("lsp-diagnostics", (message, options, theme) => {
const content = formatDiagnosticsForDisplay(messageContentToText(message.content));
if (!content) return new Text("", 0, 0);
const expanded = options.expanded === true;
const lines = content.split("\n");
const maxLines = expanded ? lines.length : DIAGNOSTICS_PREVIEW_LINES;
const display = lines.slice(0, maxLines);
const remaining = lines.length - display.length;
const styledLines = display.map((line) => {
if (line.startsWith("File: ")) return theme.fg("muted", line);
return theme.fg("toolOutput", line);
});
if (!expanded && remaining > 0) {
styledLines.push(theme.fg("dim", `... (${remaining} more lines)`));
}
return new Text(styledLines.join("\n"), 0, 0);
});
function getServerConfig(filePath: string) {
const ext = path.extname(filePath);
return LSP_SERVERS.find((s) => s.extensions.includes(ext));
}
function ensureActiveClientForFile(filePath: string, cwd: string): string | undefined {
const absPath = normalizeFilePath(filePath, cwd);
const cfg = getServerConfig(absPath);
if (!cfg) return undefined;
if (!activeClients.has(cfg.id)) {
activeClients.add(cfg.id);
updateLspStatus();
}
return absPath;
}
function extractLspFiles(input: Record<string, unknown>): string[] {
const files: string[] = [];
if (typeof input.file === "string") files.push(input.file);
if (Array.isArray(input.files)) {
for (const item of input.files) {
if (typeof item === "string") files.push(item);
}
}
return files;
}
function buildDiagnosticsOutput(
filePath: string,
diagnostics: Diagnostic[],
cwd: string,
includeFileHeader: boolean,
): { notification: string; errorCount: number; output: string } {
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
const relativePath = path.relative(cwd, absPath);
const errorCount = diagnostics.filter((e) => e.severity === 1).length;
const MAX = 5;
const lines = diagnostics.slice(0, MAX).map((e) => {
const sev = e.severity === 1 ? "ERROR" : "WARN";
return `${sev}[${e.range.start.line + 1}] ${e.message.split("\n")[0]}`;
});
let notification = `📋 ${relativePath}\n${lines.join("\n")}`;
if (diagnostics.length > MAX) notification += `\n... +${diagnostics.length - MAX} more`;
const header = includeFileHeader ? `File: ${relativePath}\n` : "";
const output = `\n${header}This file has errors, please fix\n<file_diagnostics>\n${diagnostics.map(formatDiagnostic).join("\n")}\n</file_diagnostics>\n`;
return { notification, errorCount, output };
}
async function collectDiagnostics(
filePath: string,
ctx: ExtensionContext,
includeWarnings: boolean,
includeFileHeader: boolean,
notify = true,
): Promise<string | undefined> {
const manager = getOrCreateManager(ctx.cwd);
const absPath = ensureActiveClientForFile(filePath, ctx.cwd);
if (!absPath) return undefined;
try {
const result = await manager.touchFileAndWait(absPath, diagnosticsWaitMsForFile(absPath));
if (!result.receivedResponse) return undefined;
const diagnostics = includeWarnings
? result.diagnostics
: result.diagnostics.filter((d) => d.severity === 1);
if (!diagnostics.length) return undefined;
const report = buildDiagnosticsOutput(filePath, diagnostics, ctx.cwd, includeFileHeader);
if (notify) {
if (ctx.hasUI) ctx.ui.notify(report.notification, report.errorCount > 0 ? "error" : "warning");
else console.error(report.notification);
}
return report.output;
} catch {
return undefined;
}
}
pi.registerCommand("lsp", {
description: "LSP settings (auto diagnostics hook)",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("LSP settings require UI", "warning");
return;
}
const currentMark = " ✓";
const modeOptions = ([
"edit_write",
"agent_end",
"disabled",
] as HookMode[]).map((mode) => ({
mode,
label: mode === hookMode ? `${labelForMode(mode)}${currentMark}` : labelForMode(mode),
}));
const modeChoice = await ctx.ui.select(
"LSP auto diagnostics hook mode:",
modeOptions.map((option) => option.label),
);
if (!modeChoice) return;
const nextMode = modeOptions.find((option) => option.label === modeChoice)?.mode;
if (!nextMode) return;
const scopeOptions = [
{
scope: "session" as HookScope,
label: "Session only",
},
{
scope: "global" as HookScope,
label: "Global (all sessions)",
},
];
const scopeChoice = await ctx.ui.select(
"Apply LSP auto diagnostics hook setting to:",
scopeOptions.map((option) => option.label),
);
if (!scopeChoice) return;
const scope = scopeOptions.find((option) => option.label === scopeChoice)?.scope;
if (!scope) return;
if (scope === "global") {
const ok = setGlobalHookMode(nextMode);
if (!ok) {
ctx.ui.notify("Failed to update global settings", "error");
return;
}
}
hookMode = nextMode;
hookScope = scope;
touchedFiles.clear();
persistHookEntry({ scope, hookMode: nextMode });
updateLspStatus();
ctx.ui.notify(`LSP hook: ${labelForMode(hookMode)} (${hookScope})`, "info");
},
});
pi.on("session_start", async (_event, ctx) => {
restoreHookState(ctx);
statusUpdateFn = ctx.hasUI && ctx.ui.setStatus ? ctx.ui.setStatus.bind(ctx.ui) : null;
updateLspStatus();
if (hookMode === "disabled") return;
const manager = getOrCreateManager(ctx.cwd);
for (const [marker, ext] of Object.entries(WARMUP_MAP)) {
if (fs.existsSync(path.join(ctx.cwd, marker))) {
setActivity("loading");
manager.getClientsForFile(path.join(ctx.cwd, `dummy${ext}`))
.then((clients) => {
if (clients.length > 0) {
const cfg = LSP_SERVERS.find((s) => s.extensions.includes(ext));
if (cfg) activeClients.add(cfg.id);
}
})
.catch(() => {})
.finally(() => setActivity("idle"));
break;
}
}
});
pi.on("session_switch", async (_event, ctx) => {
restoreHookState(ctx);
updateLspStatus();
});
pi.on("session_tree", async (_event, ctx) => {
restoreHookState(ctx);
updateLspStatus();
});
pi.on("session_fork", async (_event, ctx) => {
restoreHookState(ctx);
updateLspStatus();
});
pi.on("session_shutdown", async () => {
shuttingDown = true;
clearIdleShutdownTimer();
diagnosticsAbort?.abort();
diagnosticsAbort = null;
setActivity("idle");
await shutdownManager();
activeClients.clear();
statusUpdateFn?.("lsp", undefined);
});
pi.on("tool_call", async (event, ctx) => {
const input = (event.input && typeof event.input === "object")
? event.input as Record<string, unknown>
: {};
if (event.toolName === "lsp") {
clearIdleShutdownTimer();
const files = extractLspFiles(input);
for (const file of files) {
ensureActiveClientForFile(file, ctx.cwd);
}
return;
}
if (event.toolName !== "read" && event.toolName !== "write" && event.toolName !== "edit") return;
clearIdleShutdownTimer();
const filePath = typeof input.path === "string" ? input.path : undefined;
if (!filePath) return;
const absPath = ensureActiveClientForFile(filePath, ctx.cwd);
if (!absPath) return;
void getOrCreateManager(ctx.cwd).getClientsForFile(absPath).catch(() => {});
});
pi.on("agent_start", async () => {
clearIdleShutdownTimer();
diagnosticsAbort?.abort();
diagnosticsAbort = null;
setActivity("idle");
touchedFiles.clear();
});
function agentWasAborted(event: any): boolean {
const messages = Array.isArray(event?.messages) ? event.messages : [];
return messages.some((m: any) =>
m &&
typeof m === "object" &&
(m as any).role === "assistant" &&
(((m as any).stopReason === "aborted") || ((m as any).stopReason === "error"))
);
}
pi.on("agent_end", async (event, ctx) => {
try {
if (hookMode !== "agent_end") return;
if (agentWasAborted(event)) {
// Don't run diagnostics on aborted/error runs.
touchedFiles.clear();
return;
}
if (touchedFiles.size === 0) return;
if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
const abort = new AbortController();
diagnosticsAbort?.abort();
diagnosticsAbort = abort;
setActivity("working");
const files = Array.from(touchedFiles.entries());
touchedFiles.clear();
try {
const outputs: string[] = [];
for (const [filePath, includeWarnings] of files) {
if (shuttingDown || abort.signal.aborted) return;
if (!ctx.isIdle() || ctx.hasPendingMessages()) {
abort.abort();
return;
}
const output = await collectDiagnostics(filePath, ctx, includeWarnings, true, false);
if (abort.signal.aborted) return;
if (output) outputs.push(output);
}
if (shuttingDown || abort.signal.aborted) return;
if (outputs.length) {
pi.sendMessage({
customType: "lsp-diagnostics",
content: outputs.join("\n"),
display: true,
}, {
triggerTurn: true,
deliverAs: "followUp",
});
}
} finally {
if (diagnosticsAbort === abort) diagnosticsAbort = null;
if (!shuttingDown) setActivity("idle");
}
} finally {
if (!shuttingDown) scheduleIdleShutdown();
}
});
pi.on("tool_result", async (event, ctx) => {
if (event.toolName !== "write" && event.toolName !== "edit") return;
const filePath = event.input.path as string;
if (!filePath) return;
const absPath = ensureActiveClientForFile(filePath, ctx.cwd);
if (!absPath) return;
if (hookMode === "disabled") return;
if (hookMode === "agent_end") {
const includeWarnings = event.toolName === "write";
const existing = touchedFiles.get(absPath) ?? false;
touchedFiles.set(absPath, existing || includeWarnings);
return;
}
const includeWarnings = event.toolName === "write";
const output = await collectDiagnostics(absPath, ctx, includeWarnings, false);
if (!output) return;
return { content: [...event.content, { type: "text" as const, text: output }] as Array<{ type: "text"; text: string }> };
});
}

View File

@@ -0,0 +1,54 @@
{
"name": "lsp-pi",
"version": "1.0.3",
"description": "LSP extension for pi-coding-agent - provides language server tool and diagnostics feedback for Dart/Flutter, TypeScript, Vue, Svelte, Python, Go, Kotlin, Swift, Rust",
"scripts": {
"test": "npx tsx tests/lsp.test.ts",
"test:tool": "npx tsx tests/index.test.ts",
"test:integration": "npx tsx tests/lsp-integration.test.ts",
"test:all": "npm test && npm run test:tool && npm run test:integration"
},
"keywords": [
"lsp",
"language-server",
"dart",
"flutter",
"typescript",
"vue",
"svelte",
"python",
"go",
"kotlin",
"swift",
"rust",
"pi-coding-agent",
"extension",
"pi-package"
],
"author": "",
"license": "MIT",
"type": "module",
"pi": {
"extensions": [
"./lsp.ts",
"./lsp-tool.ts"
]
},
"dependencies": {
"@sinclair/typebox": "^0.34.33",
"vscode-languageserver-protocol": "^3.17.5"
},
"peerDependencies": {
"@mariozechner/pi-ai": "^0.50.0",
"@mariozechner/pi-coding-agent": "^0.50.0",
"@mariozechner/pi-tui": "^0.50.0"
},
"devDependencies": {
"@mariozechner/pi-ai": "^0.50.0",
"@mariozechner/pi-coding-agent": "^0.50.0",
"@mariozechner/pi-tui": "^0.50.0",
"@types/node": "^24.10.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,235 @@
/**
* Unit tests for index.ts formatting functions
*/
// ============================================================================
// Test utilities
// ============================================================================
const tests: Array<{ name: string; fn: () => void | Promise<void> }> = [];
function test(name: string, fn: () => void | Promise<void>) {
tests.push({ name, fn });
}
function assertEqual<T>(actual: T, expected: T, message?: string) {
const a = JSON.stringify(actual);
const e = JSON.stringify(expected);
if (a !== e) throw new Error(message || `Expected ${e}, got ${a}`);
}
// ============================================================================
// Import the module to test internal functions
// We need to test via the execute function since formatters are private
// Or we can extract and test the logic directly
// ============================================================================
import { uriToPath, findSymbolPosition, formatDiagnostic, filterDiagnosticsBySeverity } from "../lsp-core.js";
// ============================================================================
// uriToPath tests
// ============================================================================
test("uriToPath: converts file:// URI to path", () => {
const result = uriToPath("file:///Users/test/file.ts");
assertEqual(result, "/Users/test/file.ts");
});
test("uriToPath: handles encoded characters", () => {
const result = uriToPath("file:///Users/test/my%20file.ts");
assertEqual(result, "/Users/test/my file.ts");
});
test("uriToPath: passes through non-file URIs", () => {
const result = uriToPath("/some/path.ts");
assertEqual(result, "/some/path.ts");
});
test("uriToPath: handles invalid URIs gracefully", () => {
const result = uriToPath("not-a-valid-uri");
assertEqual(result, "not-a-valid-uri");
});
// ============================================================================
// findSymbolPosition tests
// ============================================================================
test("findSymbolPosition: finds exact match", () => {
const symbols = [
{ name: "greet", range: { start: { line: 5, character: 10 }, end: { line: 5, character: 15 } }, selectionRange: { start: { line: 5, character: 10 }, end: { line: 5, character: 15 } }, kind: 12, children: [] },
{ name: "hello", range: { start: { line: 10, character: 0 }, end: { line: 10, character: 5 } }, selectionRange: { start: { line: 10, character: 0 }, end: { line: 10, character: 5 } }, kind: 12, children: [] },
];
const pos = findSymbolPosition(symbols as any, "greet");
assertEqual(pos, { line: 5, character: 10 });
});
test("findSymbolPosition: finds partial match", () => {
const symbols = [
{ name: "getUserName", range: { start: { line: 3, character: 0 }, end: { line: 3, character: 11 } }, selectionRange: { start: { line: 3, character: 0 }, end: { line: 3, character: 11 } }, kind: 12, children: [] },
];
const pos = findSymbolPosition(symbols as any, "user");
assertEqual(pos, { line: 3, character: 0 });
});
test("findSymbolPosition: prefers exact over partial", () => {
const symbols = [
{ name: "userName", range: { start: { line: 1, character: 0 }, end: { line: 1, character: 8 } }, selectionRange: { start: { line: 1, character: 0 }, end: { line: 1, character: 8 } }, kind: 12, children: [] },
{ name: "user", range: { start: { line: 5, character: 0 }, end: { line: 5, character: 4 } }, selectionRange: { start: { line: 5, character: 0 }, end: { line: 5, character: 4 } }, kind: 12, children: [] },
];
const pos = findSymbolPosition(symbols as any, "user");
assertEqual(pos, { line: 5, character: 0 });
});
test("findSymbolPosition: searches nested children", () => {
const symbols = [
{
name: "MyClass",
range: { start: { line: 0, character: 0 }, end: { line: 10, character: 0 } },
selectionRange: { start: { line: 0, character: 0 }, end: { line: 0, character: 7 } },
kind: 5,
children: [
{ name: "myMethod", range: { start: { line: 2, character: 2 }, end: { line: 4, character: 2 } }, selectionRange: { start: { line: 2, character: 2 }, end: { line: 2, character: 10 } }, kind: 6, children: [] },
]
},
];
const pos = findSymbolPosition(symbols as any, "myMethod");
assertEqual(pos, { line: 2, character: 2 });
});
test("findSymbolPosition: returns null for no match", () => {
const symbols = [
{ name: "foo", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } }, selectionRange: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } }, kind: 12, children: [] },
];
const pos = findSymbolPosition(symbols as any, "bar");
assertEqual(pos, null);
});
test("findSymbolPosition: case insensitive", () => {
const symbols = [
{ name: "MyFunction", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } }, selectionRange: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } }, kind: 12, children: [] },
];
const pos = findSymbolPosition(symbols as any, "myfunction");
assertEqual(pos, { line: 0, character: 0 });
});
// ============================================================================
// formatDiagnostic tests
// ============================================================================
test("formatDiagnostic: formats error", () => {
const diag = {
range: { start: { line: 5, character: 10 }, end: { line: 5, character: 15 } },
message: "Type 'number' is not assignable to type 'string'",
severity: 1,
};
const result = formatDiagnostic(diag as any);
assertEqual(result, "ERROR [6:11] Type 'number' is not assignable to type 'string'");
});
test("formatDiagnostic: formats warning", () => {
const diag = {
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
message: "Unused variable",
severity: 2,
};
const result = formatDiagnostic(diag as any);
assertEqual(result, "WARN [1:1] Unused variable");
});
test("formatDiagnostic: formats info", () => {
const diag = {
range: { start: { line: 2, character: 4 }, end: { line: 2, character: 10 } },
message: "Consider using const",
severity: 3,
};
const result = formatDiagnostic(diag as any);
assertEqual(result, "INFO [3:5] Consider using const");
});
test("formatDiagnostic: formats hint", () => {
const diag = {
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
message: "Prefer arrow function",
severity: 4,
};
const result = formatDiagnostic(diag as any);
assertEqual(result, "HINT [1:1] Prefer arrow function");
});
// ============================================================================
// filterDiagnosticsBySeverity tests
// ============================================================================
test("filterDiagnosticsBySeverity: all returns everything", () => {
const diags = [
{ severity: 1, message: "error", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
{ severity: 2, message: "warning", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
{ severity: 3, message: "info", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
{ severity: 4, message: "hint", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
];
const result = filterDiagnosticsBySeverity(diags as any, "all");
assertEqual(result.length, 4);
});
test("filterDiagnosticsBySeverity: error returns only errors", () => {
const diags = [
{ severity: 1, message: "error", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
{ severity: 2, message: "warning", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
];
const result = filterDiagnosticsBySeverity(diags as any, "error");
assertEqual(result.length, 1);
assertEqual(result[0].message, "error");
});
test("filterDiagnosticsBySeverity: warning returns errors and warnings", () => {
const diags = [
{ severity: 1, message: "error", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
{ severity: 2, message: "warning", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
{ severity: 3, message: "info", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
];
const result = filterDiagnosticsBySeverity(diags as any, "warning");
assertEqual(result.length, 2);
});
test("filterDiagnosticsBySeverity: info returns errors, warnings, and info", () => {
const diags = [
{ severity: 1, message: "error", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
{ severity: 2, message: "warning", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
{ severity: 3, message: "info", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
{ severity: 4, message: "hint", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } },
];
const result = filterDiagnosticsBySeverity(diags as any, "info");
assertEqual(result.length, 3);
});
// ============================================================================
// Run tests
// ============================================================================
async function runTests(): Promise<void> {
console.log("Running index.ts unit tests...\n");
let passed = 0;
let failed = 0;
for (const { name, fn } of tests) {
try {
await fn();
console.log(` ${name}... ✓`);
passed++;
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.log(` ${name}... ✗`);
console.log(` Error: ${msg}\n`);
failed++;
}
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) {
process.exit(1);
}
}
runTests();

View File

@@ -0,0 +1,602 @@
/**
* Integration tests for LSP - spawns real language servers and detects errors
*
* Run with: npm run test:integration
*
* Skips tests if language server is not installed.
*/
// Suppress stream errors from vscode-jsonrpc when LSP process exits
process.on('uncaughtException', (err) => {
if (err.message?.includes('write after end')) return;
console.error('Uncaught:', err);
process.exit(1);
});
import { mkdtemp, rm, writeFile, mkdir } from "fs/promises";
import { existsSync, statSync } from "fs";
import { tmpdir } from "os";
import { join, delimiter } from "path";
import { LSPManager } from "../lsp-core.js";
// ============================================================================
// Test utilities
// ============================================================================
const tests: Array<{ name: string; fn: () => Promise<void> }> = [];
let skipped = 0;
function test(name: string, fn: () => Promise<void>) {
tests.push({ name, fn });
}
function assert(condition: boolean, message: string) {
if (!condition) throw new Error(message);
}
class SkipTest extends Error {
constructor(reason: string) {
super(reason);
this.name = "SkipTest";
}
}
function skip(reason: string): never {
throw new SkipTest(reason);
}
// Search paths matching lsp-core.ts
const SEARCH_PATHS = [
...(process.env.PATH?.split(delimiter) || []),
"/usr/local/bin",
"/opt/homebrew/bin",
`${process.env.HOME || ""}/.pub-cache/bin`,
`${process.env.HOME || ""}/fvm/default/bin`,
`${process.env.HOME || ""}/go/bin`,
`${process.env.HOME || ""}/.cargo/bin`,
];
function commandExists(cmd: string): boolean {
for (const dir of SEARCH_PATHS) {
const full = join(dir, cmd);
try {
if (existsSync(full) && statSync(full).isFile()) return true;
} catch {}
}
return false;
}
// ============================================================================
// TypeScript
// ============================================================================
test("typescript: detects type errors", async () => {
if (!commandExists("typescript-language-server")) {
skip("typescript-language-server not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-ts-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "package.json"), "{}");
await writeFile(join(dir, "tsconfig.json"), JSON.stringify({
compilerOptions: { strict: true, noEmit: true }
}));
// Code with type error
const file = join(dir, "index.ts");
await writeFile(file, `const x: string = 123;`);
const { diagnostics } = await manager.touchFileAndWait(file, 10000);
assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`);
assert(
diagnostics.some(d => d.message.toLowerCase().includes("type") || d.severity === 1),
`Expected type error, got: ${diagnostics.map(d => d.message).join(", ")}`
);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
test("typescript: valid code has no errors", async () => {
if (!commandExists("typescript-language-server")) {
skip("typescript-language-server not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-ts-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "package.json"), "{}");
await writeFile(join(dir, "tsconfig.json"), JSON.stringify({
compilerOptions: { strict: true, noEmit: true }
}));
const file = join(dir, "index.ts");
await writeFile(file, `const x: string = "hello";`);
const { diagnostics } = await manager.touchFileAndWait(file, 10000);
const errors = diagnostics.filter(d => d.severity === 1);
assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
// ============================================================================
// Dart
// ============================================================================
test("dart: detects type errors", async () => {
if (!commandExists("dart")) {
skip("dart not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-dart-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "pubspec.yaml"), "name: test_app\nenvironment:\n sdk: ^3.0.0");
await mkdir(join(dir, "lib"));
const file = join(dir, "lib/main.dart");
// Type error: assigning int to String
await writeFile(file, `
void main() {
String x = 123;
print(x);
}
`);
const { diagnostics } = await manager.touchFileAndWait(file, 15000);
assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
test("dart: valid code has no errors", async () => {
if (!commandExists("dart")) {
skip("dart not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-dart-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "pubspec.yaml"), "name: test_app\nenvironment:\n sdk: ^3.0.0");
await mkdir(join(dir, "lib"));
const file = join(dir, "lib/main.dart");
await writeFile(file, `
void main() {
String x = "hello";
print(x);
}
`);
const { diagnostics } = await manager.touchFileAndWait(file, 15000);
const errors = diagnostics.filter(d => d.severity === 1);
assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
// ============================================================================
// Rust
// ============================================================================
test("rust: detects type errors", async () => {
if (!commandExists("rust-analyzer")) {
skip("rust-analyzer not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-rust-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "Cargo.toml"), `[package]\nname = "test"\nversion = "0.1.0"\nedition = "2021"`);
await mkdir(join(dir, "src"));
const file = join(dir, "src/main.rs");
await writeFile(file, `fn main() {\n let x: i32 = "hello";\n}`);
// rust-analyzer needs a LOT of time to initialize (compiles the project)
const { diagnostics } = await manager.touchFileAndWait(file, 60000);
assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
test("rust: valid code has no errors", async () => {
if (!commandExists("rust-analyzer")) {
skip("rust-analyzer not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-rust-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "Cargo.toml"), `[package]\nname = "test"\nversion = "0.1.0"\nedition = "2021"`);
await mkdir(join(dir, "src"));
const file = join(dir, "src/main.rs");
await writeFile(file, `fn main() {\n let x = "hello";\n println!("{}", x);\n}`);
const { diagnostics } = await manager.touchFileAndWait(file, 60000);
const errors = diagnostics.filter(d => d.severity === 1);
assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
// ============================================================================
// Go
// ============================================================================
test("go: detects type errors", async () => {
if (!commandExists("gopls")) {
skip("gopls not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-go-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "go.mod"), "module test\n\ngo 1.21");
const file = join(dir, "main.go");
// Type error: cannot use int as string
await writeFile(file, `package main
func main() {
var x string = 123
println(x)
}
`);
const { diagnostics } = await manager.touchFileAndWait(file, 15000);
assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
test("go: valid code has no errors", async () => {
if (!commandExists("gopls")) {
skip("gopls not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-go-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "go.mod"), "module test\n\ngo 1.21");
const file = join(dir, "main.go");
await writeFile(file, `package main
func main() {
var x string = "hello"
println(x)
}
`);
const { diagnostics } = await manager.touchFileAndWait(file, 15000);
const errors = diagnostics.filter(d => d.severity === 1);
assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
// ============================================================================
// Kotlin
// ============================================================================
test("kotlin: detects syntax errors", async () => {
if (!commandExists("kotlin-language-server")) {
skip("kotlin-language-server not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-kt-"));
const manager = new LSPManager(dir);
try {
// Minimal Gradle markers so the LSP picks a root
await writeFile(join(dir, "settings.gradle.kts"), "rootProject.name = \"test\"\n");
await writeFile(join(dir, "build.gradle.kts"), "// empty\n");
await mkdir(join(dir, "src/main/kotlin"), { recursive: true });
const file = join(dir, "src/main/kotlin/Main.kt");
// Syntax error
await writeFile(file, "fun main() { val x = }\n");
const { diagnostics, receivedResponse } = await manager.touchFileAndWait(file, 30000);
assert(receivedResponse, "Expected Kotlin LSP to respond");
assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
test("kotlin: valid code has no errors", async () => {
if (!commandExists("kotlin-language-server")) {
skip("kotlin-language-server not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-kt-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "settings.gradle.kts"), "rootProject.name = \"test\"\n");
await writeFile(join(dir, "build.gradle.kts"), "// empty\n");
await mkdir(join(dir, "src/main/kotlin"), { recursive: true });
const file = join(dir, "src/main/kotlin/Main.kt");
await writeFile(file, "fun main() { val x = 1; println(x) }\n");
const { diagnostics, receivedResponse } = await manager.touchFileAndWait(file, 30000);
assert(receivedResponse, "Expected Kotlin LSP to respond");
const errors = diagnostics.filter(d => d.severity === 1);
assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
// ============================================================================
// Python
// ============================================================================
test("python: detects type errors", async () => {
if (!commandExists("pyright-langserver")) {
skip("pyright-langserver not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-py-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "pyproject.toml"), `[project]\nname = "test"`);
const file = join(dir, "main.py");
// Type error with type annotation
await writeFile(file, `
def greet(name: str) -> str:
return "Hello, " + name
x: str = 123 # Type error
result = greet(456) # Type error
`);
const { diagnostics } = await manager.touchFileAndWait(file, 10000);
assert(diagnostics.length > 0, `Expected errors, got ${diagnostics.length}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
test("python: valid code has no errors", async () => {
if (!commandExists("pyright-langserver")) {
skip("pyright-langserver not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-py-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "pyproject.toml"), `[project]\nname = "test"`);
const file = join(dir, "main.py");
await writeFile(file, `
def greet(name: str) -> str:
return "Hello, " + name
x: str = "world"
result = greet(x)
`);
const { diagnostics } = await manager.touchFileAndWait(file, 10000);
const errors = diagnostics.filter(d => d.severity === 1);
assert(errors.length === 0, `Expected no errors, got: ${errors.map(d => d.message).join(", ")}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
// ============================================================================
// Rename (TypeScript)
// ============================================================================
test("typescript: rename symbol", async () => {
if (!commandExists("typescript-language-server")) {
skip("typescript-language-server not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-ts-rename-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "package.json"), "{}");
await writeFile(join(dir, "tsconfig.json"), JSON.stringify({
compilerOptions: { strict: true, noEmit: true }
}));
const file = join(dir, "index.ts");
await writeFile(file, `function greet(name: string) {
return "Hello, " + name;
}
const result = greet("world");
`);
// Touch file first to ensure it's loaded
await manager.touchFileAndWait(file, 10000);
// Rename 'greet' at line 1, col 10
const edit = await manager.rename(file, 1, 10, "sayHello");
if (!edit) throw new Error("Expected rename to return WorkspaceEdit");
assert(
edit.changes !== undefined || edit.documentChanges !== undefined,
"Expected changes or documentChanges in WorkspaceEdit"
);
// Should have edits for both the function definition and the call
const allEdits: any[] = [];
if (edit.changes) {
for (const edits of Object.values(edit.changes)) {
allEdits.push(...(edits as any[]));
}
}
if (edit.documentChanges) {
for (const change of edit.documentChanges as any[]) {
if (change.edits) allEdits.push(...change.edits);
}
}
assert(allEdits.length >= 2, `Expected at least 2 edits (definition + usage), got ${allEdits.length}`);
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
// ============================================================================
// Code Actions (TypeScript)
// ============================================================================
test("typescript: get code actions for error", async () => {
if (!commandExists("typescript-language-server")) {
skip("typescript-language-server not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-ts-actions-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "package.json"), "{}");
await writeFile(join(dir, "tsconfig.json"), JSON.stringify({
compilerOptions: { strict: true, noEmit: true }
}));
const file = join(dir, "index.ts");
// Missing import - should offer "Add import" code action
await writeFile(file, `const x: Promise<string> = Promise.resolve("hello");
console.log(x);
`);
// Touch to get diagnostics first
await manager.touchFileAndWait(file, 10000);
// Get code actions at line 1
const actions = await manager.getCodeActions(file, 1, 1, 1, 50);
// May or may not have actions depending on the code, but shouldn't throw
assert(Array.isArray(actions), "Expected array of code actions");
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
test("typescript: code actions for missing function", async () => {
if (!commandExists("typescript-language-server")) {
skip("typescript-language-server not installed");
}
const dir = await mkdtemp(join(tmpdir(), "lsp-ts-actions2-"));
const manager = new LSPManager(dir);
try {
await writeFile(join(dir, "package.json"), "{}");
await writeFile(join(dir, "tsconfig.json"), JSON.stringify({
compilerOptions: { strict: true, noEmit: true }
}));
const file = join(dir, "index.ts");
// Call undefined function - should offer quick fix
await writeFile(file, `const result = undefinedFunction();
`);
await manager.touchFileAndWait(file, 10000);
// Get code actions where the error is
const actions = await manager.getCodeActions(file, 1, 16, 1, 33);
// TypeScript should offer to create the function
assert(Array.isArray(actions), "Expected array of code actions");
// Note: we don't assert on action count since it depends on TS version
} finally {
await manager.shutdown();
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
});
// ============================================================================
// Run tests
// ============================================================================
async function runTests(): Promise<void> {
console.log("Running LSP integration tests...\n");
console.log("Note: Tests are skipped if language server is not installed.\n");
let passed = 0;
let failed = 0;
for (const { name, fn } of tests) {
try {
await fn();
console.log(` ${name}... ✓`);
passed++;
} catch (error) {
if (error instanceof SkipTest) {
console.log(` ${name}... ⊘ (${error.message})`);
skipped++;
} else {
const msg = error instanceof Error ? error.message : String(error);
console.log(` ${name}... ✗`);
console.log(` Error: ${msg}\n`);
failed++;
}
}
}
console.log(`\n${passed} passed, ${failed} failed, ${skipped} skipped`);
if (failed > 0) {
process.exit(1);
}
}
runTests();

View File

@@ -0,0 +1,898 @@
/**
* Tests for LSP hook - configuration and utility functions
*
* Run with: npm test
*
* These tests cover:
* - Project root detection for various languages
* - Language ID mappings
* - URI construction
* - Server configuration correctness
*/
import { mkdtemp, rm, writeFile, mkdir } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { pathToFileURL } from "url";
import { LSP_SERVERS, LANGUAGE_IDS } from "../lsp-core.js";
// ============================================================================
// Test utilities
// ============================================================================
interface TestResult {
name: string;
passed: boolean;
error?: string;
}
const tests: Array<{ name: string; fn: () => Promise<void> }> = [];
function test(name: string, fn: () => Promise<void>) {
tests.push({ name, fn });
}
function assert(condition: boolean, message: string) {
if (!condition) throw new Error(message);
}
function assertEquals<T>(actual: T, expected: T, message: string) {
assert(
actual === expected,
`${message}\nExpected: ${JSON.stringify(expected)}\nActual: ${JSON.stringify(actual)}`
);
}
function assertIncludes(arr: string[], item: string, message: string) {
assert(arr.includes(item), `${message}\nArray: [${arr.join(", ")}]\nMissing: ${item}`);
}
/** Create a temp directory with optional file structure */
async function withTempDir(
structure: Record<string, string | null>, // null = directory, string = file content
fn: (dir: string) => Promise<void>
): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), "lsp-test-"));
try {
for (const [path, content] of Object.entries(structure)) {
const fullPath = join(dir, path);
if (content === null) {
await mkdir(fullPath, { recursive: true });
} else {
await mkdir(join(dir, path.split("/").slice(0, -1).join("/")), { recursive: true }).catch(() => {});
await writeFile(fullPath, content);
}
}
await fn(dir);
} finally {
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
}
// ============================================================================
// Language ID tests
// ============================================================================
test("LANGUAGE_IDS: TypeScript extensions", async () => {
assertEquals(LANGUAGE_IDS[".ts"], "typescript", ".ts should map to typescript");
assertEquals(LANGUAGE_IDS[".tsx"], "typescriptreact", ".tsx should map to typescriptreact");
assertEquals(LANGUAGE_IDS[".mts"], "typescript", ".mts should map to typescript");
assertEquals(LANGUAGE_IDS[".cts"], "typescript", ".cts should map to typescript");
});
test("LANGUAGE_IDS: JavaScript extensions", async () => {
assertEquals(LANGUAGE_IDS[".js"], "javascript", ".js should map to javascript");
assertEquals(LANGUAGE_IDS[".jsx"], "javascriptreact", ".jsx should map to javascriptreact");
assertEquals(LANGUAGE_IDS[".mjs"], "javascript", ".mjs should map to javascript");
assertEquals(LANGUAGE_IDS[".cjs"], "javascript", ".cjs should map to javascript");
});
test("LANGUAGE_IDS: Dart extension", async () => {
assertEquals(LANGUAGE_IDS[".dart"], "dart", ".dart should map to dart");
});
test("LANGUAGE_IDS: Go extension", async () => {
assertEquals(LANGUAGE_IDS[".go"], "go", ".go should map to go");
});
test("LANGUAGE_IDS: Rust extension", async () => {
assertEquals(LANGUAGE_IDS[".rs"], "rust", ".rs should map to rust");
});
test("LANGUAGE_IDS: Kotlin extensions", async () => {
assertEquals(LANGUAGE_IDS[".kt"], "kotlin", ".kt should map to kotlin");
assertEquals(LANGUAGE_IDS[".kts"], "kotlin", ".kts should map to kotlin");
});
test("LANGUAGE_IDS: Swift extension", async () => {
assertEquals(LANGUAGE_IDS[".swift"], "swift", ".swift should map to swift");
});
test("LANGUAGE_IDS: Python extensions", async () => {
assertEquals(LANGUAGE_IDS[".py"], "python", ".py should map to python");
assertEquals(LANGUAGE_IDS[".pyi"], "python", ".pyi should map to python");
});
test("LANGUAGE_IDS: Vue/Svelte/Astro extensions", async () => {
assertEquals(LANGUAGE_IDS[".vue"], "vue", ".vue should map to vue");
assertEquals(LANGUAGE_IDS[".svelte"], "svelte", ".svelte should map to svelte");
assertEquals(LANGUAGE_IDS[".astro"], "astro", ".astro should map to astro");
});
// ============================================================================
// Server configuration tests
// ============================================================================
test("LSP_SERVERS: has TypeScript server", async () => {
const server = LSP_SERVERS.find(s => s.id === "typescript");
assert(server !== undefined, "Should have typescript server");
assertIncludes(server!.extensions, ".ts", "Should handle .ts");
assertIncludes(server!.extensions, ".tsx", "Should handle .tsx");
assertIncludes(server!.extensions, ".js", "Should handle .js");
assertIncludes(server!.extensions, ".jsx", "Should handle .jsx");
});
test("LSP_SERVERS: has Dart server", async () => {
const server = LSP_SERVERS.find(s => s.id === "dart");
assert(server !== undefined, "Should have dart server");
assertIncludes(server!.extensions, ".dart", "Should handle .dart");
});
test("LSP_SERVERS: has Rust Analyzer server", async () => {
const server = LSP_SERVERS.find(s => s.id === "rust-analyzer");
assert(server !== undefined, "Should have rust-analyzer server");
assertIncludes(server!.extensions, ".rs", "Should handle .rs");
});
test("LSP_SERVERS: has Gopls server", async () => {
const server = LSP_SERVERS.find(s => s.id === "gopls");
assert(server !== undefined, "Should have gopls server");
assertIncludes(server!.extensions, ".go", "Should handle .go");
});
test("LSP_SERVERS: has Kotlin server", async () => {
const server = LSP_SERVERS.find(s => s.id === "kotlin");
assert(server !== undefined, "Should have kotlin server");
assertIncludes(server!.extensions, ".kt", "Should handle .kt");
assertIncludes(server!.extensions, ".kts", "Should handle .kts");
});
test("LSP_SERVERS: has Swift server", async () => {
const server = LSP_SERVERS.find(s => s.id === "swift");
assert(server !== undefined, "Should have swift server");
assertIncludes(server!.extensions, ".swift", "Should handle .swift");
});
test("LSP_SERVERS: has Pyright server", async () => {
const server = LSP_SERVERS.find(s => s.id === "pyright");
assert(server !== undefined, "Should have pyright server");
assertIncludes(server!.extensions, ".py", "Should handle .py");
assertIncludes(server!.extensions, ".pyi", "Should handle .pyi");
});
// ============================================================================
// TypeScript root detection tests
// ============================================================================
test("typescript: finds root with package.json", async () => {
await withTempDir({
"package.json": "{}",
"src/index.ts": "export const x = 1;",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const root = server.findRoot(join(dir, "src/index.ts"), dir);
assertEquals(root, dir, "Should find root at package.json location");
});
});
test("typescript: finds root with tsconfig.json", async () => {
await withTempDir({
"tsconfig.json": "{}",
"src/index.ts": "export const x = 1;",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const root = server.findRoot(join(dir, "src/index.ts"), dir);
assertEquals(root, dir, "Should find root at tsconfig.json location");
});
});
test("typescript: finds root with jsconfig.json", async () => {
await withTempDir({
"jsconfig.json": "{}",
"src/app.js": "const x = 1;",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const root = server.findRoot(join(dir, "src/app.js"), dir);
assertEquals(root, dir, "Should find root at jsconfig.json location");
});
});
test("typescript: returns undefined for deno projects", async () => {
await withTempDir({
"deno.json": "{}",
"main.ts": "console.log('deno');",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const root = server.findRoot(join(dir, "main.ts"), dir);
assertEquals(root, undefined, "Should return undefined for deno projects");
});
});
test("typescript: nested package finds nearest root", async () => {
await withTempDir({
"package.json": "{}",
"packages/web/package.json": "{}",
"packages/web/src/index.ts": "export const x = 1;",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const root = server.findRoot(join(dir, "packages/web/src/index.ts"), dir);
assertEquals(root, join(dir, "packages/web"), "Should find nearest package.json");
});
});
// ============================================================================
// Dart root detection tests
// ============================================================================
test("dart: finds root with pubspec.yaml", async () => {
await withTempDir({
"pubspec.yaml": "name: my_app",
"lib/main.dart": "void main() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "dart")!;
const root = server.findRoot(join(dir, "lib/main.dart"), dir);
assertEquals(root, dir, "Should find root at pubspec.yaml location");
});
});
test("dart: finds root with analysis_options.yaml", async () => {
await withTempDir({
"analysis_options.yaml": "linter: rules:",
"lib/main.dart": "void main() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "dart")!;
const root = server.findRoot(join(dir, "lib/main.dart"), dir);
assertEquals(root, dir, "Should find root at analysis_options.yaml location");
});
});
test("dart: nested package finds nearest root", async () => {
await withTempDir({
"pubspec.yaml": "name: monorepo",
"packages/core/pubspec.yaml": "name: core",
"packages/core/lib/core.dart": "void init() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "dart")!;
const root = server.findRoot(join(dir, "packages/core/lib/core.dart"), dir);
assertEquals(root, join(dir, "packages/core"), "Should find nearest pubspec.yaml");
});
});
// ============================================================================
// Rust root detection tests
// ============================================================================
test("rust: finds root with Cargo.toml", async () => {
await withTempDir({
"Cargo.toml": "[package]\nname = \"my_crate\"",
"src/lib.rs": "pub fn hello() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "rust-analyzer")!;
const root = server.findRoot(join(dir, "src/lib.rs"), dir);
assertEquals(root, dir, "Should find root at Cargo.toml location");
});
});
test("rust: nested workspace member finds nearest Cargo.toml", async () => {
await withTempDir({
"Cargo.toml": "[workspace]\nmembers = [\"crates/*\"]",
"crates/core/Cargo.toml": "[package]\nname = \"core\"",
"crates/core/src/lib.rs": "pub fn init() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "rust-analyzer")!;
const root = server.findRoot(join(dir, "crates/core/src/lib.rs"), dir);
assertEquals(root, join(dir, "crates/core"), "Should find nearest Cargo.toml");
});
});
// ============================================================================
// Go root detection tests (including gopls bug fix verification)
// ============================================================================
test("gopls: finds root with go.mod", async () => {
await withTempDir({
"go.mod": "module example.com/myapp",
"main.go": "package main",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "gopls")!;
const root = server.findRoot(join(dir, "main.go"), dir);
assertEquals(root, dir, "Should find root at go.mod location");
});
});
test("gopls: finds root with go.work (workspace)", async () => {
await withTempDir({
"go.work": "go 1.21\nuse ./app",
"app/go.mod": "module example.com/app",
"app/main.go": "package main",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "gopls")!;
const root = server.findRoot(join(dir, "app/main.go"), dir);
assertEquals(root, dir, "Should find root at go.work location (workspace root)");
});
});
test("gopls: prefers go.work over go.mod", async () => {
await withTempDir({
"go.work": "go 1.21\nuse ./app",
"go.mod": "module example.com/root",
"app/go.mod": "module example.com/app",
"app/main.go": "package main",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "gopls")!;
const root = server.findRoot(join(dir, "app/main.go"), dir);
// go.work is found first, so it should return the go.work location
assertEquals(root, dir, "Should prefer go.work over go.mod");
});
});
test("gopls: returns undefined when no go.mod or go.work (bug fix verification)", async () => {
await withTempDir({
"main.go": "package main",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "gopls")!;
const root = server.findRoot(join(dir, "main.go"), dir);
// This test verifies the bug fix: previously this would return undefined
// because `undefined !== cwd` was true, skipping the go.mod check
assertEquals(root, undefined, "Should return undefined when no go.mod or go.work");
});
});
test("gopls: finds go.mod when go.work not present (bug fix verification)", async () => {
await withTempDir({
"go.mod": "module example.com/myapp",
"cmd/server/main.go": "package main",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "gopls")!;
const root = server.findRoot(join(dir, "cmd/server/main.go"), dir);
// This is the key test for the bug fix
// Previously: findRoot(go.work) returns undefined, then `undefined !== cwd` is true,
// so it would return undefined without checking go.mod
// After fix: if go.work not found, falls through to check go.mod
assertEquals(root, dir, "Should find go.mod when go.work is not present");
});
});
// ============================================================================
// Kotlin root detection tests
// ============================================================================
test("kotlin: finds root with settings.gradle.kts", async () => {
await withTempDir({
"settings.gradle.kts": "rootProject.name = \"myapp\"",
"app/src/main/kotlin/Main.kt": "fun main() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "kotlin")!;
const root = server.findRoot(join(dir, "app/src/main/kotlin/Main.kt"), dir);
assertEquals(root, dir, "Should find root at settings.gradle.kts location");
});
});
test("kotlin: prefers settings.gradle(.kts) over nested build.gradle", async () => {
await withTempDir({
"settings.gradle": "rootProject.name = 'root'",
"app/build.gradle": "plugins {}",
"app/src/main/kotlin/Main.kt": "fun main() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "kotlin")!;
const root = server.findRoot(join(dir, "app/src/main/kotlin/Main.kt"), dir);
assertEquals(root, dir, "Should prefer settings.gradle at workspace root");
});
});
test("kotlin: finds root with pom.xml", async () => {
await withTempDir({
"pom.xml": "<project></project>",
"src/main/kotlin/Main.kt": "fun main() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "kotlin")!;
const root = server.findRoot(join(dir, "src/main/kotlin/Main.kt"), dir);
assertEquals(root, dir, "Should find root at pom.xml location");
});
});
// ============================================================================
// Swift root detection tests
// ============================================================================
test("swift: finds root with Package.swift", async () => {
await withTempDir({
"Package.swift": "// swift-tools-version: 5.9",
"Sources/App/main.swift": "print(\"hi\")",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "swift")!;
const root = server.findRoot(join(dir, "Sources/App/main.swift"), dir);
assertEquals(root, dir, "Should find root at Package.swift location");
});
});
test("swift: finds root with Xcode project", async () => {
await withTempDir({
"MyApp.xcodeproj/project.pbxproj": "// pbxproj",
"MyApp/main.swift": "print(\"hi\")",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "swift")!;
const root = server.findRoot(join(dir, "MyApp/main.swift"), dir);
assertEquals(root, dir, "Should find root at Xcode project location");
});
});
test("swift: finds root with Xcode workspace", async () => {
await withTempDir({
"MyApp.xcworkspace/contents.xcworkspacedata": "<Workspace/>",
"MyApp/main.swift": "print(\"hi\")",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "swift")!;
const root = server.findRoot(join(dir, "MyApp/main.swift"), dir);
assertEquals(root, dir, "Should find root at Xcode workspace location");
});
});
// ============================================================================
// Python root detection tests
// ============================================================================
test("pyright: finds root with pyproject.toml", async () => {
await withTempDir({
"pyproject.toml": "[project]\nname = \"myapp\"",
"src/main.py": "print('hello')",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "pyright")!;
const root = server.findRoot(join(dir, "src/main.py"), dir);
assertEquals(root, dir, "Should find root at pyproject.toml location");
});
});
test("pyright: finds root with setup.py", async () => {
await withTempDir({
"setup.py": "from setuptools import setup",
"myapp/main.py": "print('hello')",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "pyright")!;
const root = server.findRoot(join(dir, "myapp/main.py"), dir);
assertEquals(root, dir, "Should find root at setup.py location");
});
});
test("pyright: finds root with requirements.txt", async () => {
await withTempDir({
"requirements.txt": "flask>=2.0",
"app.py": "from flask import Flask",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "pyright")!;
const root = server.findRoot(join(dir, "app.py"), dir);
assertEquals(root, dir, "Should find root at requirements.txt location");
});
});
// ============================================================================
// URI construction tests (pathToFileURL)
// ============================================================================
test("pathToFileURL: handles simple paths", async () => {
const uri = pathToFileURL("/home/user/project/file.ts").href;
assertEquals(uri, "file:///home/user/project/file.ts", "Should create proper file URI");
});
test("pathToFileURL: encodes special characters", async () => {
const uri = pathToFileURL("/home/user/my project/file.ts").href;
assert(uri.includes("my%20project"), "Should URL-encode spaces");
});
test("pathToFileURL: handles unicode", async () => {
const uri = pathToFileURL("/home/user/项目/file.ts").href;
// pathToFileURL properly encodes unicode
assert(uri.startsWith("file:///"), "Should start with file:///");
assert(uri.includes("file.ts"), "Should contain filename");
});
// ============================================================================
// Vue/Svelte root detection tests
// ============================================================================
test("vue: finds root with package.json", async () => {
await withTempDir({
"package.json": "{}",
"src/App.vue": "<template></template>",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "vue")!;
const root = server.findRoot(join(dir, "src/App.vue"), dir);
assertEquals(root, dir, "Should find root at package.json location");
});
});
test("vue: finds root with vite.config.ts", async () => {
await withTempDir({
"vite.config.ts": "export default {}",
"src/App.vue": "<template></template>",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "vue")!;
const root = server.findRoot(join(dir, "src/App.vue"), dir);
assertEquals(root, dir, "Should find root at vite.config.ts location");
});
});
test("svelte: finds root with svelte.config.js", async () => {
await withTempDir({
"svelte.config.js": "export default {}",
"src/App.svelte": "<script></script>",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "svelte")!;
const root = server.findRoot(join(dir, "src/App.svelte"), dir);
assertEquals(root, dir, "Should find root at svelte.config.js location");
});
});
// ============================================================================
// Additional Rust tests (parity with TypeScript)
// ============================================================================
test("rust: finds root in src subdirectory", async () => {
await withTempDir({
"Cargo.toml": "[package]\nname = \"myapp\"",
"src/main.rs": "fn main() {}",
"src/lib.rs": "pub mod utils;",
"src/utils/mod.rs": "pub fn helper() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "rust-analyzer")!;
const root = server.findRoot(join(dir, "src/utils/mod.rs"), dir);
assertEquals(root, dir, "Should find root from deeply nested src file");
});
});
test("rust: workspace with multiple crates", async () => {
await withTempDir({
"Cargo.toml": "[workspace]\nmembers = [\"crates/*\"]",
"crates/api/Cargo.toml": "[package]\nname = \"api\"",
"crates/api/src/lib.rs": "pub fn serve() {}",
"crates/core/Cargo.toml": "[package]\nname = \"core\"",
"crates/core/src/lib.rs": "pub fn init() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "rust-analyzer")!;
// Each crate should find its own Cargo.toml
const apiRoot = server.findRoot(join(dir, "crates/api/src/lib.rs"), dir);
const coreRoot = server.findRoot(join(dir, "crates/core/src/lib.rs"), dir);
assertEquals(apiRoot, join(dir, "crates/api"), "API crate should find its Cargo.toml");
assertEquals(coreRoot, join(dir, "crates/core"), "Core crate should find its Cargo.toml");
});
});
test("rust: returns undefined when no Cargo.toml", async () => {
await withTempDir({
"main.rs": "fn main() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "rust-analyzer")!;
const root = server.findRoot(join(dir, "main.rs"), dir);
assertEquals(root, undefined, "Should return undefined when no Cargo.toml");
});
});
// ============================================================================
// Additional Dart tests (parity with TypeScript)
// ============================================================================
test("dart: Flutter project with pubspec.yaml", async () => {
await withTempDir({
"pubspec.yaml": "name: my_flutter_app\ndependencies:\n flutter:\n sdk: flutter",
"lib/main.dart": "import 'package:flutter/material.dart';",
"lib/screens/home.dart": "class HomeScreen {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "dart")!;
const root = server.findRoot(join(dir, "lib/screens/home.dart"), dir);
assertEquals(root, dir, "Should find root for Flutter project");
});
});
test("dart: returns undefined when no marker files", async () => {
await withTempDir({
"main.dart": "void main() {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "dart")!;
const root = server.findRoot(join(dir, "main.dart"), dir);
assertEquals(root, undefined, "Should return undefined when no pubspec.yaml or analysis_options.yaml");
});
});
test("dart: monorepo with multiple packages", async () => {
await withTempDir({
"pubspec.yaml": "name: monorepo",
"packages/auth/pubspec.yaml": "name: auth",
"packages/auth/lib/auth.dart": "class Auth {}",
"packages/ui/pubspec.yaml": "name: ui",
"packages/ui/lib/widgets.dart": "class Button {}",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "dart")!;
const authRoot = server.findRoot(join(dir, "packages/auth/lib/auth.dart"), dir);
const uiRoot = server.findRoot(join(dir, "packages/ui/lib/widgets.dart"), dir);
assertEquals(authRoot, join(dir, "packages/auth"), "Auth package should find its pubspec");
assertEquals(uiRoot, join(dir, "packages/ui"), "UI package should find its pubspec");
});
});
// ============================================================================
// Additional Python tests (parity with TypeScript)
// ============================================================================
test("pyright: finds root with pyrightconfig.json", async () => {
await withTempDir({
"pyrightconfig.json": "{}",
"src/app.py": "print('hello')",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "pyright")!;
const root = server.findRoot(join(dir, "src/app.py"), dir);
assertEquals(root, dir, "Should find root at pyrightconfig.json location");
});
});
test("pyright: returns undefined when no marker files", async () => {
await withTempDir({
"script.py": "print('hello')",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "pyright")!;
const root = server.findRoot(join(dir, "script.py"), dir);
assertEquals(root, undefined, "Should return undefined when no Python project markers");
});
});
test("pyright: monorepo with multiple packages", async () => {
await withTempDir({
"pyproject.toml": "[project]\nname = \"monorepo\"",
"packages/api/pyproject.toml": "[project]\nname = \"api\"",
"packages/api/src/main.py": "from flask import Flask",
"packages/worker/pyproject.toml": "[project]\nname = \"worker\"",
"packages/worker/src/tasks.py": "def process(): pass",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "pyright")!;
const apiRoot = server.findRoot(join(dir, "packages/api/src/main.py"), dir);
const workerRoot = server.findRoot(join(dir, "packages/worker/src/tasks.py"), dir);
assertEquals(apiRoot, join(dir, "packages/api"), "API package should find its pyproject.toml");
assertEquals(workerRoot, join(dir, "packages/worker"), "Worker package should find its pyproject.toml");
});
});
// ============================================================================
// Additional Go tests
// ============================================================================
test("gopls: monorepo with multiple modules", async () => {
await withTempDir({
"go.work": "go 1.21\nuse (\n ./api\n ./worker\n)",
"api/go.mod": "module example.com/api",
"api/main.go": "package main",
"worker/go.mod": "module example.com/worker",
"worker/main.go": "package main",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "gopls")!;
// With go.work present, all files should use workspace root
const apiRoot = server.findRoot(join(dir, "api/main.go"), dir);
const workerRoot = server.findRoot(join(dir, "worker/main.go"), dir);
assertEquals(apiRoot, dir, "API module should use go.work root");
assertEquals(workerRoot, dir, "Worker module should use go.work root");
});
});
test("gopls: nested cmd directory", async () => {
await withTempDir({
"go.mod": "module example.com/myapp",
"cmd/server/main.go": "package main",
"cmd/cli/main.go": "package main",
"internal/db/db.go": "package db",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "gopls")!;
const serverRoot = server.findRoot(join(dir, "cmd/server/main.go"), dir);
const cliRoot = server.findRoot(join(dir, "cmd/cli/main.go"), dir);
const dbRoot = server.findRoot(join(dir, "internal/db/db.go"), dir);
assertEquals(serverRoot, dir, "cmd/server should find go.mod at root");
assertEquals(cliRoot, dir, "cmd/cli should find go.mod at root");
assertEquals(dbRoot, dir, "internal/db should find go.mod at root");
});
});
// ============================================================================
// Additional TypeScript tests
// ============================================================================
test("typescript: pnpm workspace", async () => {
await withTempDir({
"package.json": "{}",
"pnpm-workspace.yaml": "packages:\n - packages/*",
"packages/web/package.json": "{}",
"packages/web/src/App.tsx": "export const App = () => null;",
"packages/api/package.json": "{}",
"packages/api/src/index.ts": "export const handler = () => {};",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const webRoot = server.findRoot(join(dir, "packages/web/src/App.tsx"), dir);
const apiRoot = server.findRoot(join(dir, "packages/api/src/index.ts"), dir);
assertEquals(webRoot, join(dir, "packages/web"), "Web package should find its package.json");
assertEquals(apiRoot, join(dir, "packages/api"), "API package should find its package.json");
});
});
test("typescript: returns undefined when no config files", async () => {
await withTempDir({
"script.ts": "const x = 1;",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const root = server.findRoot(join(dir, "script.ts"), dir);
assertEquals(root, undefined, "Should return undefined when no package.json or tsconfig.json");
});
});
test("typescript: prefers nearest tsconfig over package.json", async () => {
await withTempDir({
"package.json": "{}",
"apps/web/tsconfig.json": "{}",
"apps/web/src/index.ts": "export const x = 1;",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const root = server.findRoot(join(dir, "apps/web/src/index.ts"), dir);
// Should find tsconfig.json first (it's nearer than root package.json)
assertEquals(root, join(dir, "apps/web"), "Should find nearest config file");
});
});
// ============================================================================
// Additional Vue/Svelte tests
// ============================================================================
test("vue: Nuxt project", async () => {
await withTempDir({
"package.json": "{}",
"nuxt.config.ts": "export default {}",
"pages/index.vue": "<template></template>",
"components/Button.vue": "<template></template>",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "vue")!;
const pagesRoot = server.findRoot(join(dir, "pages/index.vue"), dir);
const componentsRoot = server.findRoot(join(dir, "components/Button.vue"), dir);
assertEquals(pagesRoot, dir, "Pages should find root");
assertEquals(componentsRoot, dir, "Components should find root");
});
});
test("vue: returns undefined when no config", async () => {
await withTempDir({
"App.vue": "<template></template>",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "vue")!;
const root = server.findRoot(join(dir, "App.vue"), dir);
assertEquals(root, undefined, "Should return undefined when no package.json or vite.config");
});
});
test("svelte: SvelteKit project", async () => {
await withTempDir({
"package.json": "{}",
"svelte.config.js": "export default {}",
"src/routes/+page.svelte": "<script></script>",
"src/lib/components/Button.svelte": "<script></script>",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "svelte")!;
const routeRoot = server.findRoot(join(dir, "src/routes/+page.svelte"), dir);
const libRoot = server.findRoot(join(dir, "src/lib/components/Button.svelte"), dir);
assertEquals(routeRoot, dir, "Route should find root");
assertEquals(libRoot, dir, "Lib component should find root");
});
});
test("svelte: returns undefined when no config", async () => {
await withTempDir({
"App.svelte": "<script></script>",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "svelte")!;
const root = server.findRoot(join(dir, "App.svelte"), dir);
assertEquals(root, undefined, "Should return undefined when no package.json or svelte.config.js");
});
});
// ============================================================================
// Stop boundary tests (findNearestFile respects cwd boundary)
// ============================================================================
test("stop boundary: does not search above cwd", async () => {
await withTempDir({
"package.json": "{}", // This is at root
"projects/myapp/src/index.ts": "export const x = 1;",
// Note: no package.json in projects/myapp
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
// When cwd is set to projects/myapp, it should NOT find the root package.json
const projectDir = join(dir, "projects/myapp");
const root = server.findRoot(join(projectDir, "src/index.ts"), projectDir);
assertEquals(root, undefined, "Should not find package.json above cwd boundary");
});
});
test("stop boundary: finds marker at cwd level", async () => {
await withTempDir({
"projects/myapp/package.json": "{}",
"projects/myapp/src/index.ts": "export const x = 1;",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const projectDir = join(dir, "projects/myapp");
const root = server.findRoot(join(projectDir, "src/index.ts"), projectDir);
assertEquals(root, projectDir, "Should find package.json at cwd level");
});
});
// ============================================================================
// Edge cases
// ============================================================================
test("edge: deeply nested file finds correct root", async () => {
await withTempDir({
"package.json": "{}",
"src/components/ui/buttons/primary/Button.tsx": "export const Button = () => null;",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const root = server.findRoot(join(dir, "src/components/ui/buttons/primary/Button.tsx"), dir);
assertEquals(root, dir, "Should find root even for deeply nested files");
});
});
test("edge: file at root level finds root", async () => {
await withTempDir({
"package.json": "{}",
"index.ts": "console.log('root');",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const root = server.findRoot(join(dir, "index.ts"), dir);
assertEquals(root, dir, "Should find root for file at root level");
});
});
test("edge: no marker files returns undefined", async () => {
await withTempDir({
"random.ts": "const x = 1;",
}, async (dir) => {
const server = LSP_SERVERS.find(s => s.id === "typescript")!;
const root = server.findRoot(join(dir, "random.ts"), dir);
assertEquals(root, undefined, "Should return undefined when no marker files");
});
});
// ============================================================================
// Run tests
// ============================================================================
async function runTests(): Promise<void> {
console.log("Running LSP tests...\n");
const results: TestResult[] = [];
let passed = 0;
let failed = 0;
for (const { name, fn } of tests) {
try {
await fn();
results.push({ name, passed: true });
console.log(` ${name}... ✓`);
passed++;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
results.push({ name, passed: false, error: errorMsg });
console.log(` ${name}... ✗`);
console.log(` Error: ${errorMsg}\n`);
failed++;
}
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) {
process.exit(1);
}
}
runTests();

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"lib": ["ES2022"]
},
"include": ["*.ts"]
}