pi config update
This commit is contained in:
178
pi/.pi/agent/extensions/lsp-pi/README.md
Normal file
178
pi/.pi/agent/extensions/lsp-pi/README.md
Normal 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
|
||||
12
pi/.pi/agent/extensions/lsp-pi/index.ts
Normal file
12
pi/.pi/agent/extensions/lsp-pi/index.ts
Normal 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);
|
||||
}
|
||||
1129
pi/.pi/agent/extensions/lsp-pi/lsp-core.ts
Normal file
1129
pi/.pi/agent/extensions/lsp-pi/lsp-core.ts
Normal file
File diff suppressed because it is too large
Load Diff
382
pi/.pi/agent/extensions/lsp-pi/lsp-tool.ts
Normal file
382
pi/.pi/agent/extensions/lsp-pi/lsp-tool.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
604
pi/.pi/agent/extensions/lsp-pi/lsp.ts
Normal file
604
pi/.pi/agent/extensions/lsp-pi/lsp.ts
Normal 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 }> };
|
||||
});
|
||||
}
|
||||
54
pi/.pi/agent/extensions/lsp-pi/package.json
Normal file
54
pi/.pi/agent/extensions/lsp-pi/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
235
pi/.pi/agent/extensions/lsp-pi/tests/index.test.ts
Normal file
235
pi/.pi/agent/extensions/lsp-pi/tests/index.test.ts
Normal 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();
|
||||
602
pi/.pi/agent/extensions/lsp-pi/tests/lsp-integration.test.ts
Normal file
602
pi/.pi/agent/extensions/lsp-pi/tests/lsp-integration.test.ts
Normal 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();
|
||||
898
pi/.pi/agent/extensions/lsp-pi/tests/lsp.test.ts
Normal file
898
pi/.pi/agent/extensions/lsp-pi/tests/lsp.test.ts
Normal 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();
|
||||
13
pi/.pi/agent/extensions/lsp-pi/tsconfig.json
Normal file
13
pi/.pi/agent/extensions/lsp-pi/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022"]
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user