Add pi-tool-display (compact tool rendering, adaptive diffs) + pi-lsp-extension (LSP diagnostics/hover/definitions) — vendored from npm 0.5.0 / 1.3.0

This commit is contained in:
2026-08-05 07:07:51 +10:00
parent b13022a6fd
commit 41cc33d141
92 changed files with 27025 additions and 0 deletions

View File

@@ -0,0 +1 @@
* @samfoy

View File

@@ -0,0 +1,22 @@
name: Publish
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
environment: npm
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm publish --provenance --access public

View File

@@ -0,0 +1,57 @@
{
"id": "1773681807334-feature",
"presetName": "feature",
"prompt": "implement specs/tree-sitter-integration.md",
"startTime": 1773681807334,
"endTime": 1773683050481,
"outcome": "Task complete ✓",
"iterations": 4,
"history": [
{
"hat": "planner",
"event": "plan.start",
"iteration": 1
},
{
"hat": "builder",
"event": "tasks.ready",
"iteration": 2
},
{
"hat": "reviewer",
"event": "build.done",
"iteration": 3
},
{
"hat": "committer",
"event": "review.approved",
"iteration": 4
}
],
"iterationLogs": [
{
"iteration": 1,
"hatKey": "planner",
"hatName": "📋 Planner",
"event": "tasks.ready",
"summary": "\n\nPlanning is complete. Here's the summary:\n\nThe spec calls for a tree-sitter layer that provides zero-config code intelligence as a fallback when no LSP server is running. The plan covers:\n\n**New files (5):**\n- `src/tree-sitter/parser-manager.ts` — WASM parser loading/caching per language\n- `src/tree-sitter/symbol-extractor.ts` — AST walking to extract symbols, find definitions, get node info at positions\n- `src/tree-sitter/workspace-index.ts` — project-wide symbol index (capped at 5k files, incremental updates)\n- `src/resolve-provider.ts` — routing helper: LSP → tree-sitter → \"no intelligence available\"\n- `src/tools/code-overview.ts` — new tool for project structure summaries\n\n**Modified files (6):**\n- `src/tools/symbols.ts`, `definition.ts`, `hover.ts`, `diagnostics.ts` — each gets a tree-sitter fallback path\n- `src/index.ts` — wire up TreeSitterManager, WorkspaceIndex, register code_overview\n- `src/file-sync.ts` — invalidate tree-sitter cache on file changes\n\n**Dependencies:** `web-tree-sitter` + `tree-sitter-wasms` (pre-built grammar WASMs, loaded lazily per language)\n\n**Key design decisions:**\n- Grammars come from `tree-sitter-wasms` npm package (lazy-loaded, no download script)\n- LSP always takes priority; tree-sitter is fallback only\n- Workspace index caps at 5000 files initially, indexes remaining on demand\n- Same output formats as existing LSP tools so the LLM sees consistent results\n\nThe scratchpad has the full step-by-step breakdown. Ready for the Builder hat.",
"timestamp": 1773681927017
},
{
"iteration": 2,
"hatKey": "builder",
"hatName": "⚙️ Builder",
"event": "build.done",
"summary": "Implementation complete. Here's what I built:\n\n5 new files and 6 modified files implementing the tree-sitter integration spec. The core layer (`parser-manager.ts`, `symbol-extractor.ts`, `workspace-index.ts`) provides WASM-based parsing with per-language symbol extraction for TS/JS, Python, Rust, Go, Java, C/C++, and Ruby. The `resolve-provider.ts` routes each tool call to LSP (if running) or tree-sitter (fallback). All four existing tools (`symbols`, `definition`, `hover`, `diagnostics`) now gracefully degrade to tree-sitter when no LSP server is available. A new `code_overview` tool summarizes project structure using tree-sitter symbol extraction.\n\nKey discovery during implementation: `web-tree-sitter@0.26.x` has ABI incompatibility with `tree-sitter-wasms` pre-built grammars — pinned to `0.24.7` which works correctly. All grammars load and parse successfully, and the project compiles cleanly with zero TypeScript errors.",
"timestamp": 1773682806836
},
{
"iteration": 3,
"hatKey": "reviewer",
"hatName": "🔎 Reviewer",
"event": "review.approved",
"summary": "## Review Summary: ✅ Pass (2 fixes applied)\n\n### Bugs Fixed\n\n1. **`export_statement` dead code** (high severity) — `TS_JS_SYMBOLS` included an `export_statement` mapping that shadowed the intended unwrapping logic in `extractFromNode`. All exported TS/JS declarations were either incorrectly named or silently dropped. **Fixed** by removing the conflicting mapping entry. Verified all export patterns now extract correctly.\n\n2. **Unused `prefix` parameter** (low) — `WorkspaceIndex.addSymbols()` had a dead `prefix?` parameter. Removed.\n\n### Spec Compliance: Complete\nAll requirements met — zero-config fallback for 19 languages, graceful LSP→tree-sitter routing, `code_overview` tool, workspace indexing with 5000-file cap, FileSync integration, `[tree-sitter]` annotations, and syntax-only diagnostic disclaimer.\n\n### Compilation: Clean\n`tsc --noEmit` passes. WASM ABI compatibility verified at runtime.",
"timestamp": 1773683001658
}
]
}

View File

@@ -0,0 +1,57 @@
{
"id": "1773683776080-feature",
"presetName": "feature",
"prompt": "specs/completions-tool.md",
"startTime": 1773683776080,
"endTime": 1773684508903,
"outcome": "Task complete ✓",
"iterations": 4,
"history": [
{
"hat": "planner",
"event": "plan.start",
"iteration": 1
},
{
"hat": "builder",
"event": "tasks.ready",
"iteration": 2
},
{
"hat": "reviewer",
"event": "build.done",
"iteration": 3
},
{
"hat": "committer",
"event": "review.approved",
"iteration": 4
}
],
"iterationLogs": [
{
"iteration": 1,
"hatKey": "planner",
"hatName": "📋 Planner",
"event": "tasks.ready",
"summary": "Everything is properly wired up.\n\n---\n\n## ✅ Summary\n\n### What was done\n\nImplemented the `lsp_completions` tool per the spec in `specs/completions-tool.md`:\n\n**New file: `src/tools/completions.ts`** (~210 lines)\n- Takes `path`, `line`, `character`, and optional `limit` (default 20) parameters\n- Sends `textDocument/completion` to the LSP server\n- Normalizes response (handles both `CompletionList` and `CompletionItem[]`)\n- Sorts by LSP `sortText` ranking, takes top N items\n- Resolves items in parallel via `completionItem/resolve` (with 2s per-item timeout) for full documentation and signatures\n- Formats output as compact text: kind (padded), label+detail, doc summary (first 2 lines, 120 char cap)\n- Includes `renderCall`/`renderResult` for TUI display\n- Handles: no LSP server, server doesn't support completions, empty results, request failures\n\n**Modified: `src/index.ts`**\n- Added import and registration of `createCompletionsTool`\n- Updated header comment\n\n### Design decisions (from spec open questions)\n- **Skipped synthetic trigger** for v1 — inserting/reverting `.` through didChange risks race conditions with diagnostics. LLM can type `obj.` directly.\n- **Resolve all top N items** in parallel with 2s timeout per item — uses `Promise.allSettled` so failures fall back gracefully to unresolved items.\n- **No filter parameter** for v1 — LSP sort order + limit handles prioritization.\n- **No tree-sitter fallback** — completions fundamentally require type info (per spec non-goals).",
"timestamp": 1773683999660
},
{
"iteration": 2,
"hatKey": "builder",
"hatName": "⚙️ Builder",
"event": "build.done",
"summary": "## Summary\n\nImplemented the **synthetic trigger** feature for `lsp_completions` — the main missing piece from the spec. The tool already had solid core functionality (completions, resolve, formatting). Here's what I added:\n\n### Changes to `src/tools/completions.ts`:\n\n1. **`trigger` parameter** — `\"auto\"` (default) or `\"none\"`. When auto, the tool detects if the cursor is at the end of an identifier and temporarily inserts a `.` to trigger member completions without requiring the LLM to edit the file.\n\n2. **`shouldSyntheticTrigger()` helper** — checks if position is after an identifier char (`\\w`, `)`, `]`, `>`) and there's no dot already present.\n\n3. **`insertDot()` helper** — string manipulation to insert `.` at a position.\n\n4. **Modified execute flow** — insert dot → `didChange` to LSP → request completions → **always revert** in `finally` block → format results. Falls through gracefully on any failure.\n\n5. **Updated descriptions and render** — mentions synthetic trigger in tool description, promptSnippet, and output header.\n\nTypeScript compiles cleanly with `tsc --noEmit`. No changes needed to `index.ts` — the tool was already registered.",
"timestamp": 1773684274098
},
{
"iteration": 3,
"hatKey": "reviewer",
"hatName": "🔎 Reviewer",
"event": "review.approved",
"summary": "## 🔎 Review Complete\n\n### Spec Compliance: ✅ All requirements met\n\nThe implementation covers every feature in the spec: tool parameters, synthetic trigger, kind labels, doc summaries, sort/limit, parallel resolve with timeout, registration, and rendering.\n\n### Bug Found: Version Desync 🐛\n\n**The synthetic trigger sends `didChange` with hardcoded versions 99990/99991 directly to the LSP client, bypassing FileSync's version tracker.** After the revert, FileSync still thinks the document is at a low version (e.g. 3), but the LSP server expects > 99991. The next real edit will send version 4, which monotonic-version-enforcing LSP servers will reject — causing stale diagnostics and completions.\n\n**Recommended fix:** Add a `withTemporaryContent(uri, content, fn)` method to LspManager that coordinates version tracking with FileSync, keeping the completions tool clean and making temporary edits reusable.\n\n### Verdict: **→ Back to Builder** to fix the version desync before proceeding to commit.",
"timestamp": 1773684389440
}
]
}

View File

@@ -0,0 +1,57 @@
{
"id": "1773685339450-feature",
"presetName": "feature",
"prompt": "specs/structural-search-rewrite.md",
"startTime": 1773685339450,
"endTime": 1773686879376,
"outcome": "Task complete ✓",
"iterations": 4,
"history": [
{
"hat": "planner",
"event": "plan.start",
"iteration": 1
},
{
"hat": "builder",
"event": "tasks.ready",
"iteration": 2
},
{
"hat": "reviewer",
"event": "build.done",
"iteration": 3
},
{
"hat": "committer",
"event": "review.approved",
"iteration": 4
}
],
"iterationLogs": [
{
"iteration": 1,
"hatKey": "planner",
"hatName": "📋 Planner",
"event": "tasks.ready",
"summary": "---\n\n## Summary\n\n### 📋 Plan → 🔨 Build → 🔍 Review — Complete\n\nImplemented the **Structural Code Search & Rewrite** feature per `specs/structural-search-rewrite.md`.\n\n### New files (5):\n\n| File | Lines | Purpose |\n|------|-------|---------|\n| `src/tree-sitter/pattern-compiler.ts` | ~200 | Parses metavariable patterns (`$NAME`, `$$$NAME`) into matchable `PatternNode` trees. Handles wrapper contexts for expression/statement fragments. |\n| `src/tree-sitter/search-engine.ts` | ~290 | AST matching engine — walks target trees depth-first, matches against pattern with field-name-aware child matching. Collects files by language, caps at 5000 files. |\n| `src/tree-sitter/rewrite-engine.ts` | ~100 | Substitutes captured metavariable values into replacement templates. Applies changes bottom-up to preserve byte offsets. |\n| `src/tools/code-search.ts` | ~100 | `code_search` tool — pattern, language, path?, max_results?. Returns matches with file:line, matched text, and metavariable bindings. |\n| `src/tools/code-rewrite.ts` | ~140 | `code_rewrite` tool — pattern, replacement, language, path?, dry_run?(default true). Previews or applies structural transformations. |\n\n### Modified files (1):\n\n| File | Change |\n|------|--------|\n| `src/index.ts` | Added imports and `pi.registerTool()` calls for both new tools |\n\n### Key design decisions:\n\n1. **Custom AST matcher instead of tree-sitter queries** — tree-sitter's S-expression queries match by node type, not text content. A custom matcher lets us match literal text for concrete nodes and wildcard for metavariables, which is what users expect.\n\n2. **Field-name-aware child matching** — pattern children with field names (e.g., `function`, `arguments`, `name`) are matched against target children with the same field name. Extra target children (like TypeScript type annotations) are allowed. This prevents false negatives when the target language has more syntax than the pattern mentions.\n\n3. **Wrapper-based pattern parsing** — patterns that a",
"timestamp": 1773686181539
},
{
"iteration": 2,
"hatKey": "builder",
"hatName": "⚙️ Builder",
"event": "build.done",
"summary": "## Summary\n\nThe structural search & rewrite feature is fully implemented and tested. I found and fixed two bugs in the existing code:\n\n### Bug 1: Semicolon stripping in rewrites\n**`src/tree-sitter/rewrite-engine.ts`** — Added `preserveTrailingSemicolon()` so that `var x = 10;` → `const x = 10;` (not `const x = 10`). The matched AST node includes the `;` as part of the statement, and the rewrite engine now preserves it when the replacement template doesn't explicitly include one.\n\n### Bug 2: Pattern compilation fails for Python\n**`src/tree-sitter/pattern-compiler.ts`** — `$ARG` is not a valid Python identifier. Added **placeholder preprocessing**: before parsing, metavars are replaced with language-safe placeholders (`$NAME` → `__META_NAME__`, `$$$NAME` → `__VMETA_NAME__`), then decoded back after the AST is built. Also added Python-specific wrapper strategies (Python uses `module` as root, not `program`).\n\n### Test results\n- **`test-structural-search.ts`**: 52/52 passing ✅\n- **`test-tree-sitter.ts`**: 62/62 passing ✅ (no regressions)\n- **`tsc --noEmit`**: clean compile ✅",
"timestamp": 1773686551683
},
{
"iteration": 3,
"hatKey": "reviewer",
"hatName": "🔎 Reviewer",
"event": "review.approved",
"summary": "**Review complete — PASS ✅**. The implementation is solid and ready for the commit hat. Four minor non-blocking issues noted in the scratchpad for future improvement.",
"timestamp": 1773686779441
}
]
}

View File

@@ -0,0 +1,26 @@
# Ralph Scratchpad
Preset: feature
Task: specs/structural-search-rewrite.md
---
## Committer — Iteration 4
### Committed: `6fe71f1`
```
feat: add structural code search and rewrite tools
```
7 files changed, 1414 insertions:
- `src/tree-sitter/pattern-compiler.ts` — metavar placeholder encoding, language wrappers
- `src/tree-sitter/search-engine.ts` — recursive AST matcher, file collection
- `src/tree-sitter/rewrite-engine.ts` — capture substitution, bottom-up application
- `src/tools/code-search.ts` — search tool wrapper
- `src/tools/code-rewrite.ts` — rewrite tool wrapper with dry-run
- `src/index.ts` — tool registration
- `test-structural-search.ts` — 52 tests
### Status: ✅ DONE
All spec requirements met. 52 tests passing, tsc clean. Feature complete.

View File

@@ -0,0 +1,192 @@
# pi-lsp-extension
A [pi](https://github.com/mariozechner/pi-mono) coding agent extension that integrates Language Server Protocol (LSP) servers, giving the LLM access to the same language intelligence that powers your IDE.
## Tools
| Tool | Description |
|------|-------------|
| `lsp_diagnostics` | Compilation errors and warnings for a file |
| `lsp_hover` | Type information and documentation at a position |
| `lsp_definition` | Go to definition of a symbol |
| `lsp_references` | Find all references to a symbol |
| `lsp_symbols` | List file symbols or search workspace symbols |
| `lsp_rename` | Preview rename refactoring (returns planned edits) |
| `lsp_completions` | Code completion suggestions at a position |
| `code_overview` | Project structure, key files, and symbols (tree-sitter) |
| `code_search` | Find code by AST structure with metavariables |
| `code_rewrite` | Transform code matching structural patterns |
LSP servers start lazily — they only spin up when a tool is first used on a file of that language. For slow servers (e.g. jdtls), you can [auto-start them on session launch](#project-config).
## Auto-diagnostics
After a successful `write` or `edit`, if an LSP server is already running for that file type, the extension automatically appends compilation errors to the tool result. This gives the LLM immediate feedback without requiring a separate `lsp_diagnostics` call.
- Scoped to the single changed file (no workspace-wide noise)
- Only errors, max 10 lines — keeps context lean
- Only fires when a server is already running (no lazy startup)
## Installation
```bash
git clone https://github.com/samfoy/pi-lsp-extension.git
cd pi-lsp-extension
npm install
```
Add to your pi `settings.json`:
```json
{
"extensions": ["/path/to/pi-lsp-extension/src/index.ts"]
}
```
Or run directly:
```bash
pi -e /path/to/pi-lsp-extension/src/index.ts
```
## Supported Languages
Install the language server you need, then it works automatically:
| Language | Server | Install |
|----------|--------|---------|
| TypeScript/JavaScript | `typescript-language-server` | `npm i -g typescript-language-server typescript` |
| Python | `pyright-langserver` | `pip install pyright` |
| Rust | `rust-analyzer` | [rustup](https://rustup.rs/) |
| Go | `gopls` | `go install golang.org/x/tools/gopls@latest` |
| Java | `jdtls` | [Eclipse JDT.LS](https://github.com/eclipse-jdtls/eclipse.jdt.ls) |
Add more at runtime:
```
/lsp-config ruby solargraph stdio
/lsp-config lua lua-language-server
```
## Commands
| Command | Description |
|---------|-------------|
| `/lsp` | Show status of running LSP servers |
| `/lsp-restart <lang>` | Restart an LSP server (kills daemon, re-initializes) |
| `/lsp-config <lang> <cmd> [args]` | Configure a language server |
| `/lsp-lombok [path]` | Set Lombok jar path for Java (or show current) |
| `/bemol [run\|watch\|stop\|status]` | Manage bemol (Brazil workspaces) |
## How it Works
1. **Lazy startup** — servers start on first tool use for a file type (or eagerly via [`.pi-lsp.json`](#project-config))
2. **Tree-sitter fallback** — when no LSP server is running, tools like `lsp_diagnostics`, `lsp_hover`, `lsp_definition`, and `lsp_symbols` fall back to tree-sitter for syntax errors, signatures, and symbol extraction
3. **File sync** — pi's `read`/`write`/`edit` operations are automatically synced to the LSP via `didOpen`/`didChange`, with LRU eviction (`didClose`) after 100 tracked files
4. **Diagnostics cache** — the server pushes diagnostics asynchronously; tools read from a local cache
5. **Auto-diagnostics** — errors are appended to write/edit results when a server is running
6. **Shared daemons** — in supported workspaces, LSP servers run as background daemons shared across pi sessions
## Lombok Support (Java)
If your Java project uses [Lombok](https://projectlombok.org/), jdtls needs the Lombok agent jar to understand generated code. The extension resolves the jar in this order:
1. **`/lsp-lombok` command** — set the path at runtime:
```
/lsp-lombok /path/to/lombok.jar
```
2. **`LOMBOK_JAR` environment variable** — set before starting pi:
```bash
export LOMBOK_JAR=/path/to/lombok.jar
pi
```
3. **Auto-detection** — in Brazil workspaces, the extension searches `env/Lombok-*/runtime/lib/` and `env/gradle-cache-2/` automatically.
Run `/lsp-lombok` with no arguments to see which jar is currently configured.
## Project Config
Create a `.pi-lsp.json` file in your project root to configure LSP behavior per-project:
```json
{
"autoStart": ["java", "typescript"],
"lombokJar": "auto",
"autoInjectDiagnostics": ["typescript"],
"servers": {
"python": { "command": "pylsp", "args": [] }
}
}
```
| Field | Description |
|-------|-------------|
| `autoStart` | Array of language IDs to start eagerly on session launch. Servers begin initializing in the background immediately — no need to wait for the first tool call. Ideal for slow servers like `jdtls`. |
| `lombokJar` | Path to a Lombok jar (absolute or relative to project root), or `"auto"` to auto-detect in Brazil workspaces. Applied before auto-start so jdtls launches with the correct `-javaagent` flag. |
| `autoInjectDiagnostics` | Controls whether LSP errors are auto-appended to `write`/`edit` tool results. `true` (default) enables for all languages, `false` disables entirely, or pass an array of language IDs (e.g. `["typescript"]`) to enable selectively. Disable for Java/Brazil workspaces where Lombok and dependency-chain false positives create noise. |
| `servers` | Custom server configs keyed by language ID. Overrides the built-in defaults. Each entry has `command`, optional `args` (string array), and optional `env` (key-value pairs). |
The config file is loaded once at session start. Changes require restarting the pi session.
**Example for a Java Brazil workspace:**
```json
{
"autoStart": ["java"],
"lombokJar": "auto"
}
```
This triggers bemol + jdtls startup as soon as the session begins, so by the time you need `lsp_diagnostics` or `lsp_hover`, the server is already warm.
## Architecture
```
src/
├── index.ts # Extension entry point, .pi-lsp.json config loader
├── lsp-client.ts # JSON-RPC client (stdio + socket modes)
├── lsp-manager.ts # Server lifecycle, per-language instances
├── file-sync.ts # didOpen/didChange tracking
├── lsp-daemon.ts # Background daemon for shared servers
├── lsp-daemon-launcher.cjs
├── bemol.ts # Brazil workspace support
├── locks.ts # File-based locking for daemon coordination
├── resolve-provider.ts # LSP vs tree-sitter provider selection
├── shared/
│ ├── constants.ts # Skip dirs, file size limits
│ ├── debug.ts # Debug logger (PI_LSP_DEBUG=1)
│ ├── format.ts # Location formatting utilities
│ ├── language-map.ts # File extension → language ID mapping
│ └── timing.ts # Timing constants
├── tree-sitter/
│ ├── parser-manager.ts # WASM parser loading and caching
│ ├── pattern-compiler.ts # Metavariable pattern → AST matcher
│ ├── search-engine.ts # Structural search over files
│ ├── rewrite-engine.ts # Structural find-and-replace
│ ├── symbol-extractor.ts # Per-language symbol extraction
│ └── workspace-index.ts # Project-wide symbol index
└── tools/
├── diagnostics.ts
├── hover.ts
├── definition.ts
├── references.ts
├── symbols.ts
├── rename.ts
├── completions.ts
├── code-overview.ts
├── code-search.ts
└── code-rewrite.ts
```
## Tips
- Position parameters are 1-indexed (line 1, column 1 = first character)
- `lsp_rename` returns a preview — the LLM uses `edit`/`write` to apply changes
- Use `/lsp-restart java` after changing Lombok config — jdtls needs a full restart to pick up `-javaagent` changes
- Set `PI_LSP_DEBUG=1` to enable debug logging for troubleshooting
- The extension adds a system prompt guideline nudging the LLM to check diagnostics after edits
## License
MIT

View File

@@ -0,0 +1,303 @@
/**
* Bemol — Amazon's Brazil workspace → LSP bridge integration.
*
* Detects Brazil workspaces, runs bemol to generate LSP configs,
* manages a background bemol --watch process, and reads workspace
* root folders for multi-root LSP support.
*/
import { spawn, execSync, type ChildProcess } from "node:child_process";
import { existsSync, readFileSync, statSync } from "node:fs";
import { join, dirname } from "node:path";
import { pathToFileURL } from "node:url";
import { acquireLock, releaseLock, isLockedByOther } from "./locks.js";
export interface BemolStatus {
isBrazilWorkspace: boolean;
workspaceRoot: string | null;
bemolAvailable: boolean;
hasConfig: boolean;
watching: boolean;
workspaceRoots: string[];
}
export interface BemolRunResult {
success: boolean;
output: string;
duration: number;
}
export class BemolManager {
private watchProcess: ChildProcess | null = null;
private _workspaceRoot: string | null = null;
private _bemolAvailable: boolean | null = null;
private _bemolRan = false;
constructor(rootDir: string) {
this._workspaceRoot = BemolManager.findWorkspaceRoot(rootDir);
}
/** Whether the cwd is inside a Brazil workspace */
get isBrazilWorkspace(): boolean {
return this._workspaceRoot !== null;
}
/** The Brazil workspace root (directory containing packageInfo) */
get workspaceRoot(): string | null {
return this._workspaceRoot;
}
/** Whether bemol has already been run this session */
get bemolRan(): boolean {
return this._bemolRan;
}
/** Whether bemol --watch is running */
get isWatching(): boolean {
return this.watchProcess !== null && !this.watchProcess.killed;
}
/**
* Walk up from startDir looking for a `packageInfo` file.
* Returns the directory containing it, or null.
*/
static findWorkspaceRoot(startDir: string): string | null {
let dir = startDir;
// Walk up at most 20 levels to avoid infinite loops
for (let i = 0; i < 20; i++) {
const candidate = join(dir, "packageInfo");
try {
if (existsSync(candidate) && statSync(candidate).isFile()) {
return dir;
}
} catch {
// ignore
}
const parent = dirname(dir);
if (parent === dir) break; // reached filesystem root
dir = parent;
}
return null;
}
/** Check if bemol is available on PATH */
static isBemolAvailable(): boolean {
try {
execSync("which bemol", { stdio: "ignore" });
return true;
} catch {
return false;
}
}
/** Check if bemol is available (cached) */
get bemolAvailable(): boolean {
if (this._bemolAvailable === null) {
this._bemolAvailable = BemolManager.isBemolAvailable();
}
return this._bemolAvailable;
}
/** Check if .bemol/ws_root_folders exists */
hasConfig(): boolean {
if (!this._workspaceRoot) return false;
const foldersFile = join(this._workspaceRoot, ".bemol", "ws_root_folders");
return existsSync(foldersFile);
}
/**
* Read .bemol/ws_root_folders and return array of existing directory paths.
*/
getWorkspaceRoots(): string[] {
if (!this._workspaceRoot) return [];
const foldersFile = join(this._workspaceRoot, ".bemol", "ws_root_folders");
try {
const content = readFileSync(foldersFile, "utf-8");
return content
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
.filter((dir) => {
try {
return existsSync(dir) && statSync(dir).isDirectory();
} catch {
return false;
}
});
} catch {
return [];
}
}
/**
* Get workspace folders formatted for LSP InitializeParams.
* Returns array of { uri, name } objects.
*/
getWorkspaceFolders(): { uri: string; name: string }[] {
const roots = this.getWorkspaceRoots();
if (roots.length === 0) return [];
return roots.map((dir) => ({
uri: pathToFileURL(dir).toString(),
name: dir.split("/").pop() ?? "package",
}));
}
/**
* Run `bemol --verbose` in the workspace root.
* Returns result with success status, output, and duration.
*/
async runBemol(): Promise<BemolRunResult> {
if (!this._workspaceRoot) {
return { success: false, output: "Not in a Brazil workspace", duration: 0 };
}
if (!this.bemolAvailable) {
return { success: false, output: "bemol is not installed (try: toolbox install bemol)", duration: 0 };
}
const start = Date.now();
return new Promise((resolve) => {
const child = spawn("bemol", ["--verbose"], {
cwd: this._workspaceRoot!,
stdio: ["ignore", "pipe", "pipe"],
});
const chunks: Buffer[] = [];
child.stdout?.on("data", (data) => chunks.push(data));
child.stderr?.on("data", (data) => chunks.push(data));
// Timeout after 120s
const timer = setTimeout(() => {
child.kill("SIGTERM");
resolve({
success: false,
output: "bemol timed out after 120 seconds",
duration: Date.now() - start,
});
}, 120_000);
child.on("error", (err) => {
clearTimeout(timer);
resolve({
success: false,
output: `bemol failed to start: ${err.message}`,
duration: Date.now() - start,
});
});
child.on("close", (code) => {
clearTimeout(timer);
const output = Buffer.concat(chunks).toString();
this._bemolRan = true;
resolve({
success: code === 0,
output: output || (code === 0 ? "bemol completed successfully" : `bemol exited with code ${code}`),
duration: Date.now() - start,
});
});
});
}
/**
* Ensure bemol has been run at least once this session.
* If configs exist already, skips. If another session is running bemol,
* waits briefly then checks again. If not, runs bemol.
* Returns true if configs are available after this call.
*/
async ensureBemolConfig(sessionId?: string): Promise<boolean> {
if (!this.isBrazilWorkspace) return false;
if (this.hasConfig()) {
this._bemolRan = true;
return true;
}
if (this._bemolRan) return this.hasConfig();
if (!this.bemolAvailable) return false;
// Check if another session is already running bemol
if (this._workspaceRoot && isLockedByOther(this._workspaceRoot, "bemol")) {
// Wait up to 60s for the other session to finish
for (let i = 0; i < 12; i++) {
await new Promise((r) => setTimeout(r, 5000));
if (this.hasConfig()) {
this._bemolRan = true;
return true;
}
if (!isLockedByOther(this._workspaceRoot!, "bemol")) break;
}
// If config appeared, use it. Otherwise fall through to run our own.
if (this.hasConfig()) {
this._bemolRan = true;
return true;
}
}
// Acquire bemol lock
const sid = sessionId ?? `${process.pid}-${Date.now()}`;
if (this._workspaceRoot) {
acquireLock(this._workspaceRoot, "bemol", sid);
}
try {
const result = await this.runBemol();
return result.success && this.hasConfig();
} finally {
if (this._workspaceRoot) {
releaseLock(this._workspaceRoot, "bemol");
}
}
}
/**
* Start `bemol --watch` as a background process.
*/
startWatch(): boolean {
if (!this._workspaceRoot || !this.bemolAvailable) return false;
if (this.isWatching) return true; // already running
this.watchProcess = spawn("bemol", ["--watch"], {
cwd: this._workspaceRoot,
stdio: ["ignore", "ignore", "ignore"],
detached: false,
});
this.watchProcess.on("error", () => {
this.watchProcess = null;
});
this.watchProcess.on("exit", () => {
this.watchProcess = null;
});
return true;
}
/** Stop the background bemol --watch process */
stopWatch(): void {
if (this.watchProcess) {
this.watchProcess.kill("SIGTERM");
setTimeout(() => {
if (this.watchProcess && !this.watchProcess.killed) {
this.watchProcess.kill("SIGKILL");
}
}, 2000);
this.watchProcess = null;
}
}
/** Get full status summary */
getStatus(): BemolStatus {
return {
isBrazilWorkspace: this.isBrazilWorkspace,
workspaceRoot: this._workspaceRoot,
bemolAvailable: this.bemolAvailable,
hasConfig: this.hasConfig(),
watching: this.isWatching,
workspaceRoots: this.getWorkspaceRoots(),
};
}
/** Shutdown: stop watch, clean up */
shutdown(): void {
this.stopWatch();
}
}

View File

@@ -0,0 +1,195 @@
/**
* pi-lsp-bemol — Bemol workspace provider for pi-lsp-extension.
*
* Detects Amazon Brazil workspaces, runs bemol to generate LSP configs,
* and registers a WorkspaceProvider with the LSP extension via pi.events.
*
* Install: place in ~/.pi/agent/extensions/pi-lsp-bemol/ or load with pi -e
*
* Requires: bemol on PATH (toolbox install bemol)
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { join } from "node:path";
import { BemolManager } from "./bemol.js";
/**
* WorkspaceProvider interface — must match the one in pi-lsp-extension.
* The LSP extension accepts any object with this shape via pi.events.
*/
interface WorkspaceProvider {
readonly type: string;
readonly workspaceRoot: string | null;
readonly stateDir: string | null;
getWorkspaceFolders(): { uri: string; name: string }[];
ensureReady(sessionId?: string): Promise<boolean>;
getStatusText(): string;
shutdown(): void;
}
class BemolWorkspaceProvider implements WorkspaceProvider {
readonly type = "bemol";
readonly manager: BemolManager;
constructor(manager: BemolManager) {
this.manager = manager;
}
get workspaceRoot(): string | null {
return this.manager.workspaceRoot;
}
get stateDir(): string | null {
const root = this.manager.workspaceRoot;
return root ? join(root, ".bemol") : null;
}
getWorkspaceFolders(): { uri: string; name: string }[] {
return this.manager.getWorkspaceFolders();
}
async ensureReady(sessionId?: string): Promise<boolean> {
return this.manager.ensureBemolConfig(sessionId);
}
getStatusText(): string {
const hasConfig = this.manager.hasConfig();
const hasBemol = this.manager.bemolAvailable;
if (hasConfig) return "Brazil workspace (bemol config found)";
if (hasBemol) return "Brazil workspace (bemol will run on first LSP use)";
return "Brazil workspace (bemol not installed)";
}
shutdown(): void {
this.manager.shutdown();
}
}
export default function bemolExtension(pi: ExtensionAPI) {
// Detect and register at factory time (synchronous, before any session_start).
// This ensures the provider is available before autoStart kicks in.
const bemol = new BemolManager(process.cwd());
let provider: BemolWorkspaceProvider | null = null;
if (bemol.isBrazilWorkspace) {
provider = new BemolWorkspaceProvider(bemol);
// Store on event bus so the LSP extension can find it regardless of load order
(pi.events as any)["lsp:workspace-provider"] = provider;
pi.events.emit("lsp:register-workspace-provider", provider);
}
// Register /bemol command and update status once we have UI context
pi.on("session_start", async (_event, ctx) => {
if (!provider) return;
// Re-detect with ctx.cwd in case it differs from process.cwd()
if (ctx.cwd !== process.cwd()) {
const freshBemol = new BemolManager(ctx.cwd);
if (freshBemol.isBrazilWorkspace) {
provider = new BemolWorkspaceProvider(freshBemol);
pi.events.emit("lsp:register-workspace-provider", provider);
} else {
provider = null;
return;
}
}
pi.registerCommand("bemol", {
description: "Manage bemol: /bemol [run|watch|stop|status]",
handler: async (args, cmdCtx) => {
if (!provider) return;
const mgr = provider.manager;
const subcommand = args?.trim().toLowerCase() || "run";
switch (subcommand) {
case "run": {
if (!mgr.bemolAvailable) {
cmdCtx.ui.notify("bemol is not installed. Run: toolbox install bemol", "warning");
return;
}
cmdCtx.ui.setStatus("lsp", cmdCtx.ui.theme.fg("warning", "LSP: running bemol..."));
cmdCtx.ui.notify("Running bemol --verbose...", "info");
const result = await mgr.runBemol();
if (result.success) {
const roots = mgr.getWorkspaceRoots();
cmdCtx.ui.notify(
`bemol completed in ${(result.duration / 1000).toFixed(1)}s\n${roots.length} package root(s) configured`,
"info"
);
} else {
cmdCtx.ui.notify(`bemol failed:\n${result.output.slice(0, 500)}`, "error");
}
cmdCtx.ui.setStatus("lsp", cmdCtx.ui.theme.fg("accent", "LSP: Brazil workspace (bemol done)"));
break;
}
case "watch": {
if (!mgr.bemolAvailable) {
cmdCtx.ui.notify("bemol is not installed. Run: toolbox install bemol", "warning");
return;
}
if (mgr.isWatching) {
cmdCtx.ui.notify("bemol --watch is already running", "info");
return;
}
const started = mgr.startWatch();
if (started) {
cmdCtx.ui.notify("Started bemol --watch in background", "info");
cmdCtx.ui.setStatus("bemol", cmdCtx.ui.theme.fg("accent", "bemol: watching"));
} else {
cmdCtx.ui.notify("Failed to start bemol --watch", "error");
}
break;
}
case "stop": {
if (!mgr.isWatching) {
cmdCtx.ui.notify("bemol --watch is not running", "info");
return;
}
mgr.stopWatch();
cmdCtx.ui.notify("Stopped bemol --watch", "info");
cmdCtx.ui.setStatus("bemol", "");
break;
}
case "status": {
const status = mgr.getStatus();
const lines = [
`Brazil workspace: ${status.isBrazilWorkspace ? "yes" : "no"}`,
`Workspace root: ${status.workspaceRoot ?? "N/A"}`,
`bemol available: ${status.bemolAvailable ? "yes" : "no"}`,
`bemol config: ${status.hasConfig ? "yes" : "missing"}`,
`bemol watch: ${status.watching ? "running" : "stopped"}`,
`Package roots: ${status.workspaceRoots.length}`,
];
if (status.workspaceRoots.length > 0) {
const shown = status.workspaceRoots.slice(0, 10);
for (const root of shown) {
lines.push(` ${root}`);
}
if (status.workspaceRoots.length > 10) {
lines.push(` ... and ${status.workspaceRoots.length - 10} more`);
}
}
cmdCtx.ui.notify(lines.join("\n"), "info");
break;
}
default:
cmdCtx.ui.notify(
"Usage: /bemol [run|watch|stop|status]\n run — run bemol --verbose\n watch — start bemol --watch\n stop — stop bemol --watch\n status — show bemol status",
"info"
);
}
},
});
});
pi.on("session_shutdown", async () => {
if (provider) {
provider.shutdown();
provider = null;
}
});
}

View File

@@ -0,0 +1,144 @@
/**
* Workspace Lock — cross-session coordination for bemol runs.
*
* Uses PID-based lockfiles in `.bemol/locks/` to prevent multiple pi sessions
* from running bemol simultaneously in the same workspace.
*/
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync, openSync, closeSync, statSync, constants as fsConstants } from "node:fs";
import { join } from "node:path";
export interface LockInfo {
pid: number;
startTime: number;
sessionId: string;
}
/**
* Check if a process is still alive.
*/
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
const STALE_LOCK_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
/**
* Try to acquire a named lock for this workspace.
* Uses O_CREAT|O_EXCL for atomic creation to avoid TOCTOU races.
* Returns true if acquired, false if another live session holds it.
*/
export function acquireLock(workspaceRoot: string, name: string, sessionId: string): boolean {
const locksDir = join(workspaceRoot, ".bemol", "locks");
const lockFile = join(locksDir, `${name}.lock`);
// Check existing lock — clean up stale locks from dead processes or old age
const existing = readLock(workspaceRoot, name);
if (existing && existing.pid !== process.pid && isProcessAlive(existing.pid)) {
// Process is alive, but check if the lock is older than 5 minutes (stale)
try {
const stat = statSync(lockFile);
const ageMs = Date.now() - stat.mtimeMs;
if (ageMs > STALE_LOCK_THRESHOLD_MS) {
// Lock is stale — remove it even though the process is alive
try { unlinkSync(lockFile); } catch { /* ignore */ }
} else {
return false; // another live session holds a fresh lock
}
} catch {
return false; // can't stat, assume lock is valid
}
}
// If stale lock exists, remove it first
if (existing) {
try { unlinkSync(lockFile); } catch { /* ignore */ }
}
// Acquire atomically using O_CREAT|O_EXCL (fails if file already exists)
try {
mkdirSync(locksDir, { recursive: true });
const info: LockInfo = { pid: process.pid, startTime: Date.now(), sessionId };
const content = JSON.stringify(info);
// O_WRONLY | O_CREAT | O_EXCL — atomic: fails with EEXIST if another process created it first
const fd = openSync(lockFile, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o644);
try {
writeFileSync(fd, content);
} finally {
closeSync(fd);
}
return true;
} catch (err: any) {
if (err.code === "EEXIST") {
// Another process acquired the lock between our unlink and open — that's fine
return false;
}
return false;
}
}
/**
* Release a lock owned by this process.
*/
export function releaseLock(workspaceRoot: string, name: string): void {
const lockFile = join(workspaceRoot, ".bemol", "locks", `${name}.lock`);
try {
const existing = readLock(workspaceRoot, name);
// Only delete if we own it
if (existing && existing.pid === process.pid) {
unlinkSync(lockFile);
}
} catch {
// ignore
}
}
/**
* Read the current lock holder, or null if no lock / stale lock.
*/
export function readLock(workspaceRoot: string, name: string): LockInfo | null {
const lockFile = join(workspaceRoot, ".bemol", "locks", `${name}.lock`);
try {
if (!existsSync(lockFile)) return null;
const content = readFileSync(lockFile, "utf-8");
const info = JSON.parse(content) as LockInfo;
if (!info.pid || !info.startTime) return null;
return info;
} catch {
return null;
}
}
/**
* Check if a lock is held by another live process (not us).
*/
export function isLockedByOther(workspaceRoot: string, name: string): boolean {
const info = readLock(workspaceRoot, name);
if (!info) return false;
if (info.pid === process.pid) return false;
return isProcessAlive(info.pid);
}
/**
* Release all locks owned by this process in the workspace.
*/
export function releaseAllLocks(workspaceRoot: string): void {
const locksDir = join(workspaceRoot, ".bemol", "locks");
try {
if (!existsSync(locksDir)) return;
const files: string[] = readdirSync(locksDir);
for (const file of files) {
if (!file.endsWith(".lock")) continue;
const name = file.replace(/\.lock$/, "");
releaseLock(workspaceRoot, name);
}
} catch {
// ignore
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
{
"name": "pi-lsp-extension",
"version": "1.3.0",
"type": "module",
"description": "Pi coding agent extension for LSP (Language Server Protocol) integration",
"keywords": [
"pi-package",
"extension"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/samfoy/pi-lsp-extension"
},
"scripts": {
"clean": "echo 'nothing to clean'",
"build": "echo 'nothing to build'",
"check": "echo 'nothing to check'"
},
"pi": {
"extensions": [
"./src/index.ts"
]
},
"dependencies": {
"tree-sitter-wasms": "^0.1.13",
"vscode-languageserver-protocol": "^3.17.5",
"web-tree-sitter": "^0.24.7"
},
"peerDependencies": {
"@earendil-works/pi-ai": "*",
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*",
"@sinclair/typebox": "*"
},
"devDependencies": {
"typescript": "^5.5.0"
}
}

View File

@@ -0,0 +1,180 @@
# Code Actions Tool
## Summary
Add an `lsp_code_actions` tool that retrieves available code actions (quick fixes, refactorings, source actions) for a position or range in a file. This lets the LLM discover and apply IDE-level fixes — auto-imports, extract method, fix lint errors — that would otherwise require manual knowledge of the codebase.
## Motivation
When the LLM encounters a diagnostic error (e.g., "Cannot find name 'Foo'"), it currently has to reason about the fix from scratch. But the LSP server often already knows the fix — "Add import from './foo'" — via code actions. Exposing these saves time and reduces hallucination.
The `lsp-pi` competitor exposes `codeAction` as one of its actions and it covers:
- Quick fixes (auto-import, remove unused variable, fix typo)
- Refactorings (extract method, extract variable, inline variable)
- Source actions (organize imports, add missing members)
## Goals
- **`lsp_code_actions` tool** — return available actions for a position or diagnostic range
- **Diagnostic-aware** — when called with a position that has a diagnostic, include the diagnostic context so the server returns relevant fixes
- **Actionable output** — format actions so the LLM knows what each one does and can apply the edits
- **Preferred actions** — highlight preferred/auto-fix actions (the server marks these)
## Non-goals
- Executing code actions automatically — the tool returns the action list and their edits; the LLM uses `edit`/`write` to apply
- Command-based actions — some code actions return a `command` instead of `edit`. We report these but can't execute them (they require IDE integration)
- Resolving lazy actions — `codeAction/resolve` for actions that defer their edit computation. This can be a follow-up if needed.
## Design
### Tool definition
**`lsp_code_actions`**
| Parameter | Type | Description |
|-----------|------|-------------|
| `path` | string | File path |
| `line` | number | Line number (1-indexed) |
| `character` | number | Column number (1-indexed) |
| `endLine` | number? | End line for range selection (1-indexed). Defaults to `line`. |
| `endCharacter` | number? | End column for range selection (1-indexed). Defaults to `character`. |
| `kind` | string? | Filter by action kind (e.g., `"quickfix"`, `"refactor"`, `"source"`) |
### LSP request
```typescript
// Collect diagnostics at the position to include as context
const uri = manager.getFileUri(filePath);
const allDiags = client.getDiagnostics(uri);
const rangeDiags = allDiags.filter(d => rangeContainsPosition(d.range, line, character));
const result = await client.sendRequest<CodeAction[]>("textDocument/codeAction", {
textDocument: { uri },
range: {
start: { line: params.line - 1, character: params.character - 1 },
end: { line: (params.endLine ?? params.line) - 1, character: (params.endCharacter ?? params.character) - 1 },
},
context: {
diagnostics: rangeDiags,
only: params.kind ? [params.kind] : undefined,
},
});
```
Including matching diagnostics in the `context` is critical — many servers (jdtls, pyright, typescript-language-server) only return quick fixes when the relevant diagnostic is in the context.
### Output format
```
5 code actions at src/handler.ts:42:10
1. ★ Add import from './types' [quickfix]
Edits: src/handler.ts:1:1 insert "import { Foo } from './types';\n"
2. ★ Remove unused variable 'x' [quickfix]
Edits: src/handler.ts:42:3-42:15 delete
3. Extract to function in module scope [refactor.extract]
Edits: (resolve required)
4. Convert to arrow function [refactor.rewrite]
Edits: src/handler.ts:40:1-45:2 replace
5. Organize imports [source.organizeImports]
Edits: (resolve required)
```
Key formatting:
- **★** marks preferred actions (`isPreferred: true`) — these are the auto-fix candidates
- **Kind** shown in brackets — helps the LLM filter mentally
- **Edits** shown inline if the action includes a `WorkspaceEdit`. Format matches `lsp_rename` output
- **"resolve required"** for actions that have no edit but have a `data` field (need `codeAction/resolve`)
- Actions are sorted: preferred first, then by kind (quickfix → refactor → source → other)
### Details object
```typescript
interface CodeActionsDetails {
count: number;
preferredCount: number;
actions: Array<{
title: string;
kind?: string;
isPreferred: boolean;
hasEdit: boolean;
hasCommand: boolean;
}>;
}
```
### TUI rendering
**`renderCall`:**
```
lsp_code_actions src/handler.ts:42:10
```
With kind filter:
```
lsp_code_actions src/handler.ts:42:10 [quickfix]
```
With range:
```
lsp_code_actions src/handler.ts:42:10-45:2
```
**`renderResult` (collapsed):**
```
5 actions (2 preferred)
```
Or if no actions:
```
No code actions available
```
### Handling edge cases
- **No actions available** — return "No code actions available at this position."
- **Command-only actions** — report the title and note that it requires IDE execution: "This action requires IDE execution and cannot be applied via edit/write."
- **Large edit sets** — truncate edits to first 20 per action, note remaining count
- **No LSP server** — return standard unavailable reason
- **Server doesn't support code actions** — check `capabilities.codeActionProvider` and return a clear message
### File layout
```
src/tools/
└── code-actions.ts
```
### Integration
- Register in `index.ts` alongside existing tools
- Add to system prompt: "When `lsp_diagnostics` shows errors, try `lsp_code_actions` at the error position to find available quick fixes before writing a manual fix."
- The workflow is: `lsp_diagnostics` → see error → `lsp_code_actions` at error position → find auto-import fix → apply with `edit`
### Utility: `rangeContainsPosition`
Add a shared helper in `src/shared/format.ts`:
```typescript
export function rangeContainsPosition(
range: Range,
line: number, // 0-indexed
character: number, // 0-indexed
): boolean {
if (line < range.start.line || line > range.end.line) return false;
if (line === range.start.line && character < range.start.character) return false;
if (line === range.end.line && character > range.end.character) return false;
return true;
}
```
## Open questions
- **`codeAction/resolve`** — should we automatically resolve actions that have `data` but no `edit`? This adds a round-trip per action but gives the LLM the actual edits. Could be gated: resolve only preferred actions, or only when the action count is small (< 5).
- **Applying actions** — should the tool have an `apply` mode that applies a specific action's edits directly? Pro: simpler workflow. Con: the LLM can already use the edit tool with the provided edit locations. Lean towards keeping it read-only for v1, matching how `lsp_rename` works.
- **Interaction with diagnostics auto-append** — when the auto-diagnostics hook fires after a write/edit and shows errors, should it also hint "try lsp_code_actions at line X for quick fixes"? Potentially noisy.

View File

@@ -0,0 +1,102 @@
# Completions Tool
## Summary
Add an `lsp_completions` tool that returns completion suggestions at a given position in a file. This lets the LLM discover available methods, properties, and APIs on objects without having to read documentation or source code.
## Motivation
When writing code, the LLM often needs to know what methods are available on an object — e.g., "what can I call on `s3Client`?" or "what fields does this struct have?". Today it either guesses (risking hallucination) or has to read the source/type definitions manually.
LSP completion is exactly this: given a cursor position, the language server returns ranked suggestions with type signatures and documentation. Kiro CLI exposes this and it's one of their differentiators.
## Goals
- **`lsp_completions` tool** — return completion items at a position, with type signatures and docs
- **Smart triggering** — the LLM can ask "what methods are available on X" and get actionable results
- **Concise output** — return the top N results (default 20) with kind, label, type, and doc summary — not the full verbose LSP response
## Non-goals
- Snippet expansion or tab-completion UX — this is a query tool, not an interactive completion engine
- Commit characters, edit ranges, or other IDE-specific completion metadata
- Tree-sitter fallback — completions fundamentally require type information, so this is LSP-only
## Design
### Tool definition
**`lsp_completions`**
| Parameter | Type | Description |
|-----------|------|-------------|
| `path` | string | File path |
| `line` | number | Line number (1-indexed) |
| `character` | number | Column number (1-indexed) |
| `limit` | number? | Max results to return (default: 20) |
### Trigger workflow
The LLM typically uses this in one of two ways:
1. **Explore an API**: Write `s3Client.` in a file, then call `lsp_completions` at the position after the dot to see available methods.
2. **Verify a method exists**: Before calling `response.bodyAsString()`, check completions on `response.` to confirm the method name.
In both cases the file needs to be open in the LSP (handled by `FileSync`) and the content needs to reflect the current state.
### Synthetic trigger
A common case is "what methods does X have?" where X is already in the code but there's no trailing dot. The tool should support a convenience mode:
- If the position points to the end of an identifier, the tool temporarily inserts a `.` after it, requests completions, then removes it. This avoids forcing the LLM to edit the file just to explore an API.
- This is optional and gated by a `trigger` parameter (`"auto"` | `"none"`, default `"auto"`).
### Output format
```
20 completions at src/handler.ts:42:15
method getObject(params: GetObjectRequest): Promise<GetObjectOutput>
Retrieves an object from S3.
method putObject(params: PutObjectRequest): Promise<PutObjectOutput>
Uploads an object to S3.
property region: string
The AWS region for this client.
...
```
Each item shows:
- **Kind** — method, property, function, variable, class, keyword, etc.
- **Label + signature** — from `detail` or `labelDetails` in the LSP response
- **Documentation** — first 1-2 lines of the doc comment, if available
Items are sorted by the LSP server's `sortText` ranking (which considers scope, type match, and usage frequency).
### Resolve for details
Many LSP servers return minimal items in the initial response and require a `completionItem/resolve` call for documentation and full signatures. The tool should:
1. Request completions at the position
2. For the top N items, call `completionItem/resolve` in parallel to get full details
3. Merge results and format output
This adds latency but dramatically improves output quality. Cap resolve calls to the `limit` parameter to bound cost.
### File layout
```
src/tools/
└── completions.ts
```
### Integration
- Register in `index.ts` alongside existing tools
- Add to the extension's system prompt snippet: mention that `lsp_completions` is available for discovering methods and properties
- No tree-sitter fallback — return a clear message if no LSP server is running
## Open questions
- **Synthetic trigger safety** — temporarily modifying file content to insert a `.` could cause issues if the LSP processes the change and emits diagnostics before we revert. Should we use a separate virtual document, or is the insert-request-revert cycle fast enough to be safe?
- **Performance** — `completionItem/resolve` for 20 items could be slow on some servers. Should we resolve lazily (only top 5) or make it configurable?
- **Filter parameter** — should the tool accept a `filter` string to pre-filter results (e.g., only methods, only properties)? Or is that over-engineering for v1?

View File

@@ -0,0 +1,152 @@
# Custom TUI Rendering
## Summary
Add `renderCall` and `renderResult` methods to the two tools that currently lack them (`code_search` and `code_rewrite`), bringing all tools to parity with the rendering quality of the LSP tools.
## Motivation
The `lsp-pi` competitor has polished TUI rendering for its unified `lsp` tool — themed headers, collapsible result previews, match counts. Our LSP tools already have good rendering (hover, diagnostics, rename, etc.), but `code_search` and `code_rewrite` fall back to the default pi rendering which dumps raw JSON-ish output.
These are high-visibility tools — structural search results and rewrite previews benefit significantly from custom formatting.
## Goals
- **`code_search`**: show a compact one-liner for the call (pattern + language + scope), and a results view with match counts, file grouping, and highlighted captured metavariables
- **`code_rewrite`**: show the call as pattern → replacement, and results as a diff-style before/after preview with file counts
- **Consistent style** — match the rendering patterns used by the existing LSP tools (themed labels, accent colors for paths, dim for metadata, success/error for status)
## Non-goals
- Interactive expansion/collapse of individual matches — pi's TUI doesn't support that level of interactivity in tool results
- Syntax highlighting of matched code — theme colors only (muted, accent, etc.), not language-aware highlighting
## Design
### `code_search` rendering
#### `renderCall`
```
code_search console.log($ARG) typescript src/
```
Format: `{toolTitle bold} {pattern accent} {language dim} {path dim}`
If no path is given, omit it (workspace root is the default).
#### `renderResult`
**Collapsed (default):**
```
23 matches in 8 files
```
Use `success` color if matches found, `dim` if zero matches.
**Expanded:**
```
23 matches in 8 files
src/handler.ts:
42:5 console.log(response) $ARG = response
67:3 console.log("done") $ARG = "done"
src/utils.ts:
12:1 console.log(err.message) $ARG = err.message
...
```
Group by file, show line:col, matched text (truncated to ~60 chars), and metavariable bindings. Cap at 20 matches in collapsed view, show all in expanded.
### `code_rewrite` rendering
#### `renderCall`
```
code_rewrite var $N = $V → const $N = $V javascript [dry-run]
```
Format: `{toolTitle bold} {pattern accent} → {replacement accent} {language dim} {mode dim}`
Show `[dry-run]` or `[apply]` based on the `dry_run` parameter.
#### `renderResult`
**Collapsed (default):**
```
12 replacements in 5 files (dry-run)
```
Or after apply:
```
✓ 12 replacements applied in 5 files
```
**Expanded:**
```
12 replacements in 5 files (dry-run)
src/handler.ts:
42: - var count = 0
+ const count = 0
67: - var name = "foo"
+ const name = "foo"
src/utils.ts:
12: - var x = arr.length
+ const x = arr.length
...
```
Use `error` color for `-` lines, `success` color for `+` lines. Cap preview at 10 replacements collapsed, all in expanded.
### Implementation
Both tools already return structured `details` objects — the rendering just needs to read from them.
#### `code_search` details shape (existing)
```typescript
interface CodeSearchDetails {
matchCount: number;
fileCount: number;
matches: Array<{
file: string;
line: number;
column: number;
text: string;
captures: Record<string, string>;
}>;
}
```
#### `code_rewrite` details shape (existing)
```typescript
interface CodeRewriteDetails {
replacementCount: number;
fileCount: number;
dryRun: boolean;
replacements: Array<{
file: string;
line: number;
before: string;
after: string;
}>;
}
```
If the current details shapes don't have all these fields, extend them as needed.
### File changes
- `src/tools/code-search.ts` — add `renderCall` and `renderResult` methods
- `src/tools/code-rewrite.ts` — add `renderCall` and `renderResult` methods
No new files needed.
## Open questions
- **Theme tokens** — the existing tools use `toolTitle`, `accent`, `dim`, `muted`, `success`, `error`, `warning`. Are there additional tokens available for diff-style rendering (e.g., `added`, `removed`)?
- **Expanded state** — does pi pass `expanded` in the `renderResult` options? Need to verify the `RenderResultOptions` type. The diagnostics tool already checks `options.expanded`.

View File

@@ -0,0 +1,249 @@
# Query-Based Position Resolution
## Summary
Add a `query` parameter to position-based tools (`lsp_hover`, `lsp_definition`, `lsp_references`, `lsp_rename`, `lsp_signature_help`, `lsp_code_actions`) that resolves a symbol name to a file position, eliminating the need for the LLM to know exact line/column numbers when it just wants to query a known symbol.
## Motivation
Today, to hover over a function named `handleRequest`, the LLM must:
1. Know or look up the exact line and column where `handleRequest` is defined or used
2. Call `lsp_hover` with `path`, `line`, `character`
This is friction. The LLM often knows the symbol name but not the exact position. It has to call `lsp_symbols` first to find the position, then call the actual tool — two round-trips.
The `lsp-pi` competitor solves this with a `query` parameter: pass `query: "handleRequest"` and it resolves the position via document symbols before making the LSP request. This is a significant UX improvement.
## Goals
- **Optional `query` parameter** on all position-based tools
- **Symbol name resolution** — find the symbol's position in the file via document symbols (LSP) or tree-sitter
- **Fallback chain** — try LSP document symbols first, then tree-sitter symbol extraction
- **Exact > partial** — exact name match wins over substring match
- **Transparent** — when `query` is used, include the resolved position in the output so the LLM knows where it landed
## Non-goals
- Workspace-wide symbol search via query — this is file-scoped resolution. Use `lsp_symbols` with a `query` for workspace search.
- Fuzzy matching — exact and substring only. Fuzzy introduces ambiguity.
- Replacing `line`/`character` — query is an alternative, not a replacement. When both are provided, `line`/`character` take precedence.
## Design
### Parameter changes
Add to all position-based tool schemas:
```typescript
query: Type.Optional(Type.String({
description: "Symbol name to find in the file. Alternative to line/character — resolves the symbol's position automatically."
})),
```
Tools that gain the `query` parameter:
- `lsp_hover`
- `lsp_definition`
- `lsp_references`
- `lsp_rename`
- `lsp_signature_help` (new tool)
- `lsp_code_actions` (new tool)
### Resolution logic
Create a shared resolver in `src/shared/resolve-position.ts`:
```typescript
export interface ResolvedPosition {
line: number; // 0-indexed (LSP convention)
character: number; // 0-indexed
symbolName: string; // the matched symbol name
source: "lsp" | "tree-sitter";
}
export async function resolveSymbolPosition(
filePath: string,
query: string,
manager: LspManager,
treeSitter?: TreeSitterManager | null,
): Promise<ResolvedPosition | null> {
// 1. Try LSP document symbols
const client = manager.getRunningClient(manager.getLanguageId(filePath) ?? "");
if (client) {
const uri = manager.getFileUri(filePath);
try {
const symbols = await client.sendRequest<DocumentSymbol[]>(
"textDocument/documentSymbol",
{ textDocument: { uri } }
);
const match = findSymbolPosition(symbols, query);
if (match) return { ...match, source: "lsp" };
} catch { /* fall through */ }
}
// 2. Try tree-sitter
if (treeSitter) {
const absPath = manager.resolvePath(filePath);
const content = await readFile(absPath, "utf-8");
const tree = await treeSitter.parse(absPath, content);
if (tree) {
const match = findSymbolInTree(tree, query);
if (match) return { ...match, source: "tree-sitter" };
}
}
return null;
}
```
### Symbol matching (`findSymbolPosition`)
Walks the document symbol tree (recursive for children) and matches:
1. **Exact match**`symbol.name === query` (case-sensitive)
2. **Case-insensitive exact**`symbol.name.toLowerCase() === query.toLowerCase()`
3. **Substring match**`symbol.name.toLowerCase().includes(query.toLowerCase())`
Returns the first match in priority order. Uses `selectionRange.start` (preferred) or `range.start` for position.
### Tree-sitter symbol matching (`findSymbolInTree`)
Uses the existing `extractSymbols` function from `symbol-extractor.ts`:
1. Extract all symbols from the tree
2. Match using the same priority chain as above
3. Return the symbol's start position
### Tool integration pattern
Each tool's `execute` function gains a preamble:
```typescript
async execute(_toolCallId, params) {
const filePath = params.path.replace(/^@/, "");
let line = params.line;
let character = params.character;
let resolvedFrom: string | undefined;
// Resolve position from query if line/character not provided
if ((line === undefined || character === undefined) && params.query) {
const resolved = await resolveSymbolPosition(filePath, params.query, manager, treeSitter);
if (resolved) {
line = resolved.line + 1; // convert to 1-indexed
character = resolved.character + 1;
resolvedFrom = `Resolved "${params.query}" → ${line}:${character} [${resolved.source}]`;
} else {
return {
content: [{ type: "text", text: `Could not find symbol "${params.query}" in ${filePath}` }],
details: { hasResult: false },
};
}
}
if (line === undefined || character === undefined) {
return {
content: [{ type: "text", text: "Either line/character or query is required." }],
details: { hasResult: false },
};
}
// ... existing tool logic using line/character ...
// Prepend resolved position to output if query was used
if (resolvedFrom) {
const existingText = result.content[0]?.text ?? "";
result.content[0] = { type: "text", text: `${resolvedFrom}\n\n${existingText}` };
}
return result;
}
```
### Validation rules
- If both `line`/`character` and `query` are provided: use `line`/`character`, ignore `query`
- If neither is provided: return error "Either line/character or query is required."
- If `query` is provided but doesn't match any symbol: return error with the file's top-level symbol names as a hint
- `line` and `character` remain individually required when `query` is not used (can't provide just `line` without `character`)
### Parameter schema update
The `line` and `character` parameters change from required to optional:
```typescript
// Before
line: Type.Number({ description: "Line number (1-indexed)" }),
character: Type.Number({ description: "Column number (1-indexed)" }),
// After
line: Type.Optional(Type.Number({ description: "Line number (1-indexed). Required unless query is provided." })),
character: Type.Optional(Type.Number({ description: "Column number (1-indexed). Required unless query is provided." })),
query: Type.Optional(Type.String({ description: "Symbol name to find in the file. Alternative to line/character." })),
```
### `renderCall` updates
When query is used instead of line/character:
```
lsp_hover src/handler.ts query="handleRequest"
```
When line/character is used (unchanged):
```
lsp_hover src/handler.ts:42:10
```
### System prompt update
Add a guideline:
```
Position-based LSP tools (lsp_hover, lsp_definition, lsp_references, lsp_rename, lsp_signature_help, lsp_code_actions) accept either line/character or a query parameter. Use query when you know the symbol name but not the exact position — it resolves the position automatically via document symbols.
```
### File layout
```
src/shared/
└── resolve-position.ts # New: shared position resolver
src/tools/
├── hover.ts # Modified: add query param
├── definition.ts # Modified: add query param
├── references.ts # Modified: add query param
├── rename.ts # Modified: add query param
├── signature-help.ts # New tool: includes query param
└── code-actions.ts # New tool: includes query param
```
## Examples
### Hover by symbol name
```
lsp_hover path="src/handler.ts" query="handleRequest"
→ Resolved "handleRequest" → 42:10 [lsp]
→ function handleRequest(req: Request): Promise<Response>
```
### Find references by name
```
lsp_references path="src/types.ts" query="UserConfig"
→ Resolved "UserConfig" → 15:14 [tree-sitter]
→ 12 references in 5 files
```
### Rename by name
```
lsp_rename path="src/utils.ts" query="formatDate" newName="formatDateTime"
→ Resolved "formatDate" → 8:17 [lsp]
→ Rename "formatDateTime": 23 edit(s) across 7 file(s)
```
## Open questions
- **Ambiguity** — what if a file has multiple symbols with the same name (e.g., overloaded methods, or a variable and a type with the same name)? Current design returns the first exact match. Should we return all matches and let the LLM pick? Or is first-match good enough?
- **Nested symbols** — `findSymbolPosition` walks children recursively, so `query="render"` could match a top-level `render` function or a `render` method inside a class. Should class-qualified names be supported (e.g., `query="MyComponent.render"`)?
- **Performance** — requesting document symbols adds a round-trip before the actual request. For LSP, this is typically fast (<50ms). For tree-sitter, it's a file parse. Acceptable for the ergonomic benefit.
- **`lsp_completions`** — should completions also support query? Less clear — completions are about a cursor position in code, not a symbol name. Leaving out for now.

View File

@@ -0,0 +1,126 @@
# Signature Help Tool
## Summary
Add an `lsp_signature_help` tool that returns function/method signature information at a call site — parameter names, types, documentation, and which parameter is currently active. This complements `lsp_hover` (which shows type info for a symbol) and `lsp_completions` (which lists available members).
## Motivation
When the LLM is writing a function call with multiple parameters, it needs to know the parameter order, types, and what each parameter does. Today it either:
1. Calls `lsp_hover` on the function name — gets the full signature but no active-parameter context
2. Reads the source/docs — expensive and slow
3. Guesses — risks hallucination
LSP's `textDocument/signatureHelp` is designed exactly for this: given a cursor position inside a call's argument list, it returns the signature with the active parameter highlighted. The `lsp-pi` competitor already exposes this as its `signature` action.
## Goals
- **`lsp_signature_help` tool** — return the active signature, parameters, and documentation at a call site position
- **Active parameter highlighting** — clearly indicate which parameter the cursor is on
- **Multiple overloads** — when a function has overloads, show the active one and list alternatives
- **Clean output** — format for LLM consumption (not raw LSP JSON)
## Non-goals
- Trigger character detection (e.g., auto-invoke on `(` or `,`) — this is a query tool, not an IDE feature
- Tree-sitter fallback — signature help fundamentally requires type resolution; no useful fallback possible
- Retrigger logic — the LSP retrigger context is for interactive editors; we do a single request
## Design
### Tool definition
**`lsp_signature_help`**
| Parameter | Type | Description |
|-----------|------|-------------|
| `path` | string | File path |
| `line` | number | Line number (1-indexed) |
| `character` | number | Column number (1-indexed) |
No `query` parameter — signature help is inherently position-based (cursor must be inside the argument list).
### LSP request
```typescript
const result = await client.sendRequest<SignatureHelp | null>("textDocument/signatureHelp", {
textDocument: { uri },
position: { line: params.line - 1, character: params.character - 1 },
});
```
### Output format
```
Signature 1 of 2 (active):
createReadStream(path: PathLike, options?: ReadStreamOptions): ReadStream
Parameters:
→ path: PathLike — The path to the file to read
options?: ReadStreamOptions — Options for the stream (encoding, start, end, highWaterMark)
Returns: ReadStream
Signature 2 of 2:
createReadStream(path: PathLike, encoding: BufferEncoding): ReadStream
```
Key formatting rules:
- Show all signatures, mark the active one (`activeSignature` index from LSP response)
- For the active signature, show all parameters with the active one marked with `→`
- Include parameter documentation if available (from `SignatureInformation.parameters[].documentation`)
- Include signature-level documentation if available (from `SignatureInformation.documentation`)
- Truncate long documentation to 2 lines per parameter
### Handling edge cases
- **No signature help available** — return "No signature help available at this position. Cursor must be inside a function call's argument list."
- **Empty parameters** — show signature line but skip the parameters section
- **Missing documentation** — show parameters with types only, skip doc lines
- **Multiple overloads** — show all, sorted by the `activeSignature` first
- **No LSP server running** — return the standard unavailable reason message
### Details object
```typescript
interface SignatureHelpDetails {
hasResult: boolean;
signatureCount: number;
activeSignature: number;
activeParameter: number;
}
```
### TUI rendering
**`renderCall`:**
```
lsp_signature_help src/handler.ts:42:15
```
**`renderResult` (collapsed):**
```
createReadStream(path, options?) — param 2 of 2
```
Show the function name and active parameter position. If no result, show "No signature help" in dim.
### File layout
```
src/tools/
└── signature-help.ts
```
### Integration
- Register in `index.ts` alongside existing tools
- Add to system prompt snippet: "Use `lsp_signature_help` when you need to know the parameter order or types for a function call"
- No tree-sitter fallback — return clear message when no LSP is available
## Open questions
- **Naming** — `lsp_signature_help` vs `lsp_signature` vs `lsp_params`? The LSP protocol calls it "signature help" so `lsp_signature_help` is most precise, but it's verbose. The competitor uses `signature` (as an action on the unified tool). Going with `lsp_signature_help` for consistency with `lsp_diagnostics`, `lsp_definition`, etc.
- **Context parameter** — should we accept an optional `context` with `triggerKind` and `triggerCharacter`? Probably not — the LLM doesn't know or care about trigger characters. Always send `triggerKind: Invoked`.
- **Interaction with completions** — when the LLM is inside a call and wants to know the expected type for the current argument, should it use signature help (to see the parameter type) then completions (to see what values match)? The system prompt should guide this workflow.

View File

@@ -0,0 +1,142 @@
# Structural Code Search & Rewrite
## Summary
Add AST-based structural search and rewrite tools powered by tree-sitter's query language. Find and transform code by structure, not text — enabling safe, language-aware refactoring that grep and sed can't do.
## Motivation
Text-based search (`grep`, `rg`) matches strings, not code structure. Searching for `console.log` catches comments and string literals. Replacing `var` with `const` via regex can break template literals and object keys.
Kiro CLI offers `pattern_search` and `pattern_rewrite` with metavariable support (`$VAR` matches any single node, `$$$` matches zero-or-more). This is genuinely useful for the LLM — it can propose structural transformations and preview them safely.
## Goals
- **`code_search` tool** — find code matching a structural pattern, returning locations and matched fragments
- **`code_rewrite` tool** — transform code matching a pattern into a replacement pattern, with dry-run support
- **Metavariable syntax** — `$NAME` for single nodes, `$$$` for variadic sequences, matching Kiro's convention
- **Multi-language** — works for any language with a tree-sitter grammar loaded (see tree-sitter-integration spec)
## Non-goals
- Type-aware matching (e.g., "find all calls where the argument is a `string`") — that needs LSP
- Cross-file rewrite coordination (e.g., updating imports when renaming) — use `lsp_rename` for that
- Custom query language exposure — we translate metavariable patterns to tree-sitter queries internally
## Depends on
- [Tree-sitter Integration](./tree-sitter-integration.md) — requires the parser-manager and grammar loading infrastructure
## Design
### Pattern syntax
User-facing patterns use a simplified syntax with metavariables:
```
// Single node capture
console.log($ARG) → matches console.log(x), console.log("hello"), etc.
// Variadic capture
function $NAME($$$PARAMS) { $$$ } → matches any function declaration
// Literal matching
$OBJ.hasOwnProperty($KEY) → matches foo.hasOwnProperty("bar")
```
Internally, patterns are compiled to tree-sitter S-expression queries with `@capture` nodes.
### Pattern compilation
1. Parse the pattern string as code in the target language using tree-sitter
2. Walk the resulting AST and replace metavariable identifiers with query captures:
- `$NAME``(_) @name` (single node wildcard)
- `$$$PARAMS``(_)* @params` (variadic wildcard)
- Literal nodes stay as concrete matchers
3. Produce a tree-sitter query string
Edge cases:
- Pattern doesn't parse → try wrapping in expression context, then statement context
- Ambiguous parse → return error with suggestion to be more specific
### Tools
#### `code_search`
| Parameter | Type | Description |
|-----------|------|-------------|
| `pattern` | string | Structural pattern with metavariables |
| `language` | string | Target language (typescript, python, rust, etc.) |
| `path` | string? | File or directory to search (default: workspace root) |
| `max_results` | number? | Cap results (default: 50) |
Returns a list of matches with:
- File path, line number, column
- Matched source text
- Captured metavariable bindings (e.g., `$NAME = "fetchUser"`)
#### `code_rewrite`
| Parameter | Type | Description |
|-----------|------|-------------|
| `pattern` | string | Pattern to match |
| `replacement` | string | Replacement pattern using same metavariables |
| `language` | string | Target language |
| `path` | string? | File or directory scope |
| `dry_run` | boolean? | Preview changes without applying (default: true) |
Returns:
- In dry-run mode: list of planned changes (file, line, before → after)
- In apply mode: summary of changes made, number of files modified
The tool applies changes via pi's `write` internally (so auto-diagnostics fire and the LLM sees any errors introduced).
### Matching engine
For each file in scope:
1. Parse with tree-sitter (reuse cached trees from workspace index)
2. Run the compiled query against the tree
3. Collect matches, extract captured nodes
4. For rewrites: reconstruct the replacement by substituting captured text into the replacement pattern
5. Apply replacements bottom-up (last match first) to preserve byte offsets
### File layout
```
src/
├── tree-sitter/
│ ├── pattern-compiler.ts # Metavariable pattern → tree-sitter query
│ ├── search-engine.ts # Run queries across files, collect matches
│ └── rewrite-engine.ts # Apply replacement patterns
└── tools/
├── code-search.ts
└── code-rewrite.ts
```
## Examples
### Find all `.unwrap()` calls in Rust
```
pattern: $E.unwrap()
language: rust
```
### Convert `var` to `const` in JavaScript
```
pattern: var $N = $V
replacement: const $N = $V
language: javascript
```
### Find async functions with no await
```
pattern: async function $NAME($$$) { $$$ }
language: typescript
```
(Post-filter: check that no captured body node contains `await` — this may need a two-pass approach or a `filter` parameter in a future iteration.)
## Open questions
- **Pattern ambiguity** — some patterns could match at multiple AST levels (expression vs. statement). Should we default to the most specific match, or let the user specify?
- **Replacement formatting** — after substitution, should we auto-format the result (e.g., via `prettier` or LSP formatting)? Or leave as-is?
- **Conflict with `lsp_rename`** — structural rewrite overlaps with rename for simple cases. The system prompt should guide the LLM: use `lsp_rename` for symbol renames (semantically correct), use `code_rewrite` for structural transformations.

View File

@@ -0,0 +1,286 @@
# Test Suite
## Summary
Add a comprehensive test suite covering the extension's core modules: LSP client, LSP manager, file sync, tree-sitter engines, tool implementations, and the extension entry point. Tests should run without requiring real LSP servers (except for optional integration tests).
## Motivation
The extension has grown to ~6,000 lines across 20+ source files with no automated tests. Both competing LSP extensions have similar gaps — `lsp-pi` is the exception with ~1,700 lines of tests. Without tests, refactoring is risky and regressions are invisible until a user hits them.
## Goals
- **Unit tests** for all core modules with mocked LSP connections
- **Integration tests** (optional, gated behind `--integration`) that spin up real LSP servers
- **Test runner** that works with `npm test` out of the box
- **CI-friendly** — no flaky timeouts, no filesystem side effects, deterministic output
## Non-goals
- 100% coverage — focus on logic-heavy modules and known edge cases
- Testing the pi extension API itself — we trust the framework
- Testing TUI rendering pixel-perfectly — just verify the render functions return `Text` nodes
## Design
### Test framework
Use **vitest** — it supports TypeScript natively (no build step), has built-in mocking, and is fast. Add to `devDependencies`.
```json
{
"devDependencies": {
"vitest": "^3.0.0"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "vitest run --project integration"
}
}
```
### File layout
```
tests/
├── unit/
│ ├── lsp-client.test.ts
│ ├── lsp-manager.test.ts
│ ├── file-sync.test.ts
│ ├── resolve-provider.test.ts
│ ├── tree-sitter/
│ │ ├── parser-manager.test.ts
│ │ ├── pattern-compiler.test.ts
│ │ ├── search-engine.test.ts
│ │ ├── rewrite-engine.test.ts
│ │ ├── symbol-extractor.test.ts
│ │ └── workspace-index.test.ts
│ └── tools/
│ ├── diagnostics.test.ts
│ ├── hover.test.ts
│ ├── definition.test.ts
│ ├── references.test.ts
│ ├── symbols.test.ts
│ ├── rename.test.ts
│ ├── completions.test.ts
│ ├── code-search.test.ts
│ ├── code-rewrite.test.ts
│ ├── code-overview.test.ts
│ ├── signature-help.test.ts # new tool
│ └── code-actions.test.ts # new tool
├── integration/
│ ├── typescript-server.test.ts
│ └── python-server.test.ts
├── fixtures/
│ ├── sample.ts
│ ├── sample.py
│ ├── sample.rs
│ └── sample-project/
│ ├── package.json
│ ├── src/
│ │ ├── index.ts
│ │ └── utils.ts
│ └── tsconfig.json
└── helpers/
├── mock-lsp-client.ts
├── mock-lsp-manager.ts
└── test-utils.ts
```
### Mock strategy
#### `MockLspClient`
A fake `LspClient` that:
- Records all `sendRequest` calls with method + params
- Returns pre-configured responses per method (set via `mockResponse(method, response)`)
- Stores and returns diagnostics via `getDiagnostics(uri)` / `getAllDiagnostics()`
- Tracks `didOpen` / `didChange` notifications
```typescript
class MockLspClient {
private responses = new Map<string, unknown>();
private diagnostics = new Map<string, Diagnostic[]>();
private requests: Array<{ method: string; params: unknown }> = [];
mockResponse(method: string, response: unknown) { ... }
setDiagnostics(uri: string, diags: Diagnostic[]) { ... }
async sendRequest<R>(method: string, params: unknown): Promise<R> {
this.requests.push({ method, params });
return this.responses.get(method) as R;
}
getRequests(method?: string) { ... }
}
```
#### `MockLspManager`
Wraps `MockLspClient` and provides:
- `getClientForFile(path)` → returns the mock client
- `getFileUri(path)` / `resolvePath(path)` → deterministic paths
- `getLanguageId(path)` → based on extension
- `getRunningClient(languageId)` → returns the mock client
- `getUnavailableReason(path)` → returns a test message
### Test categories
#### 1. LSP Client (`lsp-client.test.ts`)
- JSON-RPC message framing (Content-Length header parsing)
- Request/response correlation (matching IDs)
- Notification handling (`textDocument/publishDiagnostics`)
- Connection error handling and reconnection
- Graceful shutdown sequence
- Timeout behavior for pending requests
#### 2. LSP Manager (`lsp-manager.test.ts`)
- Server config resolution (built-in defaults, custom overrides)
- Language detection from file extension
- Lazy server startup (only when `getClientForFile` is called)
- File URI generation (handles spaces, special characters)
- Multiple servers for different languages
- Brazil workspace detection and bemol integration
- Daemon mode (shared server lifecycle)
#### 3. File Sync (`file-sync.test.ts`)
- `handleFileRead` → sends `didOpen` with correct URI and content
- `handleFileWrite` → sends `didChange` with incremented version
- Deduplication (multiple reads of the same file don't re-open)
- Version tracking consistency
- Tree-sitter integration (parse/index updates on file change)
#### 4. Tree-sitter modules
**parser-manager.test.ts**
- WASM init lifecycle
- Parse TypeScript, Python, Rust, Go, Java source
- Incremental re-parse after edit
- Unknown language handling
**pattern-compiler.test.ts**
- `$NAME` single-node metavariable compilation
- `$$$NAME` variadic metavariable compilation
- Literal node preservation
- Expression wrapping for partial patterns
- Error cases (unparseable patterns)
**search-engine.test.ts**
- Single-file pattern matching
- Multi-file directory scanning
- Metavariable capture extraction
- `max_results` limiting
- `.gitignore` respecting
**rewrite-engine.test.ts**
- Simple substitution (`$A → $A`)
- Multi-capture replacement (`$A.$B → $B.$A`)
- Variadic replacement (`$$$ARGS`)
- Bottom-up application (offset preservation)
- Dry-run vs. apply modes
**symbol-extractor.test.ts**
- Function/method/class/interface extraction per language
- Nested symbol handling (methods inside classes)
- Signature text extraction
- Syntax error detection
**workspace-index.test.ts**
- Index build from fixture project
- Symbol search (exact and fuzzy)
- Incremental update on file change
- Skip patterns (`node_modules`, `.git`, etc.)
#### 5. Tool tests
Each tool test follows the same pattern:
1. Create a `MockLspManager` with pre-configured responses
2. Call the tool's `execute` function with test params
3. Assert on the returned `content` text and `details` object
4. Verify `renderCall` returns a `Text` node with expected content
5. Verify `renderResult` returns a `Text` node for both partial and complete states
Example for `hover.test.ts`:
```typescript
describe("lsp_hover", () => {
it("formats MarkupContent hover response", async () => {
const mockManager = createMockManager();
mockManager.client.mockResponse("textDocument/hover", {
contents: { kind: "markdown", value: "```ts\nfunction foo(): void\n```" },
});
const tool = createHoverTool(mockManager);
const result = await tool.execute("call-1", { path: "test.ts", line: 5, character: 10 });
expect(result.content[0].text).toContain("function foo(): void");
expect(result.details.hasResult).toBe(true);
});
it("falls back to tree-sitter when no LSP", async () => { ... });
it("returns unavailable message for unknown language", async () => { ... });
it("handles LSP error gracefully", async () => { ... });
it("renderCall shows file:line:col", () => { ... });
it("renderResult shows truncated hover on collapse", () => { ... });
});
```
#### 6. Integration tests
Gated behind `--project integration` flag. Require real servers installed:
**typescript-server.test.ts**
- Start `typescript-language-server`
- Open a TypeScript file
- Get diagnostics, hover, definition, references, completions
- Verify results against known fixture content
- Clean shutdown
**python-server.test.ts**
- Start `pyright-langserver`
- Same test pattern for Python fixtures
These tests have a 30-second timeout and skip if the server binary isn't found.
### Configuration
`vitest.config.ts`:
```typescript
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/unit/**/*.test.ts"],
testTimeout: 10000,
projects: [
{
name: "unit",
include: ["tests/unit/**/*.test.ts"],
},
{
name: "integration",
include: ["tests/integration/**/*.test.ts"],
testTimeout: 30000,
},
],
},
});
```
### Coverage targets
- Unit tests: aim for 80%+ line coverage on core modules
- Tools: every tool should have at least 3 tests (happy path, error, edge case)
- Tree-sitter: heavy coverage on pattern-compiler and search-engine (complex logic)
- Integration: smoke-test level — verify end-to-end flow works
## Open questions
- **Snapshot testing** — should `renderCall`/`renderResult` tests use snapshot assertions for the `Text` output? Pros: catches regressions in formatting. Cons: noisy diffs when theme changes.
- **Fixture management** — should fixtures be inline strings or separate files? Inline is easier to read in tests; files are better for integration tests that need real project structure.
- **Mock fidelity** — how closely should `MockLspClient` mimic real JSON-RPC behavior? A thin mock (just record/playback) is easier to maintain but may miss framing bugs.

View File

@@ -0,0 +1,82 @@
# Tree-sitter Integration
## Summary
Add a tree-sitter layer that provides code intelligence without requiring an LSP server. This gives the extension useful symbol search, document symbols, and definition lookup out of the box for any supported language — no install step needed.
## Motivation
Today every tool in the extension requires a running LSP server. If the user hasn't installed `typescript-language-server` or `pyright`, they get nothing. Kiro CLI solves this with a built-in tree-sitter layer that covers 18 languages with zero setup.
Tree-sitter parsing is fast (incremental), works on syntactically broken files, and can power symbol search, document outlines, and basic go-to-definition without any external process.
## Goals
- **Zero-config code intelligence** — symbol search, document symbols, and definition lookup work immediately for supported languages
- **Graceful upgrade** — when an LSP server _is_ running, LSP results take priority; tree-sitter is the fallback
- **Codebase overview** — expose a tool that summarizes project structure (directories, key files, entry points) using tree-sitter symbol extraction
- **Minimal footprint** — tree-sitter WASM bindings, no native compilation required at install time
## Non-goals
- Replacing LSP — tree-sitter cannot do cross-file type resolution, find-references across files, or rename refactoring
- Supporting every language immediately — start with the languages we already support via LSP (TypeScript, JavaScript, Python, Rust, Go, Java) and expand later
## Design
### Parser management
- Use `web-tree-sitter` (WASM-based) so the extension works without native build tools
- Bundle or lazy-download `.wasm` grammar files for each supported language
- Cache parsed trees per-file; invalidate on `didChange` events from `FileSync`
- Reuse the `EXT_TO_LANGUAGE` mapping in `lsp-manager.ts` to detect language from file extension
### Fallback strategy
Each tool that currently calls the LSP checks whether a server is running for the file's language:
1. If LSP server is running and healthy → use LSP (current behavior)
2. If no LSP server → fall back to tree-sitter
3. If tree-sitter has no grammar for the language → return "no intelligence available" error
This logic lives in a shared `resolveProvider(filePath)` helper that tools call instead of going straight to `manager.getClientForFile()`.
### New tools / tool enhancements
| Tool | Tree-sitter behavior |
|------|---------------------|
| `lsp_symbols` (file) | Walk the tree-sitter AST for function, class, method, interface, enum, and variable declarations. Same output format as today. |
| `lsp_symbols` (workspace) | Scan project files (respecting `.gitignore`), parse each, and collect top-level symbols. Fuzzy-match against the query. Cache the index and update incrementally. |
| `lsp_definition` | For a symbol at a position, search the current file's tree for a matching definition node. If not found, search the workspace index. Best-effort — won't resolve imports through `node_modules` or type aliases. |
| `lsp_hover` | Extract the enclosing node's kind and text (e.g., "function declaration", the signature line). No type info — that requires LSP. |
| `lsp_diagnostics` | Tree-sitter can detect parse errors (syntax errors). Report those as a minimal diagnostic set when no LSP is available. |
| `code_overview` | **New tool.** Summarize project structure: directory tree, top-level symbols per file, entry points, dependency manifests. Uses tree-sitter for symbol extraction. |
### Workspace indexing
- On first use, walk the project tree (skip `node_modules`, `.git`, `build`, `dist`, `target`, etc.)
- Parse each file with tree-sitter, extract top-level symbols (name, kind, file, line)
- Store in an in-memory index keyed by symbol name
- Re-index individual files when `FileSync` reports changes
- For large repos, cap initial indexing at ~5000 files and index remaining on demand
### File layout
```
src/
├── tree-sitter/
│ ├── parser-manager.ts # Load/cache WASM parsers per language
│ ├── symbol-extractor.ts # AST → symbol list, per-language queries
│ ├── workspace-index.ts # Project-wide symbol index
│ └── grammars/ # .wasm files (or download script)
├── resolve-provider.ts # LSP-or-tree-sitter routing
└── tools/
├── code-overview.ts # New tool
└── ... (existing tools updated to use resolveProvider)
```
## Open questions
- **Grammar distribution** — bundle `.wasm` files in the repo (adds ~2-5 MB) or download on first use? Bundling is simpler; downloading keeps the repo small.
- **Incremental indexing performance** — is the in-memory index fast enough for monorepos with 10k+ files, or do we need SQLite/a persistent cache?
- **Query language** — tree-sitter has a built-in query language for pattern matching. Should we expose that as a user-facing feature here, or save it for the structural search spec?

View File

@@ -0,0 +1,196 @@
/**
* File Sync — keeps LSP servers informed of file changes.
*
* Hooks into pi tool results for read/write/edit and sends
* didOpen/didChange notifications to the appropriate LSP server.
*
* Maintains an LRU-bounded set of tracked documents. When the limit is
* reached, the least-recently-used document is closed via didClose to
* prevent unbounded memory growth in the LSP server during long sessions.
*/
import { readFile } from "node:fs/promises";
import { LspManager } from "./lsp-manager.js";
import type { TreeSitterManager } from "./tree-sitter/parser-manager.js";
import type { WorkspaceIndex } from "./tree-sitter/workspace-index.js";
/** Callback to check if a synthetic dot operation is in progress for a URI */
export type SyntheticDotChecker = (uri: string) => boolean;
/** Max open documents tracked simultaneously. Oldest are closed via didClose. */
const MAX_TRACKED_DOCUMENTS = 100;
interface TrackedDocument {
uri: string;
languageId: string;
version: number;
}
export class FileSync {
/** LRU map: most-recently-used documents are at the end (Map preserves insertion order) */
private tracked: Map<string, TrackedDocument> = new Map();
private treeSitter: TreeSitterManager | null = null;
private workspaceIndex: WorkspaceIndex | null = null;
private isSyntheticDotActive: SyntheticDotChecker = () => false;
private maxTracked: number;
constructor(private manager: LspManager, maxTracked?: number) {
this.maxTracked = maxTracked ?? MAX_TRACKED_DOCUMENTS;
}
/** Set the synthetic dot checker to coordinate with the completions tool */
setSyntheticDotChecker(checker: SyntheticDotChecker): void {
this.isSyntheticDotActive = checker;
}
/** Set the tree-sitter manager for cache invalidation */
setTreeSitter(treeSitter: TreeSitterManager, workspaceIndex?: WorkspaceIndex): void {
this.treeSitter = treeSitter;
this.workspaceIndex = workspaceIndex ?? null;
}
/**
* Touch a URI in the LRU — moves it to the end (most-recently-used position).
* If the map exceeds maxTracked, evicts the oldest entry and sends didClose.
*/
private touchAndEvict(uri: string): void {
const doc = this.tracked.get(uri);
if (doc) {
// Move to end by deleting and re-inserting
this.tracked.delete(uri);
this.tracked.set(uri, doc);
}
// Evict oldest if over capacity
while (this.tracked.size > this.maxTracked) {
const oldest = this.tracked.entries().next();
if (oldest.done) break;
const [evictUri, evictDoc] = oldest.value;
this.tracked.delete(evictUri);
// Send didClose to the appropriate LSP server
const client = this.manager.getRunningClient(evictDoc.languageId);
if (client) {
client.didClose(evictUri);
}
}
}
/**
* Handle a file being read — sends didOpen if not yet tracked.
* Called from tool_result handler for the `read` tool.
*/
async handleFileRead(filePath: string): Promise<void> {
const absPath = this.manager.resolvePath(filePath);
const uri = this.manager.getFileUri(absPath);
// Already tracked? Just touch it for LRU freshness.
if (this.tracked.has(uri)) {
this.touchAndEvict(uri);
return;
}
const languageId = this.manager.getLanguageId(absPath);
if (!languageId) return;
// Only sync if we have a client already running for this language (don't start one just for a read)
const client = this.manager.getRunningClient(languageId);
if (!client) return;
try {
const content = await readFile(absPath, "utf-8");
const doc: TrackedDocument = { uri, languageId, version: 1 };
this.tracked.set(uri, doc);
client.didOpen(uri, languageId, doc.version, content);
this.touchAndEvict(uri);
} catch {
// File might not exist or be unreadable — ignore
}
}
/**
* Handle a file being written/edited — sends didOpen or didChange.
* Called from tool_result handler for `write` and `edit` tools.
*/
async handleFileWrite(filePath: string): Promise<void> {
const absPath = this.manager.resolvePath(filePath);
const uri = this.manager.getFileUri(absPath);
const languageId = this.manager.getLanguageId(absPath);
// Invalidate tree-sitter cache and re-index
if (this.treeSitter) {
this.treeSitter.invalidate(absPath);
if (this.workspaceIndex) {
// Re-index in the background (don't block the write)
this.workspaceIndex.indexFile(absPath).catch(() => {});
}
}
if (!languageId) return;
// If a synthetic dot operation is in progress for this URI, defer the
// didChange to avoid version conflicts. The completions tool will revert
// the document to the correct content when it's done.
if (this.isSyntheticDotActive(uri)) {
// Schedule a retry after the synthetic dot window (200ms should be enough
// for the 100ms settle delay + completion request + revert)
setTimeout(() => {
// Re-check: if still active, skip — another retry would be needed
if (!this.isSyntheticDotActive(uri)) {
this.handleFileWrite(filePath).catch(() => {});
}
}, 200);
return;
}
// Get client (start server lazily if configured)
const client = await this.manager.getClientForFile(absPath).catch(() => null);
if (!client) return;
try {
const content = await readFile(absPath, "utf-8");
const existing = this.tracked.get(uri);
if (existing) {
// Already open — send didChange with incremented version
existing.version++;
client.didChange(uri, existing.version, content);
} else {
// First time — send didOpen
const doc: TrackedDocument = { uri, languageId, version: 1 };
this.tracked.set(uri, doc);
client.didOpen(uri, languageId, doc.version, content);
}
this.touchAndEvict(uri);
} catch {
// File might not exist or be unreadable — ignore
}
}
/**
* Get the current tracked version for a URI, or null if not tracked.
* Used by tools that need to send temporary didChange notifications
* while keeping versions in sync.
*/
getTrackedVersion(uri: string): number | null {
const doc = this.tracked.get(uri);
return doc ? doc.version : null;
}
/**
* Update the tracked version for a URI after external didChange calls.
* This keeps FileSync in sync when other code (e.g., completions tool)
* sends didChange notifications directly to the LSP client.
*/
setTrackedVersion(uri: string, version: number): void {
const doc = this.tracked.get(uri);
if (doc) {
doc.version = version;
}
}
/** Get the number of tracked documents */
get trackedCount(): number {
return this.tracked.size;
}
}

View File

@@ -0,0 +1,607 @@
/**
* pi-lsp-extension — Pi coding agent extension for LSP integration.
*
* Exposes Language Server Protocol capabilities as tools the LLM can call:
* - lsp_diagnostics: compilation errors and warnings
* - lsp_hover: type info and docs at a position
* - lsp_definition: go to definition
* - lsp_references: find all references
* - lsp_symbols: file/workspace symbol search
* - lsp_rename: preview rename refactoring
* - lsp_completions: code completion suggestions at a position
* - lsp_code_actions: quick fixes, refactorings, and source actions
*
* Position-based tools (hover, definition, references, rename, code_actions)
* accept an optional `query` parameter as an alternative to line/character,
* resolving a symbol name to its position automatically.
*
* Usage:
* 1. npm install in this directory
* 2. Add to pi via settings.json extensions, or: pi -e ./src/index.ts
* 3. LSP servers start lazily when you first use a tool on a file
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
isReadToolResult,
isWriteToolResult,
isEditToolResult,
} from "@earendil-works/pi-coding-agent";
import { DiagnosticSeverity, type Diagnostic } from "vscode-languageserver-protocol";
import { LspManager, type ServerConfig, type LspManagerCallbacks } from "./lsp-manager.js";
import { FileSync } from "./file-sync.js";
import { TreeSitterManager } from "./tree-sitter/parser-manager.js";
import { WorkspaceIndex } from "./tree-sitter/workspace-index.js";
import { type WorkspaceProvider, DefaultWorkspaceProvider } from "./workspace-provider.js";
import { createDiagnosticsTool } from "./tools/diagnostics.js";
import { createHoverTool } from "./tools/hover.js";
import { createDefinitionTool } from "./tools/definition.js";
import { createReferencesTool } from "./tools/references.js";
import { createSymbolsTool } from "./tools/symbols.js";
import { createRenameTool } from "./tools/rename.js";
import { createCodeOverviewTool } from "./tools/code-overview.js";
import { createCompletionsTool } from "./tools/completions.js";
import { createCodeSearchTool } from "./tools/code-search.js";
import { createCodeRewriteTool } from "./tools/code-rewrite.js";
import { createCodeActionsTool } from "./tools/code-actions.js";
import { syntheticDotLocks } from "./tools/completions.js";
import { relative } from "node:path";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { DIAGNOSTIC_SETTLE_DELAY_MS } from "./shared/timing.js";
/**
* Project-level LSP config — loaded from `.pi-lsp.json` in the workspace root.
*
* Example:
* ```json
* {
* "autoStart": ["java", "typescript"],
* "lombokJar": "env/Lombok-1.18.x/runtime/lib/lombok-1.18.42.jar",
* "servers": {
* "python": { "command": "pylsp", "args": [] }
* }
* }
* ```
*/
interface ProjectLspConfig {
/** Languages to start eagerly on session_start (e.g. ["java", "typescript"]) */
autoStart?: string[];
/** Path to Lombok jar (absolute or relative to project root). "auto" to auto-detect. */
lombokJar?: string;
/** Custom server configs keyed by language ID */
servers?: Record<string, { command: string; args?: string[]; env?: Record<string, string>; initializationOptions?: Record<string, unknown>; settings?: Record<string, unknown> }>;
/**
* Auto-inject LSP error diagnostics into write/edit tool results.
* Set to false to disable, or provide an array of language IDs to enable selectively.
* Default: true (all languages).
*
* Examples:
* true — inject for all languages
* false — never inject
* ["typescript"] — only inject for TypeScript files
*/
autoInjectDiagnostics?: boolean | string[];
}
/** Load .pi-lsp.json from a directory. Returns null if not found or invalid. */
function loadProjectConfig(dir: string): ProjectLspConfig | null {
const configPath = join(dir, ".pi-lsp.json");
try {
if (!existsSync(configPath)) return null;
const raw = readFileSync(configPath, "utf-8");
const parsed = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return null;
return parsed as ProjectLspConfig;
} catch {
return null;
}
}
export default function lspExtension(pi: ExtensionAPI) {
// Prevent EPIPE errors from LSP server exits from crashing the host process.
// When an LSP server exits unexpectedly, in-flight writes to its stdin pipe
// can produce EPIPE errors that escape all connection-level error handlers.
const origListeners = process.listeners("uncaughtException");
process.on("uncaughtException", (err: any) => {
if (err?.code === "EPIPE") return; // swallow — LSP server exited, harmless
// Re-throw for other handlers
for (const listener of origListeners) (listener as any)(err);
if (origListeners.length === 0) {
console.error("[LSP] Uncaught exception:", err);
}
});
let manager: LspManager | null = null;
let fileSync: FileSync | null = null;
let treeSitter: TreeSitterManager | null = null;
let workspaceIndex: WorkspaceIndex | null = null;
let pendingProvider: WorkspaceProvider | null = null;
// Store latest ctx for lifecycle callbacks (updated on each event)
let latestCtx: any = null;
// Project config — loaded on session_start, used by auto-injection guard
let projectConfig: ProjectLspConfig | null = null;
/**
* Run `fn` with the currently captured ctx, swallowing stale-ctx errors.
*
* Lifecycle callbacks from LspManager (server ready, workspace setup, etc.)
* and `pi.events` listeners can fire AFTER the session is replaced or reloaded,
* at which point the captured ctx has been invalidated and any property access
* throws via ExtensionRunner.assertActive(). We can't use optional chaining to
* dodge it because the `ui` / `theme` getters themselves run assertActive().
*/
const withLatestCtx = (fn: (ctx: any) => void) => {
const ctx = latestCtx;
if (!ctx) return;
try {
fn(ctx);
} catch (err: any) {
if (typeof err?.message === "string" && err.message.includes("stale after session")) {
// Session was replaced/reloaded — drop the stale ctx and move on.
latestCtx = null;
return;
}
throw err;
}
};
// Listen for external workspace providers (e.g. bemol extension).
// Also check if one was already registered before we loaded (load order varies).
// Store as pendingProvider so it's available when the manager is created later.
const applyProvider = (data: unknown) => {
const provider = data as WorkspaceProvider;
pendingProvider = provider;
if (manager) {
manager.setWorkspaceProvider(provider);
}
// Update status if we have a UI context
const statusText = provider.getStatusText();
if (!statusText) return;
withLatestCtx((ctx) => {
if (!ctx.ui?.theme) return;
ctx.ui.setStatus("lsp", ctx.ui.theme.fg("accent", `LSP: ${statusText}`));
});
};
pi.events.on("lsp:register-workspace-provider", applyProvider);
// Check for provider registered before our listener existed
const existing = (pi.events as any)["lsp:workspace-provider"];
if (existing) applyProvider(existing);
/** Build lifecycle callbacks that update UI status */
const setLspStatus = (color: string, text: string) => {
withLatestCtx((ctx) => {
if (!ctx.ui?.theme) return;
ctx.ui.setStatus("lsp", ctx.ui.theme.fg(color, text));
});
};
const makeCallbacks = (): LspManagerCallbacks => ({
onWorkspaceSetupStart: () => {
setLspStatus("warning", "LSP: workspace setup...");
},
onWorkspaceSetupEnd: (success: boolean, duration: number) => {
const secs = (duration / 1000).toFixed(1);
if (success) {
setLspStatus("accent", `LSP: workspace ready (${secs}s)`);
} else {
setLspStatus("warning", `LSP: workspace setup failed (${secs}s)`);
}
},
onServerStart: (languageId: string, command: string) => {
setLspStatus("warning", `LSP: starting ${languageId} (${command})...`);
},
onServerReady: (languageId: string) => {
setLspStatus("accent", `LSP: ${languageId} ready`);
},
onServerError: (languageId: string, _error: string) => {
setLspStatus("error", `LSP: ${languageId} failed`);
},
onServerCrash: (languageId: string, restarting: boolean, attempt: number) => {
if (restarting) {
setLspStatus("warning", `LSP: restarting ${languageId}... (attempt ${attempt}/3)`);
} else {
setLspStatus("error", `LSP: ${languageId} crashed — auto-restart exhausted`);
}
},
});
// Create manager eagerly so tools can reference it, but servers start lazily
const getManager = (): LspManager => {
if (!manager) {
manager = new LspManager(process.cwd(), undefined, makeCallbacks(), undefined, pendingProvider ?? undefined);
fileSync = new FileSync(manager);
fileSync.setSyntheticDotChecker((uri) => syntheticDotLocks.has(uri));
treeSitter = new TreeSitterManager();
workspaceIndex = new WorkspaceIndex(process.cwd(), treeSitter);
fileSync.setTreeSitter(treeSitter, workspaceIndex);
}
return manager;
};
const getFileSync = (): FileSync => {
if (!fileSync) {
getManager(); // ensures fileSync is created
}
return fileSync!;
};
const getTreeSitter = (): TreeSitterManager => {
if (!treeSitter) {
getManager(); // ensures treeSitter is created
}
return treeSitter!;
};
const getWorkspaceIndex = (): WorkspaceIndex => {
if (!workspaceIndex) {
getManager(); // ensures workspaceIndex is created
}
return workspaceIndex!;
};
// Initialize manager (uses cwd at session start time)
pi.on("session_start", async (_event, ctx) => {
latestCtx = ctx;
// If manager was already created eagerly (e.g. by a tool before session_start),
// shut it down so we can re-create with the correct ctx.cwd.
if (manager) {
await manager.shutdownAll().catch(() => {});
if (treeSitter) treeSitter.shutdown();
}
manager = new LspManager(ctx.cwd, undefined, makeCallbacks(), undefined, pendingProvider ?? undefined);
fileSync = new FileSync(manager);
fileSync.setSyntheticDotChecker((uri) => syntheticDotLocks.has(uri));
treeSitter = new TreeSitterManager();
workspaceIndex = new WorkspaceIndex(ctx.cwd, treeSitter);
fileSync.setTreeSitter(treeSitter, workspaceIndex);
// Initialize tree-sitter in the background (don't block session start)
treeSitter.init().catch((err) => {
console.error(`[pi-lsp-extension] tree-sitter WASM init failed: ${err?.message ?? err}`);
});
// Show workspace provider status
const wsProvider = manager.workspace;
const statusText = wsProvider.getStatusText();
if (statusText) {
ctx.ui.setStatus("lsp", ctx.ui.theme.fg("accent", `LSP: ${statusText}`));
} else {
ctx.ui.setStatus("lsp", ctx.ui.theme.fg("dim", "LSP: idle"));
}
// Load project config and apply settings
projectConfig = loadProjectConfig(ctx.cwd);
if (projectConfig) {
// Apply custom server configs
if (projectConfig.servers) {
for (const [lang, serverConf] of Object.entries(projectConfig.servers)) {
manager.setServerConfig(lang, {
command: serverConf.command,
args: serverConf.args ?? [],
env: serverConf.env,
initializationOptions: serverConf.initializationOptions,
settings: serverConf.settings,
});
}
}
// Set Lombok jar path (explicit path or "auto" for auto-detection)
if (projectConfig.lombokJar) {
if (projectConfig.lombokJar !== "auto") {
manager.setLombokJar(projectConfig.lombokJar);
}
// "auto" is the default behavior — findLombokJar() already auto-detects.
// Setting it explicitly just confirms the user wants Lombok support.
}
// Auto-start configured languages in the background
if (projectConfig.autoStart && projectConfig.autoStart.length > 0) {
const langs = projectConfig.autoStart;
const lombokNote = langs.includes("java") && manager.getLombokJar()
? ` (lombok: ${manager.getLombokJar()?.split("/").pop()})` : "";
setLspStatus("warning", `LSP: auto-starting ${langs.join(", ")}${lombokNote}...`);
manager.startEagerly(langs);
}
}
});
// Register all LSP tools
// Tools call getManager() lazily so they work even if session_start hasn't fired
const managerProxy = new Proxy({} as LspManager, {
get(_target, prop) {
return (getManager() as any)[prop];
},
});
const treeSitterProxy = new Proxy({} as TreeSitterManager, {
get(_target, prop) {
return (getTreeSitter() as any)[prop];
},
});
const workspaceIndexProxy = new Proxy({} as WorkspaceIndex, {
get(_target, prop) {
return (getWorkspaceIndex() as any)[prop];
},
});
pi.registerTool(createDiagnosticsTool(managerProxy, treeSitterProxy));
pi.registerTool(createHoverTool(managerProxy, treeSitterProxy));
pi.registerTool(createDefinitionTool(managerProxy, treeSitterProxy, workspaceIndexProxy));
pi.registerTool(createReferencesTool(managerProxy, treeSitterProxy));
pi.registerTool(createSymbolsTool(managerProxy, treeSitterProxy, workspaceIndexProxy));
pi.registerTool(createRenameTool(managerProxy, treeSitterProxy));
pi.registerTool(createCodeActionsTool(managerProxy, treeSitterProxy));
pi.registerTool(createCompletionsTool(managerProxy, {
getTrackedVersion: (uri) => getFileSync().getTrackedVersion(uri),
setTrackedVersion: (uri, v) => getFileSync().setTrackedVersion(uri, v),
isSyntheticDotActive: (uri) => syntheticDotLocks.has(uri),
}, treeSitterProxy));
const getRootDir = () => manager?.resolvePath(".") ?? process.cwd();
pi.registerTool(createCodeOverviewTool(getRootDir, treeSitterProxy, workspaceIndexProxy));
pi.registerTool(createCodeSearchTool(getRootDir, treeSitterProxy));
pi.registerTool(createCodeRewriteTool(getRootDir, treeSitterProxy, {
onFileModified: (filePath: string) => {
getFileSync().handleFileWrite(filePath).catch(() => {});
},
}));
pi.registerTool(createCodeActionsTool(managerProxy, treeSitterProxy));
// File sync: track file reads/writes/edits
// After writes/edits, append file-scoped error diagnostics to the tool result
pi.on("tool_result", async (event) => {
const sync = getFileSync();
try {
if (isReadToolResult(event) && !event.isError) {
const path = (event.input as any)?.path;
if (path) await sync.handleFileRead(path);
}
if (isWriteToolResult(event) && !event.isError) {
const path = (event.input as any)?.path;
if (path) await sync.handleFileWrite(path);
}
if (isEditToolResult(event) && !event.isError) {
const path = (event.input as any)?.path;
if (path) await sync.handleFileWrite(path);
}
} catch {
// File sync errors are non-fatal
}
// Auto-append diagnostics for the changed file (write/edit only)
if ((isWriteToolResult(event) || isEditToolResult(event)) && !event.isError && manager) {
const path = (event.input as any)?.path;
if (!path) return;
const languageId = manager.getLanguageId(path);
if (!languageId) return;
// Check autoInjectDiagnostics config
const inject = projectConfig?.autoInjectDiagnostics;
if (inject === false) return;
if (Array.isArray(inject) && !inject.includes(languageId)) return;
const client = manager.getRunningClient(languageId);
if (!client) return;
// Wait briefly for the LSP to publish updated diagnostics
await new Promise((r) => setTimeout(r, DIAGNOSTIC_SETTLE_DELAY_MS));
const uri = manager.getFileUri(path);
const diagnostics = client.getDiagnostics(uri);
const errors = diagnostics.filter((d) => d.severity === DiagnosticSeverity.Error);
if (errors.length === 0) return;
// Build a compact summary — just errors, max 10 lines
const relPath = relative(manager.resolvePath("."), manager.resolvePath(path));
const lines = errors.slice(0, 10).map((d) => {
const line = d.range.start.line + 1;
const col = d.range.start.character + 1;
const source = d.source ? ` [${d.source}]` : "";
return `${relPath}:${line}:${col} error: ${d.message}${source}`;
});
if (errors.length > 10) {
lines.push(`... and ${errors.length - 10} more error(s)`);
}
const summary = `\n\n⚠ LSP: ${errors.length} error(s) in ${relPath}:\n${lines.join("\n")}`;
return {
content: [
...event.content,
{ type: "text" as const, text: summary },
],
};
}
});
// Update status after tool execution ends
pi.on("tool_execution_end", async (_event, ctx) => {
latestCtx = ctx;
if (!manager) return;
const statuses = manager.getStatus();
const running = statuses.filter((s) => s.running);
if (running.length === 0) {
ctx.ui.setStatus("lsp", ctx.ui.theme.fg("dim", "LSP: idle"));
} else {
const totalDiags = running.reduce((n, s) => n + s.diagnosticsCount, 0);
const langs = running.map((s) => s.languageId).join(", ");
let status = `LSP: ${langs}`;
if (totalDiags > 0) {
status += ` (${totalDiags} diagnostic${totalDiags !== 1 ? "s" : ""})`;
}
ctx.ui.setStatus("lsp", ctx.ui.theme.fg("accent", status));
}
});
// /lsp command — show server status
pi.registerCommand("lsp", {
description: "Show LSP server status",
handler: async (_args, ctx) => {
if (!manager) {
ctx.ui.notify("LSP manager not initialized", "warning");
return;
}
const statuses = manager.getStatus();
if (statuses.length === 0) {
ctx.ui.notify("No LSP servers configured", "info");
return;
}
const lines = statuses.map((s) => {
const icon = s.running ? "🟢" : "⚪";
const diags =
s.diagnosticsCount > 0 ? ` (${s.diagnosticsCount} diagnostics)` : "";
const shared = s.shared ? " [shared]" : "";
return `${icon} ${s.languageId}: ${s.command}${diags}${shared}`;
});
ctx.ui.notify(lines.join("\n"), "info");
},
});
// /lsp-restart command — restart a specific language server
pi.registerCommand("lsp-restart", {
description: "Restart an LSP server: /lsp-restart <language> (e.g. java, typescript)",
handler: async (args, ctx) => {
if (!manager) {
ctx.ui.notify("LSP manager not initialized", "warning");
return;
}
const languageId = args?.trim().toLowerCase();
if (!languageId) {
// Show running servers and usage
const statuses = manager.getStatus().filter((s) => s.running);
if (statuses.length === 0) {
ctx.ui.notify("No LSP servers are running.\n\nUsage: /lsp-restart <language>", "info");
} else {
const langs = statuses.map((s) => s.languageId).join(", ");
ctx.ui.notify(
`Running servers: ${langs}\n\nUsage: /lsp-restart <language>\nExample: /lsp-restart java`,
"info"
);
}
return;
}
ctx.ui.setStatus("lsp", ctx.ui.theme.fg("warning", `LSP: restarting ${languageId}...`));
ctx.ui.notify(`Restarting ${languageId} server (kills daemon if shared)...`, "info");
try {
await manager.restartServer(languageId);
const lombokJar = languageId === "java" ? manager.getLombokJar() : null;
const lombokNote = lombokJar ? `\nLombok: ${lombokJar}` : "";
ctx.ui.notify(`${languageId} server restarted successfully.${lombokNote}`, "info");
ctx.ui.setStatus("lsp", ctx.ui.theme.fg("accent", `LSP: ${languageId} ready`));
} catch (err: any) {
ctx.ui.notify(`Failed to restart ${languageId}: ${err.message}`, "error");
ctx.ui.setStatus("lsp", ctx.ui.theme.fg("error", `LSP: ${languageId} restart failed`));
}
},
});
// /lsp-config command — add or override server configuration
pi.registerCommand("lsp-config", {
description:
"Configure an LSP server: /lsp-config <language> <command> [args...]",
handler: async (args, ctx) => {
if (!args?.trim()) {
ctx.ui.notify(
"Usage: /lsp-config <language> <command> [args...]\nExample: /lsp-config python pylsp",
"info"
);
return;
}
const parts = args.trim().split(/\s+/);
if (parts.length < 2) {
ctx.ui.notify(
"Usage: /lsp-config <language> <command> [args...]",
"warning"
);
return;
}
const [languageId, command, ...serverArgs] = parts;
const config: ServerConfig = { command, args: serverArgs };
getManager().setServerConfig(languageId, config);
ctx.ui.notify(
`Configured LSP for ${languageId}: ${command} ${serverArgs.join(" ")}`,
"info"
);
},
});
// /lsp-lombok command — set Lombok jar path for Java
pi.registerCommand("lsp-lombok", {
description:
"Set Lombok jar path for Java: /lsp-lombok <path-to-lombok.jar>",
handler: async (args, ctx) => {
const mgr = getManager();
if (!args?.trim()) {
const current = mgr.getLombokJar();
if (current) {
ctx.ui.notify(`Lombok jar: ${current}`, "info");
} else {
ctx.ui.notify(
"No Lombok jar configured or detected.\n\n" +
"Usage: /lsp-lombok <path-to-lombok.jar>\n" +
"Or set LOMBOK_JAR environment variable.\n\n" +
"Download from: https://projectlombok.org/download",
"info"
);
}
return;
}
const jarPath = args.trim();
const { existsSync } = await import("node:fs");
const { resolve } = await import("node:path");
const resolved = resolve(ctx.cwd, jarPath);
if (!existsSync(resolved)) {
ctx.ui.notify(`File not found: ${resolved}`, "error");
return;
}
if (!resolved.endsWith(".jar")) {
ctx.ui.notify(`Warning: ${resolved} doesn't end in .jar — setting anyway`, "warning");
}
mgr.setLombokJar(resolved);
ctx.ui.notify(`Lombok jar set: ${resolved}`, "info");
},
});
// Clean shutdown (includes workspace provider, all LSP servers, and tree-sitter)
pi.on("session_shutdown", async () => {
// Drop the captured ctx immediately — once shutdown fires, any late
// LSP manager callback that reaches setLspStatus/applyProvider would
// otherwise hit an invalidated ctx and throw an uncaught exception.
latestCtx = null;
if (manager) {
await manager.shutdownAll();
manager = null;
fileSync = null;
}
if (treeSitter) {
treeSitter.shutdown();
treeSitter = null;
}
workspaceIndex = null;
});
}

View File

@@ -0,0 +1,443 @@
/**
* LSP Client — JSON-RPC client for LSP servers.
*
* Supports two modes:
* - **Direct**: spawns LSP server as child process (stdio)
* - **Socket**: connects to an LSP daemon via Unix domain socket
*
* Uses vscode-jsonrpc (bundled with vscode-languageserver-protocol) for
* JSON-RPC message framing and transport.
*/
import { spawn, type ChildProcess } from "node:child_process";
import { connect as netConnect, type Socket } from "node:net";
import { pathToFileURL } from "node:url";
import {
createMessageConnection,
StreamMessageReader,
StreamMessageWriter,
SocketMessageReader,
SocketMessageWriter,
type MessageConnection,
} from "vscode-languageserver-protocol/node.js";
import type {
InitializeParams,
InitializeResult,
ServerCapabilities,
Diagnostic,
PublishDiagnosticsParams,
} from "vscode-languageserver-protocol";
export interface LspClientOptions {
/** Command to start the LSP server */
command: string;
/** Arguments for the command */
args: string[];
/** Root directory of the workspace */
rootDir: string;
/** Language ID this server handles */
languageId: string;
/** Extra environment variables */
env?: Record<string, string>;
/** Additional workspace folders (e.g. for multi-package workspaces) */
workspaceFolders?: { uri: string; name: string }[];
/** Connect to existing daemon socket instead of spawning a new process */
socketPath?: string;
/** LSP initializationOptions (e.g. jdtls settings for Lombok) */
initializationOptions?: Record<string, unknown>;
/** Settings returned by workspace/configuration handler (keyed by section, e.g. { intelephense: {...} }) */
settings?: Record<string, unknown>;
/** Called when the server exits unexpectedly (not from user-initiated shutdown) */
onUnexpectedExit?: (code: number | null) => void;
}
export class LspClient {
private process: ChildProcess | null = null;
private socket: Socket | null = null;
private connection: MessageConnection | null = null;
private _serverCapabilities: ServerCapabilities | null = null;
private _diagnostics: Map<string, Diagnostic[]> = new Map();
private _initialized = false;
private _disposed = false;
/** True if connected to a daemon socket (server init handled by daemon) */
private _isDaemonClient = false;
readonly languageId: string;
readonly command: string;
readonly rootDir: string;
constructor(private options: LspClientOptions) {
this.languageId = options.languageId;
this.command = options.command;
this.rootDir = options.rootDir;
}
get initialized(): boolean {
return this._initialized;
}
get disposed(): boolean {
return this._disposed;
}
get serverCapabilities(): ServerCapabilities | null {
return this._serverCapabilities;
}
/** Get cached diagnostics for a URI */
getDiagnostics(uri: string): Diagnostic[] {
return this._diagnostics.get(uri) ?? [];
}
/** Get all cached diagnostics */
getAllDiagnostics(): Map<string, Diagnostic[]> {
return new Map(this._diagnostics);
}
/** Start the LSP server and perform the initialize handshake */
async start(): Promise<void> {
if (this._initialized || this._disposed) return;
if (this.options.socketPath) {
await this.connectToSocket(this.options.socketPath);
} else {
await this.spawnDirect();
}
}
/** Register shared connection handlers (diagnostics, workspace/configuration, errors) */
private registerConnectionHandlers(): void {
if (!this.connection) return;
// Listen for published diagnostics
this.connection.onNotification(
"textDocument/publishDiagnostics",
(params: PublishDiagnosticsParams) => {
this._diagnostics.set(params.uri, params.diagnostics);
}
);
// Handle workspace/configuration requests from the server.
// Servers like Intelephense request their settings via this method.
// Return settings from options if configured, otherwise empty defaults.
this.connection.onRequest(
"workspace/configuration",
(params: { items: { section?: string }[] }) => {
const settings = this.options.settings;
return params.items.map((item) => {
if (item.section && settings && item.section in settings) {
return settings[item.section];
}
return {};
});
}
);
// Handle connection-level errors to prevent unhandled exceptions
this.connection.onError(([err]) => {
console.error(`[LSP ${this.languageId}] Connection error: ${err.message}`);
});
this.connection.onClose(() => {
if (!this._disposed) {
this._initialized = false;
}
});
}
/** Connect to an existing LSP daemon via Unix socket (no init handshake needed) */
private async connectToSocket(socketPath: string): Promise<void> {
this._isDaemonClient = true;
return new Promise((resolve, reject) => {
let settled = false;
const settle = (fn: () => void) => {
if (!settled) { settled = true; fn(); }
};
const socket = netConnect(socketPath, () => {
this.socket = socket;
const reader = new SocketMessageReader(socket);
const writer = new SocketMessageWriter(socket);
this.connection = createMessageConnection(reader, writer);
this.registerConnectionHandlers();
this.connection.listen();
this._initialized = true;
settle(() => resolve());
});
socket.on("error", (err) => {
if (!this._initialized) {
settle(() => reject(new Error(`Failed to connect to LSP daemon: ${err.message}`)));
} else {
this._initialized = false;
}
});
socket.on("close", () => {
if (!this._disposed) {
this._initialized = false;
this.options.onUnexpectedExit?.(null);
}
});
// Timeout
setTimeout(() => {
if (!settled) {
socket.destroy();
settle(() => reject(new Error("Timeout connecting to LSP daemon socket")));
}
}, 10_000);
});
}
/** Spawn LSP server directly as child process with stdio */
private async spawnDirect(): Promise<void> {
const env = { ...process.env, ...this.options.env };
this.process = spawn(this.options.command, this.options.args, {
stdio: ["pipe", "pipe", "pipe"],
env,
cwd: this.rootDir,
});
if (!this.process.stdout || !this.process.stdin) {
throw new Error(`Failed to spawn LSP server: ${this.options.command}`);
}
// Wait for the process to successfully spawn before setting up the connection.
// spawn() is async — ENOENT and other errors arrive on the 'error' event.
// If we don't wait, we'll try to write to a destroyed stdin and crash.
await new Promise<void>((resolve, reject) => {
const onSpawn = () => { cleanup(); resolve(); };
const onError = (err: Error) => {
cleanup();
reject(new Error(`Failed to spawn LSP server "${this.options.command}": ${err.message}`));
};
const cleanup = () => {
this.process?.removeListener("spawn", onSpawn);
this.process?.removeListener("error", onError);
};
this.process!.on("spawn", onSpawn);
this.process!.on("error", onError);
});
// Discard stderr to prevent blocking
this.process.stderr?.resume();
// Patch stdin.write to silently drop writes when the stream is destroyed.
// StreamMessageWriter wraps stdin and calls write() which returns a Promise.
// If the stream is destroyed (process exited), write() throws ERR_STREAM_DESTROYED
// inside the Promise constructor, creating a rejection that propagates through
// the writer's semaphore and becomes unhandled (notifications are fire-and-forget).
// No amount of error handlers on the stream or connection can catch this.
const stdin = this.process.stdin!;
const originalWrite = stdin.write;
stdin.write = function (this: typeof stdin, ...args: any[]): boolean {
if (this.destroyed || this.writableEnded || this.writableFinished) {
// Call the callback (last arg) so the Promise resolves instead of rejecting
const cb = args[args.length - 1];
if (typeof cb === "function") process.nextTick(cb);
return false;
}
try {
return originalWrite.apply(this, args as any);
} catch (err: any) {
// Catch EPIPE synchronously — process exited between our check and the write
if (err?.code === "EPIPE" || err?.code === "ERR_STREAM_DESTROYED") {
const cb = args[args.length - 1];
if (typeof cb === "function") process.nextTick(cb);
return false;
}
throw err;
}
} as any;
// Catch EPIPE on the stdin stream itself to prevent unhandled error events
stdin.on("error", (err: any) => {
if (err?.code === "EPIPE") return; // expected when server exits
console.error(`[LSP ${this.languageId}] stdin error: ${err.message}`);
});
this.process.on("error", (err) => {
console.error(`[LSP ${this.languageId}] Process error: ${err.message}`);
this._initialized = false;
this.disposeConnection();
});
this.process.on("exit", (code) => {
if (!this._disposed) {
console.error(`[LSP ${this.languageId}] Server exited with code ${code}`);
this._initialized = false;
this.disposeConnection();
this.options.onUnexpectedExit?.(code);
}
});
const reader = new StreamMessageReader(this.process.stdout);
const writer = new StreamMessageWriter(this.process.stdin);
this.connection = createMessageConnection(reader, writer);
this.registerConnectionHandlers();
this.connection.listen();
// Initialize handshake
const rootUri = pathToFileURL(this.rootDir).toString();
const defaultFolder = { uri: rootUri, name: this.rootDir.split("/").pop() ?? "workspace" };
// Use provided workspace folders or fall back to single root
const workspaceFolders = this.options.workspaceFolders && this.options.workspaceFolders.length > 0
? this.options.workspaceFolders
: [defaultFolder];
const initParams: InitializeParams = {
processId: process.pid,
capabilities: {
textDocument: {
synchronization: {
didSave: true,
dynamicRegistration: false,
},
hover: {
contentFormat: ["plaintext", "markdown"],
},
definition: {},
references: {},
documentSymbol: {
hierarchicalDocumentSymbolSupport: true,
},
rename: {
prepareSupport: false,
},
publishDiagnostics: {
relatedInformation: true,
},
completion: {
completionItem: {
snippetSupport: false,
},
},
},
workspace: {
workspaceFolders: true,
symbol: {},
configuration: true,
},
},
rootUri,
workspaceFolders,
...(this.options.initializationOptions
? { initializationOptions: this.options.initializationOptions }
: {}),
};
const result: InitializeResult = await this.connection.sendRequest(
"initialize",
initParams
);
this._serverCapabilities = result.capabilities;
// Send initialized notification
this.connection.sendNotification("initialized", {});
this._initialized = true;
}
/** Safely dispose the connection without throwing */
private disposeConnection(): void {
try {
if (this.connection) {
this.connection.dispose();
}
} catch {
// Already disposed or stream destroyed — ignore
}
this.connection = null;
}
/** Send a request to the LSP server */
async sendRequest<R>(method: string, params: unknown): Promise<R> {
if (!this.connection || !this._initialized) {
throw new Error(`LSP ${this.languageId} not initialized`);
}
return this.connection.sendRequest(method, params) as Promise<R>;
}
/** Send a notification to the LSP server */
sendNotification(method: string, params: unknown): void {
if (!this.connection || !this._initialized) return;
this.connection.sendNotification(method, params);
}
/** Notify server of a newly opened document */
didOpen(uri: string, languageId: string, version: number, text: string): void {
this.sendNotification("textDocument/didOpen", {
textDocument: { uri, languageId, version, text },
});
}
/** Notify server of a document change (full content sync) */
didChange(uri: string, version: number, text: string): void {
this.sendNotification("textDocument/didChange", {
textDocument: { uri, version },
contentChanges: [{ text }],
});
}
/** Notify server of a closed document */
didClose(uri: string): void {
this.sendNotification("textDocument/didClose", {
textDocument: { uri },
});
}
/** Gracefully shut down or disconnect from the server */
async shutdown(): Promise<void> {
if (this._disposed) return;
this._disposed = true;
this._initialized = false;
if (this._isDaemonClient) {
// Socket client: just disconnect — daemon keeps the server alive
this.disposeConnection();
if (this.socket) {
this.socket.destroy();
}
this.socket = null;
return;
}
// Direct mode: shut down the server we own
try {
if (this.connection) {
// Race shutdown request against a timeout. Catch the request separately
// so if the timeout wins, the abandoned sendRequest rejection doesn't
// become an unhandled promise rejection.
const shutdownReq = this.connection.sendRequest("shutdown").catch(() => {});
await Promise.race([
shutdownReq,
new Promise((resolve) => setTimeout(resolve, 3000)),
]);
try { this.connection.sendNotification("exit"); } catch {}
}
} catch {
// Server may already be dead
}
this.disposeConnection();
if (this.process) {
this.process.kill("SIGTERM");
// Force kill after 2s
setTimeout(() => {
if (this.process && !this.process.killed) {
this.process.kill("SIGKILL");
}
}, 2000);
}
this.process = null;
}
}

View File

@@ -0,0 +1,22 @@
/**
* Daemon launcher — loads lsp-daemon.ts via jiti.
*
* Usage: node lsp-daemon-launcher.cjs <jitiPath> <daemonScript> <socketPath> <command> [args...]
*
* This tiny JS file exists because the daemon runs as a detached process
* and can't rely on --import hooks. It uses the jiti path resolved at
* spawn time from the parent's module context.
*/
const [,, jitiPath, daemonScript, ...rest] = process.argv;
if (!jitiPath || !daemonScript) {
console.error("Usage: node lsp-daemon-launcher.cjs <jitiPath> <daemonScript> <socketPath> <command> [args...]");
process.exit(1);
}
// Override argv so the daemon sees: [node, daemonScript, socketPath, command, ...args]
process.argv = [process.argv[0], daemonScript, ...rest];
const { createJiti } = require(jitiPath);
const jiti = createJiti(daemonScript);
jiti(daemonScript);

View File

@@ -0,0 +1,537 @@
#!/usr/bin/env node
/**
* LSP Daemon — persistent LSP server proxy over Unix domain socket.
*
* Spawns an LSP server (stdio) and multiplexes connections from multiple
* pi sessions via a Unix socket. This avoids running duplicate heavy
* LSP servers (jdtls, pyright, etc.) across sessions in the same workspace.
*
* Protocol: clients connect to the socket and speak JSON-RPC using
* LSP Content-Length framing — same as talking to an LSP server directly.
*
* Lifecycle:
* - First session spawns daemon (detached)
* - Daemon spawns LSP server, listens on socket
* - Sessions connect/disconnect freely
* - After last client disconnects, daemon waits 5 min then exits
*
* Usage: node lsp-daemon.js <socketPath> <command> [args...]
* Env: LSP_ROOT_DIR, LSP_LANGUAGE_ID, LSP_WORKSPACE_FOLDERS (JSON)
*/
import { createServer, type Server, type Socket } from "node:net";
import { spawn, type ChildProcess } from "node:child_process";
import { unlinkSync, existsSync, writeFileSync, mkdirSync, appendFileSync } from "node:fs";
import { dirname } from "node:path";
import { pathToFileURL } from "node:url";
// ── LSP Message Framing ────────────────────────────────────────────────────
interface JsonRpcMessage {
jsonrpc: "2.0";
id?: number | string;
method?: string;
params?: unknown;
result?: unknown;
error?: unknown;
}
/**
* Incremental parser for LSP Content-Length framed messages.
* Accumulates data and emits complete JSON-RPC messages.
*/
class MessageParser {
private buffer = Buffer.alloc(0);
private contentLength = -1;
private headerComplete = false;
private onMessage: (msg: JsonRpcMessage) => void;
constructor(onMessage: (msg: JsonRpcMessage) => void) {
this.onMessage = onMessage;
}
feed(data: Buffer): void {
this.buffer = Buffer.concat([this.buffer, data]);
this.parse();
}
private parse(): void {
while (true) {
if (!this.headerComplete) {
const headerEnd = this.buffer.indexOf("\r\n\r\n");
if (headerEnd === -1) return; // incomplete header
const header = this.buffer.subarray(0, headerEnd).toString("ascii");
const match = header.match(/Content-Length:\s*(\d+)/i);
if (!match) {
// Skip malformed header — advance past it
this.buffer = this.buffer.subarray(headerEnd + 4);
continue;
}
this.contentLength = parseInt(match[1], 10);
this.headerComplete = true;
this.buffer = this.buffer.subarray(headerEnd + 4);
}
if (this.buffer.length < this.contentLength) return; // incomplete body
const body = this.buffer.subarray(0, this.contentLength).toString("utf-8");
this.buffer = this.buffer.subarray(this.contentLength);
this.contentLength = -1;
this.headerComplete = false;
try {
const msg = JSON.parse(body) as JsonRpcMessage;
this.onMessage(msg);
} catch {
// Skip malformed JSON
}
}
}
}
/** Encode a JSON-RPC message with Content-Length header */
function encodeMessage(msg: JsonRpcMessage): Buffer {
const body = JSON.stringify(msg);
const bodyBytes = Buffer.byteLength(body, "utf-8");
const header = `Content-Length: ${bodyBytes}\r\n\r\n`;
return Buffer.concat([Buffer.from(header, "ascii"), Buffer.from(body, "utf-8")]);
}
// ── Client Tracking ────────────────────────────────────────────────────────
interface ClientConnection {
id: number;
socket: Socket;
parser: MessageParser;
}
// ── Main Daemon ────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const socketPath = args[0];
const lspCommand = args[1];
const lspArgs = args.slice(2);
const rootDir = process.env.LSP_ROOT_DIR || process.cwd();
const languageId = process.env.LSP_LANGUAGE_ID || "unknown";
const workspaceFoldersJson = process.env.LSP_WORKSPACE_FOLDERS;
const initializationOptionsJson = process.env.LSP_INITIALIZATION_OPTIONS;
const settingsJson = process.env.LSP_SETTINGS;
if (!socketPath || !lspCommand) {
console.error("Usage: lsp-daemon.js <socketPath> <command> [args...]");
process.exit(1);
}
let nextClientId = 1;
let nextDaemonRequestId = 1;
const clients = new Map<number, ClientConnection>();
/** Map daemon request ID → { clientId, originalId } for client-initiated requests */
const pendingRequests = new Map<number | string, { clientId: number; originalId: number | string }>();
/** Map server request ID → clientId for server-initiated requests forwarded to a client */
const pendingServerRequests = new Map<number | string, number>();
/** Parsed settings for workspace/configuration responses */
let settings: Record<string, unknown> | undefined;
let shutdownTimer: ReturnType<typeof setTimeout> | null = null;
let server: Server;
let lspProcess: ChildProcess;
let lspParser: MessageParser;
let lspInitialized = false;
/** The request ID used for the initialize handshake (set by initializeLsp) */
let initRequestId: number | null = null;
/** Resolve/reject callbacks for the initialize promise */
let initResolve: (() => void) | null = null;
let initReject: ((err: Error) => void) | null = null;
// ── Spawn LSP Server ───────────────────────────────────────────────────────
function spawnLspServer(): ChildProcess {
const child = spawn(lspCommand, lspArgs, {
stdio: ["pipe", "pipe", "pipe"],
cwd: rootDir,
env: process.env,
});
child.stderr?.resume(); // prevent blocking
child.on("error", (err) => {
log(`LSP server error: ${err.message}`);
shutdown(1);
});
child.on("exit", (code) => {
log(`LSP server exited with code ${code}`);
shutdown(0);
});
return child;
}
// ── Message Routing ────────────────────────────────────────────────────────
/** Forward a message from a client to the LSP server, rewriting request IDs */
function clientToServer(clientId: number, msg: JsonRpcMessage): void {
if (!lspProcess.stdin?.writable) return;
if (msg.id !== undefined && msg.method) {
// Request from client — rewrite ID to track routing
const daemonId = nextDaemonRequestId++;
pendingRequests.set(daemonId, { clientId, originalId: msg.id });
const rewritten = { ...msg, id: daemonId };
lspProcess.stdin.write(encodeMessage(rewritten));
} else if (msg.id !== undefined && !msg.method) {
// Response from client to a server-initiated request — relay back to LSP server
if (pendingServerRequests.has(msg.id)) {
pendingServerRequests.delete(msg.id);
lspProcess.stdin.write(encodeMessage(msg));
}
} else {
// Notification from client — forward as-is
lspProcess.stdin.write(encodeMessage(msg));
}
}
/**
* Route a message from the LSP server.
* Before initialization: looks for the init response. All other messages are dropped
* (no clients can be connected before init completes anyway).
* After initialization: routes to connected clients.
*/
function handleServerMessage(msg: JsonRpcMessage): void {
if (!lspInitialized) {
// During init, only the initialize response matters
if (initRequestId !== null && msg.id === initRequestId && !msg.method) {
lspInitialized = true;
initRequestId = null;
// Send initialized notification
lspProcess.stdin!.write(encodeMessage({
jsonrpc: "2.0",
method: "initialized",
params: {},
}));
log(`LSP server initialized (${languageId})`);
initResolve?.();
initResolve = null;
initReject = null;
}
// Other pre-init messages (e.g. window/logMessage) are ignored —
// no clients are connected yet anyway
return;
}
serverToClients(msg);
}
/** Route a message from the LSP server to the appropriate client(s) */
function serverToClients(msg: JsonRpcMessage): void {
if (msg.id !== undefined && !msg.method) {
// Response — route to the client that sent the request
const pending = pendingRequests.get(msg.id);
if (pending) {
pendingRequests.delete(msg.id);
const client = clients.get(pending.clientId);
if (client && !client.socket.destroyed) {
const rewritten = { ...msg, id: pending.originalId };
client.socket.write(encodeMessage(rewritten));
}
}
} else if (msg.id !== undefined && msg.method) {
// Server-initiated request (e.g. workspace/configuration) — handle locally or forward to a client
handleServerRequest(msg);
} else if (msg.method && msg.id === undefined) {
// Notification from server — broadcast to ALL clients
const encoded = encodeMessage(msg);
for (const client of clients.values()) {
if (!client.socket.destroyed) {
client.socket.write(encoded);
}
}
}
}
/**
* Handle a server-initiated request.
* Some requests (workspace/configuration) can be answered locally by the daemon.
* Others are forwarded to the first connected client and the response is relayed back.
*/
function handleServerRequest(msg: JsonRpcMessage): void {
if (msg.method === "workspace/configuration") {
// Answer locally — daemon has the settings from LSP_SETTINGS env
const params = msg.params as { items: { section?: string }[] } | undefined;
const items = params?.items ?? [];
const result = items.map((item) => {
if (item.section && settings && item.section in settings) {
return settings[item.section];
}
return {};
});
const response: JsonRpcMessage = { jsonrpc: "2.0", id: msg.id!, result };
lspProcess.stdin!.write(encodeMessage(response));
return;
}
// For other server-initiated requests, forward to first connected client
// and relay the response back to the server
const firstClient = getFirstConnectedClient();
if (firstClient) {
pendingServerRequests.set(msg.id!, firstClient.id);
firstClient.socket.write(encodeMessage(msg));
} else {
// No clients connected — respond with error
const errorResponse: JsonRpcMessage = {
jsonrpc: "2.0",
id: msg.id!,
error: { code: -32001, message: "No clients connected to handle request" },
};
lspProcess.stdin!.write(encodeMessage(errorResponse));
}
}
/** Get first non-destroyed connected client */
function getFirstConnectedClient(): ClientConnection | undefined {
for (const client of clients.values()) {
if (!client.socket.destroyed) return client;
}
return undefined;
}
// ── Initialize LSP Server ──────────────────────────────────────────────────
async function initializeLsp(): Promise<void> {
return new Promise((resolve, reject) => {
const rootUri = pathToFileURL(rootDir).toString();
let workspaceFolders = [{ uri: rootUri, name: rootDir.split("/").pop() ?? "workspace" }];
if (workspaceFoldersJson) {
try {
const parsed = JSON.parse(workspaceFoldersJson);
if (Array.isArray(parsed) && parsed.length > 0) {
workspaceFolders = parsed;
}
} catch { /* use default */ }
}
let initializationOptions: Record<string, unknown> | undefined;
if (initializationOptionsJson) {
try {
initializationOptions = JSON.parse(initializationOptionsJson);
} catch { /* ignore */ }
}
const initParams = {
processId: process.pid,
capabilities: {
textDocument: {
synchronization: { didSave: true, dynamicRegistration: false },
hover: { contentFormat: ["plaintext", "markdown"] },
definition: {},
references: {},
documentSymbol: { hierarchicalDocumentSymbolSupport: true },
rename: { prepareSupport: false },
publishDiagnostics: { relatedInformation: true },
completion: { completionItem: { snippetSupport: false } },
},
workspace: { workspaceFolders: true, symbol: {}, configuration: true },
},
rootUri,
workspaceFolders,
...(initializationOptions ? { initializationOptions } : {}),
};
// Store resolve/reject so handleServerMessage can settle the promise
initResolve = resolve;
initReject = reject;
// Set the init request ID so handleServerMessage knows which response to watch for
initRequestId = nextDaemonRequestId++;
const initRequest: JsonRpcMessage = {
jsonrpc: "2.0",
id: initRequestId,
method: "initialize",
params: initParams,
};
lspProcess.stdin!.write(encodeMessage(initRequest));
// Timeout
setTimeout(() => {
if (!lspInitialized) {
initResolve = null;
initReject = null;
reject(new Error("LSP initialize timed out after 5 minutes"));
}
}, 5 * 60_000);
});
}
// ── Socket Server ──────────────────────────────────────────────────────────
function startSocketServer(): Server {
// Ensure socket directory exists
mkdirSync(dirname(socketPath), { recursive: true });
// Clean up stale socket
if (existsSync(socketPath)) {
try { unlinkSync(socketPath); } catch { /* ignore */ }
}
const srv = createServer((socket) => {
const clientId = nextClientId++;
log(`Client ${clientId} connected`);
// Cancel shutdown timer
if (shutdownTimer) {
clearTimeout(shutdownTimer);
shutdownTimer = null;
}
const parser = new MessageParser((msg) => {
clientToServer(clientId, msg);
});
const conn: ClientConnection = { id: clientId, socket, parser };
clients.set(clientId, conn);
socket.on("data", (data) => parser.feed(Buffer.from(data)));
socket.on("close", () => {
log(`Client ${clientId} disconnected`);
clients.delete(clientId);
// Clean up pending requests for this client
for (const [reqId, pending] of pendingRequests) {
if (pending.clientId === clientId) {
pendingRequests.delete(reqId);
}
}
// Start shutdown timer if no clients remain
if (clients.size === 0) {
log("No clients remaining, will shut down in 5 minutes");
shutdownTimer = setTimeout(() => shutdown(0), 5 * 60 * 1000);
}
});
socket.on("error", (err) => {
log(`Client ${clientId} error: ${err.message}`);
clients.delete(clientId);
});
});
srv.listen(socketPath, () => {
log(`Listening on ${socketPath}`);
// Write PID file
const pidPath = socketPath.replace(/\.sock$/, ".pid");
writeFileSync(pidPath, String(process.pid));
});
srv.on("error", (err) => {
log(`Socket server error: ${err.message}`);
shutdown(1);
});
return srv;
}
// ── Lifecycle ──────────────────────────────────────────────────────────────
function log(msg: string): void {
const ts = new Date().toISOString();
const logPath = socketPath.replace(/\.sock$/, ".log");
try {
appendFileSync(logPath, `[${ts}] ${msg}\n`);
} catch { /* ignore */ }
}
function shutdown(code: number): void {
log("Shutting down daemon");
if (shutdownTimer) clearTimeout(shutdownTimer);
// Close all client connections
for (const client of clients.values()) {
client.socket.destroy();
}
clients.clear();
// Shut down LSP server
if (lspProcess && !lspProcess.killed) {
try {
lspProcess.stdin?.write(encodeMessage({
jsonrpc: "2.0",
id: nextDaemonRequestId++,
method: "shutdown",
params: null,
}));
setTimeout(() => {
lspProcess.stdin?.write(encodeMessage({
jsonrpc: "2.0",
method: "exit",
params: null,
}));
setTimeout(() => {
if (!lspProcess.killed) lspProcess.kill("SIGTERM");
}, 1000);
}, 2000);
} catch {
lspProcess.kill("SIGTERM");
}
}
// Clean up socket and PID files
try { unlinkSync(socketPath); } catch { /* ignore */ }
try { unlinkSync(socketPath.replace(/\.sock$/, ".pid")); } catch { /* ignore */ }
if (server) server.close();
setTimeout(() => process.exit(code), 3000);
}
process.on("SIGTERM", () => shutdown(0));
process.on("SIGINT", () => shutdown(0));
process.on("uncaughtException", (err) => {
log(`Uncaught exception: ${err.message}\n${err.stack ?? ""}`);
shutdown(1);
});
process.on("unhandledRejection", (reason) => {
log(`Unhandled rejection: ${reason}`);
shutdown(1);
});
// ── Start ──────────────────────────────────────────────────────────────────
async function main() {
log(`Starting daemon: ${lspCommand} ${lspArgs.join(" ")} (${languageId})`);
// Parse settings for workspace/configuration responses
if (settingsJson) {
try {
settings = JSON.parse(settingsJson);
} catch (e) { log(`Warning: failed to parse LSP_SETTINGS: ${e}`); }
}
lspProcess = spawnLspServer();
// Single parser for all server messages — routes via handleServerMessage
// which checks lspInitialized to decide whether to look for init response
// or forward to clients. No parser swapping needed.
lspParser = new MessageParser(handleServerMessage);
lspProcess.stdout!.on("data", (data) => lspParser.feed(data));
server = startSocketServer();
try {
await initializeLsp();
} catch (err: any) {
log(`Failed to initialize LSP server: ${err.message}`);
shutdown(1);
}
}
main().catch((err) => {
log(`Daemon fatal error: ${err.message}`);
process.exit(1);
});

View File

@@ -0,0 +1,707 @@
/**
* LSP Manager — manages multiple LSP server instances, one per language.
*
* Lazily starts servers on first use. Auto-detects language from file extension.
* Uses a WorkspaceProvider for workspace detection, multi-root folders, and daemon state.
*/
import { resolve, join, dirname } from "node:path";
import { pathToFileURL } from "node:url";
import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
import { spawn as spawnChild } from "node:child_process";
import { LspClient } from "./lsp-client.js";
import { type WorkspaceProvider, DefaultWorkspaceProvider } from "./workspace-provider.js";
import { getLanguageIdFromPath } from "./shared/language-map.js";
import { DAEMON_SOCKET_READY_DELAY_MS, DAEMON_RETRY_INTERVAL_MS, DAEMON_MAX_RETRIES } from "./shared/timing.js";
export interface ServerConfig {
command: string;
args: string[];
env?: Record<string, string>;
/** LSP initializationOptions passed during the initialize handshake */
initializationOptions?: Record<string, unknown>;
/** Settings returned by workspace/configuration handler (keyed by section) */
settings?: Record<string, unknown>;
}
/** Lifecycle callbacks for UI notifications */
export interface LspManagerCallbacks {
onWorkspaceSetupStart?: () => void;
onWorkspaceSetupEnd?: (success: boolean, duration: number) => void;
onServerStart?: (languageId: string, command: string) => void;
onServerReady?: (languageId: string) => void;
onServerError?: (languageId: string, error: string) => void;
onServerCrash?: (languageId: string, restarting: boolean, attempt: number) => void;
}
/** Default server configurations for common languages */
const DEFAULT_SERVERS: Record<string, ServerConfig> = {
typescript: { command: "typescript-language-server", args: ["--stdio"] },
javascript: { command: "typescript-language-server", args: ["--stdio"] },
typescriptreact: { command: "typescript-language-server", args: ["--stdio"] },
javascriptreact: { command: "typescript-language-server", args: ["--stdio"] },
rust: { command: "rust-analyzer", args: [] },
python: { command: "pyright-langserver", args: ["--stdio"] },
go: { command: "gopls", args: ["serve"] },
java: { command: "jdtls", args: [] },
};
// File extension → language ID mapping is in shared/language-map.ts
export interface ServerStatus {
languageId: string;
command: string;
running: boolean;
diagnosticsCount: number;
/** True if using a shared daemon (vs direct spawn) */
shared: boolean;
}
export class LspManager {
private clients: Map<string, LspClient> = new Map();
private serverConfigs: Map<string, ServerConfig>;
private rootDir: string;
private startingServers: Map<string, Promise<LspClient>> = new Map();
private _workspace: WorkspaceProvider;
private _workspaceReady = false;
private _workspaceReadying: Promise<boolean> | null = null;
private _callbacks: LspManagerCallbacks;
private _sessionId: string;
private _lombokJarPath: string | null = null;
private _shuttingDown = false;
private _restartAttempts: Map<string, number> = new Map();
private _restartBackoff: Map<string, number> = new Map();
private static readonly MAX_RESTART_ATTEMPTS = 3;
private static readonly INITIAL_BACKOFF_MS = 1000;
private static readonly MAX_BACKOFF_MS = 30000;
constructor(rootDir: string, customConfigs?: Record<string, ServerConfig>, callbacks?: LspManagerCallbacks, sessionId?: string, workspace?: WorkspaceProvider) {
this.rootDir = resolve(rootDir);
this.serverConfigs = new Map(Object.entries({
...DEFAULT_SERVERS,
...customConfigs,
}));
this._workspace = workspace ?? new DefaultWorkspaceProvider();
this._callbacks = callbacks ?? {};
this._sessionId = sessionId ?? `${process.pid}-${Date.now()}`;
}
/** Get the workspace provider */
get workspace(): WorkspaceProvider {
return this._workspace;
}
/** Replace the workspace provider (e.g. when an external extension registers one) */
setWorkspaceProvider(provider: WorkspaceProvider): void {
this._workspace = provider;
this._workspaceReady = false;
this._workspaceReadying = null;
}
/** Update or add a server configuration */
setServerConfig(languageId: string, config: ServerConfig): void {
this.serverConfigs.set(languageId, config);
}
/** Set an explicit path to a Lombok jar for Java/jdtls support */
setLombokJar(jarPath: string): void {
this._lombokJarPath = resolve(this.rootDir, jarPath);
}
/** Get the currently configured Lombok jar path (if any) */
getLombokJar(): string | null {
return this.findLombokJar();
}
/**
* Auto-detect Lombok jar.
* Searches: explicit path, LOMBOK_JAR env var, workspace root env/ directories.
*/
private findLombokJar(): string | null {
// 1. Explicit path set via setLombokJar()
if (this._lombokJarPath) {
if (existsSync(this._lombokJarPath)) return this._lombokJarPath;
}
// 2. LOMBOK_JAR environment variable
const envJar = process.env.LOMBOK_JAR;
if (envJar) {
const resolved = resolve(this.rootDir, envJar);
if (existsSync(resolved)) return resolved;
}
// 3. Auto-detect from workspace root (e.g. Brazil workspace env/ directory)
// Search both workspace root and cwd in case they differ
const searchRoots = [this.rootDir];
const wsRoot = this._workspace.workspaceRoot;
if (wsRoot && wsRoot !== this.rootDir) {
searchRoots.unshift(wsRoot);
}
for (const root of searchRoots) {
const envDir = join(root, "env");
if (!existsSync(envDir)) continue;
// env/Lombok-{version}/runtime/lib/lombok-*.jar
try {
const lombokDirs = readdirSync(envDir).filter(d => d.startsWith("Lombok-"));
for (const dir of lombokDirs) {
const libDir = join(envDir, dir, "runtime", "lib");
if (existsSync(libDir)) {
const jars = readdirSync(libDir).filter(f => f.startsWith("lombok-") && f.endsWith(".jar"));
if (jars.length > 0) return join(libDir, jars[0]);
}
}
} catch { /* ignore */ }
// env/gradle-cache-2/org/projectlombok/lombok/{version}/lombok-{version}.jar
const gradleLombok = join(envDir, "gradle-cache-2", "org", "projectlombok", "lombok");
if (existsSync(gradleLombok)) {
try {
const versions = readdirSync(gradleLombok);
for (const ver of versions) {
const jarPath = join(gradleLombok, ver, `lombok-${ver}.jar`);
if (existsSync(jarPath)) return jarPath;
}
} catch { /* ignore */ }
}
}
return null;
}
/** Build initializationOptions for Java (jdtls) with Lombok support */
private getJavaInitializationOptions(): Record<string, unknown> | undefined {
const lombokJar = this.findLombokJar();
if (!lombokJar) return undefined;
// jdtls expects flat dotted keys in settings, not nested objects
return {
settings: {
"java.jdt.ls.vmargs": `-javaagent:${lombokJar}`,
},
};
}
/** Get all configured languages */
getConfiguredLanguages(): string[] {
return [...this.serverConfigs.keys()];
}
/** Resolve a file path to a language ID */
getLanguageId(filePath: string): string | undefined {
return getLanguageIdFromPath(filePath);
}
/** Get a file URI from a path */
getFileUri(filePath: string): string {
const abs = resolve(this.rootDir, filePath);
return pathToFileURL(abs).toString();
}
/** Resolve an absolute path from potentially relative input */
resolvePath(filePath: string): string {
return resolve(this.rootDir, filePath);
}
/**
* Get the LSP client for a language ONLY if it's already running.
* Does not start a new server. Returns null if no client is active.
*/
getRunningClient(languageId: string): LspClient | null {
const existing = this.clients.get(languageId);
if (existing && existing.initialized && !existing.disposed) {
return existing;
}
return null;
}
/**
* Get the LSP client for a file, starting the server if needed.
* Returns null if no server is configured for this file type.
*/
async getClientForFile(filePath: string): Promise<LspClient | null> {
const languageId = this.getLanguageId(filePath);
if (!languageId) return null;
return this.getClientForLanguage(languageId);
}
/**
* Get a user-friendly message about why no client is available for a file.
*/
getUnavailableReason(filePath: string): string {
const languageId = this.getLanguageId(filePath);
if (!languageId) return `No LSP server configured for file type: ${filePath}`;
const waitHint = this.getExpectedStartTime(languageId);
if (this.startingServers.has(languageId)) {
return `LSP server for ${languageId} is starting. ${waitHint} Retry shortly.`;
}
const existing = this.clients.get(languageId);
if (existing && !existing.initialized && !existing.disposed) {
return `LSP server for ${languageId} is initializing. ${waitHint} Retry shortly.`;
}
return `No LSP server available for: ${filePath}. If you just opened this project, call any LSP tool on a file to trigger server startup.`;
}
/** Estimated startup time hint for a language server */
private getExpectedStartTime(languageId: string): string {
switch (languageId) {
case "java":
return "This typically takes 1-5 minutes for Java (project indexing).";
case "rust":
return "This typically takes 30s-2min for Rust (cargo metadata + indexing).";
case "typescript":
case "javascript":
case "typescriptreact":
case "javascriptreact":
return "This typically takes 10-30s for TypeScript/JavaScript.";
case "python":
return "This typically takes 10-30s for Python.";
case "go":
return "This typically takes 10-30s for Go.";
default:
return "This may take a few seconds to a minute.";
}
}
/**
* Get the LSP client for a language, starting the server if needed.
* In Brazil workspaces, connects to a shared daemon (or spawns one).
* Returns null if no server is configured for this language.
*/
async getClientForLanguage(languageId: string): Promise<LspClient | null> {
// Already running?
const existing = this.clients.get(languageId);
if (existing && existing.initialized && !existing.disposed) {
return existing;
}
// Already starting? Don't block — return null so tools can show "starting" message
const starting = this.startingServers.get(languageId);
if (starting) return null;
const config = this.serverConfigs.get(languageId);
if (!config) return null;
// Kick off server start in the background — don't await
const startPromise = this.startServer(languageId, config);
this.startingServers.set(languageId, startPromise);
// Fire-and-forget: clean up on completion or failure
startPromise.catch((err) => {
this.startingServers.delete(languageId);
const message = `Failed to start LSP server for ${languageId}: ${err.message}`;
this._callbacks.onServerError?.(languageId, message);
});
return null; // Server not ready yet
}
/**
* Check if a server is currently starting up for a language.
*/
isServerStarting(languageId: string): boolean {
return this.startingServers.has(languageId);
}
/**
* Eagerly start LSP servers for the given languages in the background.
* Unlike getClientForLanguage (which returns null immediately), this method
* is fire-and-forget — intended for session_start auto-start.
*/
startEagerly(languageIds: string[]): void {
for (const languageId of languageIds) {
// Skip if already running or starting
const existing = this.clients.get(languageId);
if (existing && existing.initialized && !existing.disposed) continue;
if (this.startingServers.has(languageId)) continue;
const config = this.serverConfigs.get(languageId);
if (!config) continue;
const startPromise = this.startServer(languageId, config);
this.startingServers.set(languageId, startPromise);
startPromise.catch((err) => {
this.startingServers.delete(languageId);
this._callbacks.onServerError?.(languageId, `Auto-start failed: ${err.message}`);
});
}
}
/**
* Ensure workspace provider is ready (one-time per session).
* Deduplicates concurrent calls.
*/
private async ensureWorkspace(): Promise<void> {
if (this._workspaceReady) return;
if (this._workspaceReadying) {
await this._workspaceReadying;
return;
}
this._callbacks.onWorkspaceSetupStart?.();
const start = Date.now();
this._workspaceReadying = this._workspace.ensureReady(this._sessionId);
try {
const success = await this._workspaceReadying;
this._callbacks.onWorkspaceSetupEnd?.(success, Date.now() - start);
} finally {
this._workspaceReady = true;
this._workspaceReadying = null;
}
}
private async startServer(languageId: string, config: ServerConfig): Promise<LspClient> {
// Ensure workspace provider is ready (one-time setup)
await this.ensureWorkspace();
const stateDir = this._workspace.stateDir;
const folders = this._workspace.getWorkspaceFolders();
const workspaceFolders = folders.length > 0 ? folders : undefined;
// Build language-specific initializationOptions (e.g. Lombok for Java)
// Use config-level initializationOptions if provided, otherwise fall back to language-specific defaults
const initializationOptions = config.initializationOptions
?? (languageId === "java" ? this.getJavaInitializationOptions() : undefined);
// For Java with Lombok, inject --jvm-arg so jdtls launches with -javaagent
let effectiveArgs = config.args;
if (languageId === "java") {
const lombokJar = this.findLombokJar();
if (lombokJar) {
effectiveArgs = [`--jvm-arg=-javaagent:${lombokJar}`, ...config.args];
}
}
// Callback for auto-restart on unexpected exit
const onUnexpectedExit = (code: number | null) => this.handleUnexpectedExit(languageId, code);
// Try connecting to an existing daemon socket first
const socketPath = this.getSocketPath(languageId);
if (socketPath && this.isDaemonAlive(languageId)) {
this._callbacks.onServerStart?.(languageId, `${config.command} (shared)`);
try {
const client = new LspClient({
command: config.command,
args: effectiveArgs,
rootDir: this.rootDir,
languageId,
socketPath,
initializationOptions,
settings: config.settings,
onUnexpectedExit,
});
await client.start();
this.clients.set(languageId, client);
this.startingServers.delete(languageId);
this._callbacks.onServerReady?.(languageId);
this._restartAttempts.delete(languageId);
this._restartBackoff.delete(languageId);
this.triggerPostInit(languageId, client);
return client;
} catch {
// Daemon may be stale — fall through to spawn new one
}
}
// No existing daemon — spawn one (or start direct if no state directory)
this._callbacks.onServerStart?.(languageId, config.command);
if (stateDir) {
// Spawn daemon and connect via socket
try {
await this.spawnDaemon(languageId, config, effectiveArgs, workspaceFolders, initializationOptions);
// Small delay to let daemon start listening
await new Promise((r) => setTimeout(r, DAEMON_SOCKET_READY_DELAY_MS));
const daemonSocket = this.getSocketPath(languageId)!;
// Retry connection (daemon may still be initializing jdtls which can take minutes)
let lastErr: Error | null = null;
for (let attempt = 0; attempt < DAEMON_MAX_RETRIES; attempt++) {
try {
const client = new LspClient({
command: config.command,
args: effectiveArgs,
rootDir: this.rootDir,
languageId,
socketPath: daemonSocket,
initializationOptions,
settings: config.settings,
onUnexpectedExit,
});
await client.start();
this.clients.set(languageId, client);
this.startingServers.delete(languageId);
this._callbacks.onServerReady?.(languageId);
this._restartAttempts.delete(languageId);
this._restartBackoff.delete(languageId);
this.triggerPostInit(languageId, client);
return client;
} catch (err: any) {
lastErr = err;
// Check if daemon is still alive before retrying
if (!this.isDaemonAlive(languageId)) {
throw new Error(`Daemon for ${languageId} died during startup: ${err.message}`);
}
await new Promise((r) => setTimeout(r, DAEMON_RETRY_INTERVAL_MS));
}
}
throw lastErr ?? new Error("Failed to connect to daemon");
} catch (err: any) {
// Fall back to direct mode — log the daemon failure
this._callbacks.onServerError?.(languageId, `Daemon mode failed, falling back to direct: ${err.message}`);
this.startingServers.delete(languageId);
// Don't throw — try direct mode below
}
}
// Direct mode (no state directory for daemon, or daemon failed)
const client = new LspClient({
command: config.command,
args: effectiveArgs,
rootDir: this.rootDir,
languageId,
env: config.env,
workspaceFolders,
initializationOptions,
settings: config.settings,
onUnexpectedExit,
});
try {
await client.start();
this.clients.set(languageId, client);
this.startingServers.delete(languageId);
this._callbacks.onServerReady?.(languageId);
this._restartAttempts.delete(languageId);
this._restartBackoff.delete(languageId);
this.triggerPostInit(languageId, client);
return client;
} catch (err: any) {
this.startingServers.delete(languageId);
const message = `Failed to start LSP server for ${languageId} (${config.command}): ${err.message}`;
this._callbacks.onServerError?.(languageId, message);
throw new Error(message);
}
}
/**
* Post-initialization hook for language-specific setup.
* For Java/jdtls: triggers a workspace build so diagnostics refresh
* after Lombok annotation processing completes.
*/
private triggerPostInit(languageId: string, client: LspClient): void {
if (languageId === "java") {
// java/buildWorkspace forces jdtls to rebuild and re-publish diagnostics.
// The boolean parameter means "full build" (true) vs incremental (false).
client.sendRequest("java/buildWorkspace", true).catch(() => {
// Not critical — diagnostics will just be stale until a file changes
});
}
}
/** Get the socket path for a language's daemon */
private getSocketPath(languageId: string): string | null {
const stateDir = this._workspace.stateDir;
if (!stateDir) return null;
return join(stateDir, "sockets", `lsp-${languageId}.sock`);
}
/** Check if a daemon is alive for this language */
private isDaemonAlive(languageId: string): boolean {
const stateDir = this._workspace.stateDir;
if (!stateDir) return false;
const pidPath = join(stateDir, "sockets", `lsp-${languageId}.pid`);
try {
if (!existsSync(pidPath)) return false;
const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
if (isNaN(pid)) return false;
process.kill(pid, 0); // throws if not alive
return true;
} catch {
return false;
}
}
/** Spawn an LSP daemon as a detached background process */
private async spawnDaemon(
languageId: string,
config: ServerConfig,
effectiveArgs: string[],
workspaceFolders?: { uri: string; name: string }[],
initializationOptions?: Record<string, unknown>,
): Promise<void> {
const socketPath = this.getSocketPath(languageId)!;
const daemonScript = new URL("./lsp-daemon.ts", import.meta.url).pathname;
const launcherScript = new URL("./lsp-daemon-launcher.cjs", import.meta.url).pathname;
// Resolve jiti from the running process's module context
let jitiPath: string;
try {
jitiPath = require.resolve("jiti");
} catch {
try {
jitiPath = require.resolve("@mariozechner/jiti");
} catch {
throw new Error("Cannot resolve jiti for daemon spawn — jiti not found in module path");
}
}
const env: Record<string, string> = {
...process.env as Record<string, string>,
...(config.env ?? {}),
LSP_ROOT_DIR: this.rootDir,
LSP_LANGUAGE_ID: languageId,
};
if (workspaceFolders && workspaceFolders.length > 0) {
env.LSP_WORKSPACE_FOLDERS = JSON.stringify(workspaceFolders);
}
if (initializationOptions) {
env.LSP_INITIALIZATION_OPTIONS = JSON.stringify(initializationOptions);
}
if (config.settings) {
env.LSP_SETTINGS = JSON.stringify(config.settings);
}
const child = spawnChild(
process.execPath, // node
[launcherScript, jitiPath, daemonScript, socketPath, config.command, ...effectiveArgs],
{
cwd: this.rootDir,
env,
detached: true,
stdio: "ignore",
},
);
child.unref(); // let daemon outlive this process
}
/** Get status of all configured/running servers */
getStatus(): ServerStatus[] {
const statuses: ServerStatus[] = [];
for (const [languageId, config] of this.serverConfigs) {
const client = this.clients.get(languageId);
let diagnosticsCount = 0;
if (client) {
for (const diags of client.getAllDiagnostics().values()) {
diagnosticsCount += diags.length;
}
}
const daemonAlive = this.isDaemonAlive(languageId);
statuses.push({
languageId,
command: config.command,
running: client?.initialized === true && !client.disposed,
diagnosticsCount,
shared: daemonAlive,
});
}
return statuses;
}
/**
* Handle an unexpected server exit. Attempts auto-restart with exponential backoff.
* Gives up after MAX_RESTART_ATTEMPTS failures per language per session.
*/
private handleUnexpectedExit(languageId: string, code: number | null): void {
if (this._shuttingDown) return;
// Clean up the dead client
this.clients.delete(languageId);
this.startingServers.delete(languageId);
const attempts = this._restartAttempts.get(languageId) ?? 0;
if (attempts >= LspManager.MAX_RESTART_ATTEMPTS) {
this._callbacks.onServerError?.(languageId, `LSP server for ${languageId} crashed ${attempts} times — giving up auto-restart`);
this._callbacks.onServerCrash?.(languageId, false, attempts);
return;
}
const backoff = this._restartBackoff.get(languageId) ?? LspManager.INITIAL_BACKOFF_MS;
this._restartAttempts.set(languageId, attempts + 1);
this._restartBackoff.set(languageId, Math.min(backoff * 2, LspManager.MAX_BACKOFF_MS));
this._callbacks.onServerCrash?.(languageId, true, attempts + 1);
setTimeout(() => {
if (this._shuttingDown) return;
const config = this.serverConfigs.get(languageId);
if (!config) return;
const startPromise = this.startServer(languageId, config);
this.startingServers.set(languageId, startPromise);
startPromise.catch((err) => {
this.startingServers.delete(languageId);
this._callbacks.onServerError?.(languageId, `Auto-restart failed for ${languageId}: ${err.message}`);
// Trigger another restart attempt (recursive backoff)
this.handleUnexpectedExit(languageId, null);
});
}, backoff);
}
/** Shut down all clients (disconnect from daemons, kill direct servers) */
async shutdownAll(): Promise<void> {
this._workspace.shutdown();
this._shuttingDown = true;
const shutdowns = [...this.clients.values()].map((client) =>
client.shutdown().catch(() => {})
);
await Promise.all(shutdowns);
this.clients.clear();
this.startingServers.clear();
}
/**
* Restart a specific language server. Shuts down the existing client
* (and kills the daemon if shared), then starts a fresh server.
* Returns once the new server is initialized, or throws on failure.
*/
async restartServer(languageId: string): Promise<void> {
// Shut down existing client
const existing = this.clients.get(languageId);
if (existing) {
await existing.shutdown().catch(() => {});
this.clients.delete(languageId);
}
// Kill the daemon if one is running (so we get a fresh server with new config)
this.killDaemon(languageId);
// Wait for pending starts to clear
const pending = this.startingServers.get(languageId);
if (pending) {
await pending.catch(() => {});
this.startingServers.delete(languageId);
}
// Start fresh
const config = this.serverConfigs.get(languageId);
if (!config) throw new Error(`No server configured for ${languageId}`);
await this.startServer(languageId, config);
}
/** Kill a running daemon for a language (if any) */
private killDaemon(languageId: string): void {
const stateDir = this._workspace.stateDir;
if (!stateDir) return;
const pidPath = join(stateDir, "sockets", `lsp-${languageId}.pid`);
try {
if (!existsSync(pidPath)) return;
const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
if (isNaN(pid)) return;
process.kill(pid, "SIGTERM");
// Clean up socket and pid files
const socketPath = join(stateDir, "sockets", `lsp-${languageId}.sock`);
try { unlinkSync(socketPath); } catch {}
try { unlinkSync(pidPath); } catch {}
} catch {
// Process may already be dead
}
}
}

View File

@@ -0,0 +1,67 @@
/**
* Resolve Provider — determines whether to use LSP or tree-sitter for a file.
*
* LSP takes priority when available. Tree-sitter is the fallback.
*/
import type { LspManager } from "./lsp-manager.js";
import type { TreeSitterManager } from "./tree-sitter/parser-manager.js";
export type ProviderResult =
| { type: "lsp" }
| { type: "tree-sitter"; languageId: string }
| { type: "none"; reason: string };
/**
* Determine the best intelligence provider for a file.
*
* 1. If an LSP server is running and initialized → "lsp"
* 2. If tree-sitter has a grammar for the language → "tree-sitter"
* 3. Otherwise → "none" with a human-readable reason
*/
export function resolveProvider(
filePath: string,
manager: LspManager,
treeSitter: TreeSitterManager | null,
): ProviderResult {
const languageId = manager.getLanguageId(filePath);
if (languageId) {
// Check if LSP is running for this language
const client = manager.getRunningClient(languageId);
if (client) return { type: "lsp" };
// Check if LSP is starting (server will be ready soon)
if (manager.isServerStarting(languageId)) {
// Still prefer tree-sitter for now since LSP isn't ready yet
if (treeSitter?.hasGrammar(languageId)) {
return { type: "tree-sitter", languageId };
}
return { type: "none", reason: `LSP server for ${languageId} is still starting up. Try again in a moment.` };
}
}
// No LSP — try tree-sitter
if (treeSitter) {
const tsLang = treeSitter.getLanguageId(filePath);
if (tsLang && treeSitter.hasGrammar(tsLang)) {
return { type: "tree-sitter", languageId: tsLang };
}
}
// No intelligence available
const ext = filePath.match(/\.[^.]+$/)?.[0] ?? "";
return {
type: "none",
reason: `No code intelligence available for ${ext || "this file type"}. No LSP server is running and no tree-sitter grammar is available.`,
};
}
/**
* Check if an LSP server is available and running for a language.
* Does NOT start a server — just checks if one is already running.
*/
export function hasLspServer(manager: LspManager, languageId: string): boolean {
const client = manager.getRunningClient(languageId);
return client !== null;
}

View File

@@ -0,0 +1,19 @@
/**
* Shared constants — directories to skip, file size limits, etc.
*
* Used across code-overview, workspace-index, and search-engine.
*/
/** Directories to always skip when walking the project tree */
export const SKIP_DIRS = new Set([
"node_modules", ".git", "build", "dist", "target", "out", ".next",
"__pycache__", ".tox", ".venv", "venv", ".mypy_cache", ".pytest_cache",
"vendor", ".gradle", ".idea", ".vscode", ".bemol", "env",
"coverage", ".nyc_output", ".cache",
]);
/** Max file size to parse (500KB) */
export const MAX_FILE_SIZE = 500 * 1024;
/** Max files to index in the initial pass */
export const MAX_INDEX_FILES = 5000;

View File

@@ -0,0 +1,17 @@
/**
* Shared debug logger — lightweight logging for non-fatal errors.
*
* Controlled by the PI_LSP_DEBUG environment variable.
* When enabled, logs to stderr so they're visible but don't interfere
* with JSON-RPC or tool output.
*/
const DEBUG = process.env.PI_LSP_DEBUG === "1" || process.env.PI_LSP_DEBUG === "true";
/** Log a debug message if PI_LSP_DEBUG is enabled */
export function debug(context: string, message: string, error?: unknown): void {
if (!DEBUG) return;
const errMsg = error instanceof Error ? error.message : error ? String(error) : "";
const suffix = errMsg ? `: ${errMsg}` : "";
console.error(`[pi-lsp] ${context}${suffix ? " — " + message + suffix : " — " + message}`);
}

View File

@@ -0,0 +1,29 @@
/**
* Shared formatting utilities for LSP tool results.
*/
import type { Location, LocationLink } from "vscode-languageserver-protocol";
import { fileURLToPath } from "node:url";
import { relative } from "node:path";
/** Format an LSP Location as a relative file:line:col string */
export function formatLocation(loc: Location, rootDir: string): string {
try {
const absPath = fileURLToPath(loc.uri);
const relPath = relative(rootDir, absPath);
return `${relPath}:${loc.range.start.line + 1}:${loc.range.start.character + 1}`;
} catch {
return `${loc.uri}:${loc.range.start.line + 1}:${loc.range.start.character + 1}`;
}
}
/** Format an LSP LocationLink as a relative file:line:col string */
export function formatLocationLink(link: LocationLink, rootDir: string): string {
try {
const absPath = fileURLToPath(link.targetUri);
const relPath = relative(rootDir, absPath);
return `${relPath}:${link.targetSelectionRange.start.line + 1}:${link.targetSelectionRange.start.character + 1}`;
} catch {
return `${link.targetUri}:${link.targetSelectionRange.start.line + 1}:${link.targetSelectionRange.start.character + 1}`;
}
}

View File

@@ -0,0 +1,51 @@
/**
* Shared language mappings — single source of truth for file extension → language ID.
*
* Used by both LspManager and TreeSitterManager.
*/
/** Map file extensions to LSP language IDs */
export const EXT_TO_LANGUAGE: Record<string, string> = {
".ts": "typescript",
".tsx": "typescriptreact",
".js": "javascript",
".jsx": "javascriptreact",
".mts": "typescript",
".mjs": "javascript",
".cts": "typescript",
".cjs": "javascript",
".rs": "rust",
".py": "python",
".go": "go",
".java": "java",
".c": "c",
".h": "c",
".cpp": "cpp",
".cc": "cpp",
".hpp": "cpp",
".cs": "csharp",
".rb": "ruby",
".kt": "kotlin",
".kts": "kotlin",
".scala": "scala",
".ex": "elixir",
".exs": "elixir",
".lua": "lua",
".sh": "bash",
".bash": "bash",
".zsh": "bash",
".swift": "swift",
".zig": "zig",
".json": "json",
".html": "html",
".htm": "html",
".css": "css",
".vue": "vue",
".php": "php",
};
/** Get the language ID for a file path based on extension */
export function getLanguageIdFromPath(filePath: string): string | undefined {
const ext = filePath.match(/\.[^.]+$/)?.[0]?.toLowerCase();
return ext ? EXT_TO_LANGUAGE[ext] : undefined;
}

View File

@@ -0,0 +1,265 @@
/**
* Shared position resolver — resolves a symbol name to a file position.
*
* Used by position-based tools (hover, definition, references, rename, completions)
* to allow the LLM to pass a symbol name instead of exact line/character.
*/
import type { DocumentSymbol, SymbolInformation } from "vscode-languageserver-protocol";
import type { LspManager } from "../lsp-manager.js";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import { extractSymbols, type SymbolInfo } from "../tree-sitter/symbol-extractor.js";
import { getLanguageIdFromPath } from "./language-map.js";
import { readFile } from "node:fs/promises";
export interface ResolvedPosition {
line: number; // 1-indexed (tool convention)
character: number; // 1-indexed
symbolName: string;
source: "lsp" | "tree-sitter";
}
type DocumentSymbolResponse = DocumentSymbol[] | SymbolInformation[] | null;
/**
* Resolve a symbol name to a position in a file.
*
* Priority:
* 1. LSP document symbols (most accurate)
* 2. Tree-sitter symbol extraction (fallback)
*
* Matching priority:
* 1. Exact case-sensitive match
* 2. Case-insensitive exact match
* 3. Substring match (case-insensitive)
* 4. Dot-qualified match (e.g. "MyClass.render" matches "render" inside "MyClass")
*/
export async function resolveSymbolPosition(
filePath: string,
query: string,
manager: LspManager,
treeSitter?: TreeSitterManager | null,
): Promise<ResolvedPosition | null> {
// Try LSP document symbols first
const client = await manager.getClientForFile(filePath).catch(() => null);
if (client) {
const uri = manager.getFileUri(filePath);
try {
const symbols = await client.sendRequest<DocumentSymbolResponse>(
"textDocument/documentSymbol",
{ textDocument: { uri } }
);
if (symbols && symbols.length > 0) {
const match = findInDocumentSymbols(symbols, query);
if (match) return match;
}
} catch { /* fall through to tree-sitter */ }
}
// Try tree-sitter fallback
if (treeSitter) {
try {
const absPath = manager.resolvePath(filePath);
const content = await readFile(absPath, "utf-8");
const languageId = getLanguageIdFromPath(filePath);
if (languageId) {
const tree = await treeSitter.parse(absPath, content);
if (tree) {
const symbols = extractSymbols(tree, languageId);
const match = findInSymbolInfos(symbols, query);
if (match) return match;
}
}
} catch { /* fall through */ }
}
return null;
}
/**
* Get top-level symbol names from a file (for error hints).
*/
export async function getSymbolNames(
filePath: string,
manager: LspManager,
treeSitter?: TreeSitterManager | null,
): Promise<string[]> {
const client = await manager.getClientForFile(filePath).catch(() => null);
if (client) {
const uri = manager.getFileUri(filePath);
try {
const symbols = await client.sendRequest<DocumentSymbolResponse>(
"textDocument/documentSymbol",
{ textDocument: { uri } }
);
if (symbols && symbols.length > 0) {
if ("selectionRange" in symbols[0]) {
return (symbols as DocumentSymbol[]).map(s => s.name);
}
return (symbols as SymbolInformation[]).map(s => s.name);
}
} catch { /* fall through */ }
}
if (treeSitter) {
try {
const absPath = manager.resolvePath(filePath);
const content = await readFile(absPath, "utf-8");
const languageId = getLanguageIdFromPath(filePath);
if (languageId) {
const tree = await treeSitter.parse(absPath, content);
if (tree) {
const symbols = extractSymbols(tree, languageId);
return symbols.map(s => s.name);
}
}
} catch { /* fall through */ }
}
return [];
}
// --- LSP DocumentSymbol matching ---
function findInDocumentSymbols(
symbols: DocumentSymbol[] | SymbolInformation[],
query: string,
): ResolvedPosition | null {
if (symbols.length === 0) return null;
// Check if these are DocumentSymbol (hierarchical) or SymbolInformation (flat)
if ("selectionRange" in symbols[0]) {
return findInHierarchicalSymbols(symbols as DocumentSymbol[], query);
}
return findInFlatSymbols(symbols as SymbolInformation[], query);
}
interface SymbolCandidate {
name: string;
line: number; // 1-indexed
character: number; // 1-indexed
parent?: string;
}
function findInHierarchicalSymbols(
symbols: DocumentSymbol[],
query: string,
): ResolvedPosition | null {
const candidates = flattenDocumentSymbols(symbols);
return matchCandidates(candidates, query, "lsp");
}
function flattenDocumentSymbols(
symbols: DocumentSymbol[],
parent?: string,
): SymbolCandidate[] {
const result: SymbolCandidate[] = [];
for (const sym of symbols) {
result.push({
name: sym.name,
line: sym.selectionRange.start.line + 1,
character: sym.selectionRange.start.character + 1,
parent,
});
if (sym.children && sym.children.length > 0) {
result.push(...flattenDocumentSymbols(sym.children, sym.name));
}
}
return result;
}
function findInFlatSymbols(
symbols: SymbolInformation[],
query: string,
): ResolvedPosition | null {
const candidates: SymbolCandidate[] = symbols.map(sym => ({
name: sym.name,
line: sym.location.range.start.line + 1,
character: sym.location.range.start.character + 1,
parent: sym.containerName ?? undefined,
}));
return matchCandidates(candidates, query, "lsp");
}
// --- Tree-sitter SymbolInfo matching ---
function findInSymbolInfos(
symbols: SymbolInfo[],
query: string,
): ResolvedPosition | null {
const candidates = flattenSymbolInfos(symbols);
return matchCandidates(candidates, query, "tree-sitter");
}
function flattenSymbolInfos(
symbols: SymbolInfo[],
parent?: string,
): SymbolCandidate[] {
const result: SymbolCandidate[] = [];
for (const sym of symbols) {
result.push({
name: sym.name,
line: sym.line,
character: 1, // tree-sitter symbols don't have column precision for the name
parent,
});
if (sym.children && sym.children.length > 0) {
result.push(...flattenSymbolInfos(sym.children, sym.name));
}
}
return result;
}
// --- Shared matching logic ---
function matchCandidates(
candidates: SymbolCandidate[],
query: string,
source: "lsp" | "tree-sitter",
): ResolvedPosition | null {
// Support dot-qualified queries like "MyClass.render"
const dotIndex = query.lastIndexOf(".");
let parentFilter: string | undefined;
let symbolQuery: string;
if (dotIndex > 0) {
parentFilter = query.slice(0, dotIndex);
symbolQuery = query.slice(dotIndex + 1);
} else {
symbolQuery = query;
}
// If dot-qualified, try to match parent.child first
if (parentFilter) {
const qualified = candidates.filter(
c => c.parent?.toLowerCase() === parentFilter!.toLowerCase()
);
const match = matchByPriority(qualified, symbolQuery, source);
if (match) return match;
}
// Fall back to unqualified match across all candidates
return matchByPriority(candidates, symbolQuery, source);
}
function matchByPriority(
candidates: SymbolCandidate[],
query: string,
source: "lsp" | "tree-sitter",
): ResolvedPosition | null {
const queryLower = query.toLowerCase();
// 1. Exact case-sensitive match
const exact = candidates.find(c => c.name === query);
if (exact) return { line: exact.line, character: exact.character, symbolName: exact.name, source };
// 2. Case-insensitive exact match
const caseInsensitive = candidates.find(c => c.name.toLowerCase() === queryLower);
if (caseInsensitive) return { line: caseInsensitive.line, character: caseInsensitive.character, symbolName: caseInsensitive.name, source };
// 3. Substring match (case-insensitive)
const substring = candidates.find(c => c.name.toLowerCase().includes(queryLower));
if (substring) return { line: substring.line, character: substring.character, symbolName: substring.name, source };
return null;
}

View File

@@ -0,0 +1,18 @@
/**
* Shared timing constants for LSP operations.
*/
/** Delay (ms) to wait for LSP to publish diagnostics after a file change */
export const DIAGNOSTIC_SETTLE_DELAY_MS = 1500;
/** Delay (ms) to wait for a daemon socket to start listening after spawn */
export const DAEMON_SOCKET_READY_DELAY_MS = 500;
/** Interval (ms) between retries when connecting to a daemon */
export const DAEMON_RETRY_INTERVAL_MS = 5000;
/** Maximum number of retries when connecting to a daemon (5 min total at 5s intervals) */
export const DAEMON_MAX_RETRIES = 60;
/** Delay (ms) to let LSP process a synthetic didChange before requesting completions */
export const SYNTHETIC_DOT_SETTLE_DELAY_MS = 100;

View File

@@ -0,0 +1,284 @@
/**
* lsp_code_actions — Get available code actions (quick fixes, refactorings) at a position.
*/
import { Type } from "@sinclair/typebox";
import type {
CodeAction,
Command,
Diagnostic,
TextEdit,
WorkspaceEdit,
} from "vscode-languageserver-protocol";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import type { LspManager } from "../lsp-manager.js";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import { resolveSymbolPosition, getSymbolNames } from "../shared/resolve-position.js";
import { fileURLToPath } from "node:url";
import { relative } from "node:path";
type CodeActionResponse = (CodeAction | Command)[] | null;
const CodeActionsParams = Type.Object({
path: Type.String({ description: "File path" }),
line: Type.Optional(Type.Number({ description: "Line number (1-indexed). Required unless query is provided." })),
character: Type.Optional(Type.Number({ description: "Column number (1-indexed). Required unless query is provided." })),
query: Type.Optional(Type.String({ description: "Symbol name to find in the file. Alternative to line/character — resolves the symbol's position automatically." })),
endLine: Type.Optional(Type.Number({ description: "End line for range selection (1-indexed). Defaults to line." })),
endCharacter: Type.Optional(Type.Number({ description: "End column for range selection (1-indexed). Defaults to character." })),
kind: Type.Optional(Type.String({ description: 'Filter by action kind (e.g., "quickfix", "refactor", "source")' })),
});
interface CodeActionsDetails {
count: number;
preferredCount: number;
}
/** Check if a range contains a position (all 0-indexed) */
function rangeContainsPosition(
range: { start: { line: number; character: number }; end: { line: number; character: number } },
line: number,
character: number,
): boolean {
if (line < range.start.line || line > range.end.line) return false;
if (line === range.start.line && character < range.start.character) return false;
if (line === range.end.line && character > range.end.character) return false;
return true;
}
/** Format a workspace edit's changes into readable lines */
function formatEditSummary(edit: WorkspaceEdit, rootDir: string): string[] {
const lines: string[] = [];
if (edit.documentChanges) {
for (const change of edit.documentChanges) {
if ("textDocument" in change && "edits" in change) {
let relPath: string;
try { relPath = relative(rootDir, fileURLToPath(change.textDocument.uri)); } catch { relPath = change.textDocument.uri; }
for (const textEdit of (change.edits as TextEdit[]).slice(0, 5)) {
const ln = textEdit.range.start.line + 1;
const col = textEdit.range.start.character + 1;
const endLn = textEdit.range.end.line + 1;
const endCol = textEdit.range.end.character + 1;
const newText = textEdit.newText.length > 60
? textEdit.newText.slice(0, 57) + "..."
: textEdit.newText;
if (textEdit.range.start.line === textEdit.range.end.line && textEdit.range.start.character === textEdit.range.end.character) {
lines.push(` ${relPath}:${ln}:${col} insert "${newText.replace(/\n/g, "\\n")}"`);
} else {
lines.push(` ${relPath}:${ln}:${col}-${endLn}:${endCol} → "${newText.replace(/\n/g, "\\n")}"`);
}
}
const remaining = (change.edits as TextEdit[]).length - 5;
if (remaining > 0) lines.push(` ... and ${remaining} more edits in ${relPath}`);
}
}
}
const changes = edit.changes ?? {};
for (const [uri, edits] of Object.entries(changes)) {
let relPath: string;
try { relPath = relative(rootDir, fileURLToPath(uri)); } catch { relPath = uri; }
for (const textEdit of edits.slice(0, 5)) {
const ln = textEdit.range.start.line + 1;
const col = textEdit.range.start.character + 1;
const endLn = textEdit.range.end.line + 1;
const endCol = textEdit.range.end.character + 1;
const newText = textEdit.newText.length > 60
? textEdit.newText.slice(0, 57) + "..."
: textEdit.newText;
if (textEdit.range.start.line === textEdit.range.end.line && textEdit.range.start.character === textEdit.range.end.character) {
lines.push(` ${relPath}:${ln}:${col} insert "${newText.replace(/\n/g, "\\n")}"`);
} else {
lines.push(` ${relPath}:${ln}:${col}-${endLn}:${endCol} → "${newText.replace(/\n/g, "\\n")}"`);
}
}
const remaining = edits.length - 5;
if (remaining > 0) lines.push(` ... and ${remaining} more edits in ${relPath}`);
}
return lines;
}
/** Check if a response item is a CodeAction (vs a Command) */
function isCodeAction(item: CodeAction | Command): item is CodeAction {
return "kind" in item || "edit" in item || "diagnostics" in item || "isPreferred" in item;
}
export function createCodeActionsTool(
manager: LspManager,
treeSitter?: TreeSitterManager | null,
): ToolDefinition<typeof CodeActionsParams, CodeActionsDetails> {
return {
name: "lsp_code_actions",
label: "LSP Code Actions",
description: "Get available code actions (quick fixes, refactorings, source actions) at a position or range. Returns actionable fixes the LSP server can suggest — auto-imports, remove unused, extract method, etc.",
promptSnippet: "Get available code actions (quick fixes, refactorings) at a file position via LSP. Use after lsp_diagnostics shows errors to find auto-fixes.",
parameters: CodeActionsParams,
async execute(_toolCallId, params) {
const filePath = params.path.replace(/^@/, "");
let line = params.line;
let character = params.character;
let resolvedFrom: string | undefined;
// Resolve position from query if line/character not provided
if ((line === undefined || character === undefined) && params.query) {
const resolved = await resolveSymbolPosition(filePath, params.query, manager, treeSitter);
if (resolved) {
line = resolved.line;
character = resolved.character;
resolvedFrom = `Resolved "${params.query}" → ${resolved.symbolName} at ${line}:${character} [${resolved.source}]`;
} else {
const names = await getSymbolNames(filePath, manager, treeSitter);
const hint = names.length > 0 ? `\nAvailable symbols: ${names.slice(0, 20).join(", ")}` : "";
return { content: [{ type: "text", text: `Could not find symbol "${params.query}" in ${filePath}${hint}` }], details: { count: 0, preferredCount: 0 } };
}
}
if (line === undefined || character === undefined) {
return { content: [{ type: "text", text: "Either line/character or query is required." }], details: { count: 0, preferredCount: 0 } };
}
const client = await manager.getClientForFile(filePath).catch(() => null);
if (!client) {
return { content: [{ type: "text", text: manager.getUnavailableReason(filePath) }], details: { count: 0, preferredCount: 0 } };
}
// Check if server supports code actions
const caps = client.serverCapabilities;
if (caps && !caps.codeActionProvider) {
return {
content: [{ type: "text", text: "LSP server for this file does not support code actions." }],
details: { count: 0, preferredCount: 0 },
};
}
const uri = manager.getFileUri(filePath);
const startLine = line - 1;
const startChar = character - 1;
const endLine = (params.endLine ?? line) - 1;
const endChar = (params.endCharacter ?? character) - 1;
// Collect diagnostics that overlap with the requested range
const allDiags = client.getDiagnostics(uri) ?? [];
const rangeDiags = allDiags.filter((d: Diagnostic) =>
rangeContainsPosition(d.range, startLine, startChar) ||
rangeContainsPosition({ start: { line: startLine, character: startChar }, end: { line: endLine, character: endChar } }, d.range.start.line, d.range.start.character)
);
try {
const response = await client.sendRequest<CodeActionResponse>("textDocument/codeAction", {
textDocument: { uri },
range: {
start: { line: startLine, character: startChar },
end: { line: endLine, character: endChar },
},
context: {
diagnostics: rangeDiags,
only: params.kind ? [params.kind] : undefined,
},
});
if (!response || response.length === 0) {
const text = resolvedFrom
? `${resolvedFrom}\n\nNo code actions available at this position.`
: "No code actions available at this position.";
return { content: [{ type: "text", text }], details: { count: 0, preferredCount: 0 } };
}
// Separate CodeActions from Commands, sort: preferred first, then by kind
const actions: CodeAction[] = [];
const commands: Command[] = [];
for (const item of response) {
if (isCodeAction(item)) {
actions.push(item);
} else {
commands.push(item);
}
}
// Sort: preferred first, then quickfix > refactor > source > other
const kindOrder: Record<string, number> = { quickfix: 0, refactor: 1, source: 2 };
actions.sort((a, b) => {
if (a.isPreferred && !b.isPreferred) return -1;
if (!a.isPreferred && b.isPreferred) return 1;
const aKind = a.kind?.split(".")[0] ?? "zzz";
const bKind = b.kind?.split(".")[0] ?? "zzz";
return (kindOrder[aKind] ?? 3) - (kindOrder[bKind] ?? 3);
});
const rootDir = manager.resolvePath(".");
const outputLines: string[] = [];
let preferredCount = 0;
for (let i = 0; i < actions.length; i++) {
const action = actions[i];
const preferred = action.isPreferred ? "★ " : "";
if (action.isPreferred) preferredCount++;
const kindStr = action.kind ? ` [${action.kind}]` : "";
outputLines.push(` ${i + 1}. ${preferred}${action.title}${kindStr}`);
if (action.edit) {
const editLines = formatEditSummary(action.edit, rootDir);
outputLines.push(...editLines);
} else if (action.command && !action.edit) {
outputLines.push(` (command: ${action.command.title || action.command.command})`);
} else {
outputLines.push(" (resolve required)");
}
}
// Append plain commands at the end
for (const cmd of commands) {
outputLines.push(`${cmd.title} (command-only, requires IDE execution)`);
}
const totalCount = actions.length + commands.length;
const header = `${totalCount} code action(s) at ${filePath}:${line}:${character}`;
const preferredNote = preferredCount > 0 ? ` (${preferredCount} preferred)` : "";
let text = `${header}${preferredNote}\n\n${outputLines.join("\n")}`;
if (resolvedFrom) text = `${resolvedFrom}\n\n${text}`;
return {
content: [{ type: "text", text }],
details: { count: totalCount, preferredCount },
};
} catch (err: any) {
return {
content: [{ type: "text", text: `LSP code action request failed: ${err.message}` }],
details: { count: 0, preferredCount: 0 },
};
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("lsp_code_actions "));
if (args.query && !args.line) {
text += theme.fg("accent", args.path);
text += theme.fg("muted", ` query="${args.query}"`);
} else if (args.endLine) {
text += theme.fg("accent", `${args.path}:${args.line}:${args.character}-${args.endLine}:${args.endCharacter}`);
} else {
text += theme.fg("accent", `${args.path}:${args.line}:${args.character}`);
}
if (args.kind) {
text += theme.fg("dim", ` [${args.kind}]`);
}
return new Text(text, 0, 0);
},
renderResult(result, { isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Loading..."), 0, 0);
const details = result.details;
if (!details || details.count === 0) {
return new Text(theme.fg("dim", "No code actions available"), 0, 0);
}
const preferred = details.preferredCount > 0 ? ` (${details.preferredCount} preferred)` : "";
return new Text(theme.fg("success", `${details.count} action(s)${preferred}`), 0, 0);
},
};
}

View File

@@ -0,0 +1,233 @@
/**
* code_overview — Summarize project structure, key files, and symbols.
*
* Uses tree-sitter for symbol extraction. Shows:
* - Directory tree (respecting .gitignore, max depth ~3)
* - Top-level symbols per key file
* - Dependency manifests
*/
import { Type } from "@sinclair/typebox";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { truncateHead, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import { resolve, relative } from "node:path";
import { readdir, readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import type { WorkspaceIndex } from "../tree-sitter/workspace-index.js";
import { extractSymbols } from "../tree-sitter/symbol-extractor.js";
import { SKIP_DIRS } from "../shared/constants.js";
// Shared constant imported from ../shared/constants.ts
/** Known dependency manifest files */
const MANIFESTS = [
"package.json", "Cargo.toml", "go.mod", "go.sum",
"pom.xml", "build.gradle", "build.gradle.kts",
"requirements.txt", "pyproject.toml", "setup.py", "setup.cfg",
"Gemfile", "Makefile", "CMakeLists.txt",
];
/** Known entry point patterns */
const ENTRY_PATTERNS = [
"index.ts", "index.js", "main.ts", "main.js", "app.ts", "app.js",
"main.py", "app.py", "__init__.py",
"main.rs", "lib.rs",
"main.go",
"Main.java", "Application.java",
];
const MAX_TREE_DEPTH = 3;
const MAX_TREE_ENTRIES = 200;
const OverviewParams = Type.Object({
path: Type.Optional(Type.String({ description: "Root directory to analyze (defaults to project root)" })),
depth: Type.Optional(Type.Number({ description: "Maximum directory depth (default: 3)" })),
});
interface OverviewDetails { files: number; symbols: number }
export function createCodeOverviewTool(
rootDirOrGetter: string | (() => string),
treeSitter: TreeSitterManager,
workspaceIndex: WorkspaceIndex,
): ToolDefinition<typeof OverviewParams, OverviewDetails> {
const getRootDir = typeof rootDirOrGetter === "function" ? rootDirOrGetter : () => rootDirOrGetter;
return {
name: "code_overview",
label: "Code Overview",
description: "Summarize project structure: directory tree, top-level symbols per key file, dependency manifests. Uses tree-sitter for symbol extraction — no LSP required.",
promptSnippet: "Get a structural overview of the project (directories, key files, symbols)",
parameters: OverviewParams,
async execute(_toolCallId, params) {
const rootDir = getRootDir();
const targetDir = resolve(rootDir, params.path ?? ".");
const maxDepth = params.depth ?? MAX_TREE_DEPTH;
const sections: string[] = [];
let totalFiles = 0;
let totalSymbols = 0;
// 1. Directory tree
const treeLines: string[] = [];
await buildTree(targetDir, "", 0, maxDepth, treeLines);
totalFiles = treeLines.filter((l) => !l.endsWith("/")).length;
sections.push("## Directory Structure\n\n```\n" + treeLines.join("\n") + "\n```");
// 2. Dependency manifests
const manifests: string[] = [];
for (const m of MANIFESTS) {
const path = resolve(targetDir, m);
if (existsSync(path)) {
manifests.push(m);
}
}
if (manifests.length > 0) {
sections.push("## Dependency Manifests\n\n" + manifests.map((m) => `- ${m}`).join("\n"));
}
// 3. Key files with symbols
const keyFiles = await findKeyFiles(targetDir);
if (keyFiles.length > 0) {
const symbolSections: string[] = [];
for (const file of keyFiles.slice(0, 10)) {
try {
const content = await readFile(file, "utf-8");
const languageId = treeSitter.getLanguageId(file);
if (!languageId) continue;
const tree = await treeSitter.parse(file, content);
if (!tree) continue;
const symbols = extractSymbols(tree, languageId);
if (symbols.length === 0) continue;
const relPath = relative(targetDir, file);
const symbolLines = symbols.slice(0, 20).map((s) => {
const kindNames: Record<number, string> = {
5: "class", 6: "method", 10: "enum", 11: "interface",
12: "function", 13: "variable", 14: "constant", 22: "struct",
};
const kind = kindNames[s.kind] ?? "symbol";
return ` ${kind} ${s.name} (line ${s.line})`;
});
if (symbols.length > 20) {
symbolLines.push(` ... and ${symbols.length - 20} more`);
}
totalSymbols += symbols.length;
symbolSections.push(`### ${relPath}\n${symbolLines.join("\n")}`);
} catch { /* skip */ }
}
if (symbolSections.length > 0) {
sections.push("## Key Files\n\n" + symbolSections.join("\n\n"));
}
}
// 4. Workspace index stats
if (workspaceIndex.isBuilt) {
const stats = workspaceIndex.getStats();
sections.push(`## Index Stats\n\n- ${stats.files} indexed files\n- ${stats.symbols} symbols`);
}
const output = sections.join("\n\n");
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let text = truncation.content;
if (truncation.truncated) {
text += `\n\n[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`;
}
return {
content: [{ type: "text", text }],
details: { files: totalFiles, symbols: totalSymbols },
};
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("code_overview "));
if (args.path) text += theme.fg("accent", args.path);
else text += theme.fg("dim", "(project root)");
return new Text(text, 0, 0);
},
renderResult(result, { isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Analyzing..."), 0, 0);
const d = result.details;
if (!d) return new Text(theme.fg("dim", "No overview"), 0, 0);
return new Text(theme.fg("success", `${d.files} files, ${d.symbols} symbols`), 0, 0);
},
};
}
/** Build a directory tree representation */
async function buildTree(
dir: string,
prefix: string,
depth: number,
maxDepth: number,
lines: string[],
): Promise<void> {
if (depth > maxDepth || lines.length > MAX_TREE_ENTRIES) return;
try {
const entries = await readdir(dir, { withFileTypes: true });
// Sort: directories first, then files
const sorted = entries
.filter((e) => !e.name.startsWith(".") || e.name === ".github")
.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
});
for (let i = 0; i < sorted.length; i++) {
if (lines.length > MAX_TREE_ENTRIES) {
lines.push(`${prefix}... (truncated)`);
return;
}
const entry = sorted[i];
const isLast = i === sorted.length - 1;
const connector = isLast ? "└── " : "├── ";
const childPrefix = isLast ? " " : "│ ";
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) {
lines.push(`${prefix}${connector}${entry.name}/ (skipped)`);
continue;
}
lines.push(`${prefix}${connector}${entry.name}/`);
await buildTree(
resolve(dir, entry.name),
prefix + childPrefix,
depth + 1,
maxDepth,
lines,
);
} else {
lines.push(`${prefix}${connector}${entry.name}`);
}
}
} catch { /* permission denied */ }
}
/** Find key/entry-point files in the project */
async function findKeyFiles(dir: string): Promise<string[]> {
const found: string[] = [];
// Check common entry point locations
const searchDirs = [dir, resolve(dir, "src"), resolve(dir, "lib"), resolve(dir, "app")];
for (const searchDir of searchDirs) {
for (const pattern of ENTRY_PATTERNS) {
const fullPath = resolve(searchDir, pattern);
if (existsSync(fullPath)) {
found.push(fullPath);
}
}
}
return [...new Set(found)]; // deduplicate
}

View File

@@ -0,0 +1,168 @@
/**
* code_rewrite — Transform code matching a structural pattern into a replacement.
*
* Matches code by AST structure (like ast_search), then applies a replacement
* template that can reference captured metavariables. Supports dry-run preview.
*/
import { Type } from "@sinclair/typebox";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { truncateHead, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
import { relative } from "node:path";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import { compilePattern } from "../tree-sitter/pattern-compiler.js";
import { searchFiles } from "../tree-sitter/search-engine.js";
import { computeRewrites, applyRewrites } from "../tree-sitter/rewrite-engine.js";
/** Callback to notify when files are modified by rewrites */
export interface RewriteFileChangeCallback {
onFileModified(filePath: string): void;
}
const RewriteParams = Type.Object({
pattern: Type.String({ description: "Structural pattern to match (with $NAME / $$$NAME metavariables)" }),
replacement: Type.String({ description: "Replacement template using the same metavariables" }),
language: Type.String({ description: "Target language (typescript, python, rust, java, etc.)" }),
path: Type.Optional(Type.String({ description: "File or directory scope (default: workspace root)" })),
dry_run: Type.Optional(Type.Boolean({ description: "Preview changes without applying (default: true)" })),
});
interface RewriteDetails {
matchCount: number;
filesModified: number;
dryRun: boolean;
}
export function createCodeRewriteTool(
rootDirOrGetter: string | (() => string),
treeSitter: TreeSitterManager,
fileChangeCallback?: RewriteFileChangeCallback,
): ToolDefinition<typeof RewriteParams> {
const getRootDir = typeof rootDirOrGetter === "function" ? rootDirOrGetter : () => rootDirOrGetter;
return {
name: "code_rewrite",
label: "Code Rewrite",
description:
"Transform code matching a structural pattern into a replacement. " +
"Use $NAME to capture and reuse single nodes, $$$NAME for sequences. " +
"Defaults to dry-run mode (preview only). Set dry_run=false to apply changes. " +
"For symbol renames, prefer lsp_rename instead (semantically correct).",
parameters: RewriteParams,
async execute(_toolCallId, params) {
const rootDir = getRootDir();
const { pattern: patternStr, replacement, language, path, dry_run } = params;
const isDryRun = dry_run !== false; // default true
// Compile the pattern
let compiled;
try {
compiled = await compilePattern(patternStr, language, treeSitter);
} catch (e: any) {
return {
content: [{ type: "text", text: `Error: ${e.message}` }],
details: { matchCount: 0, filesModified: 0, dryRun: isDryRun },
};
}
// Validate that replacement references only known metavars
const knownVars = new Set(compiled.metavars);
const refRe = /\$\$\$([A-Z_][A-Z0-9_]*)|\$([A-Z_][A-Z0-9_]*)/g;
let refMatch;
while ((refMatch = refRe.exec(replacement)) !== null) {
const name = refMatch[1] ?? refMatch[2];
if (!knownVars.has(name)) {
return {
content: [{ type: "text", text: `Error: Replacement references $${name} but pattern doesn't capture it. Pattern captures: ${compiled.metavars.join(", ") || "(none)"}` }],
details: { matchCount: 0, filesModified: 0, dryRun: isDryRun },
};
}
}
// Find matches
const matches = await searchFiles(compiled, rootDir, treeSitter, {
path,
maxResults: 500,
});
if (matches.length === 0) {
return {
content: [{ type: "text", text: "No matches found. No changes to make." }],
details: { matchCount: 0, filesModified: 0, dryRun: isDryRun },
};
}
if (isDryRun) {
// Preview mode
const changes = computeRewrites(matches, replacement);
const lines: string[] = [];
lines.push(`Dry run: ${changes.length} change${changes.length !== 1 ? "s" : ""} would be made:\n`);
for (const c of changes) {
const relPath = relative(rootDir, c.file);
lines.push(`${relPath}:${c.line}:${c.column}`);
const beforeLines = c.before.split("\n");
const afterLines = c.after.split("\n");
for (const l of beforeLines) {
lines.push(` - ${l}`);
}
for (const l of afterLines) {
lines.push(` + ${l}`);
}
lines.push("");
}
lines.push("Run with dry_run=false to apply these changes.");
const text = lines.join("\n");
const truncation = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let output = truncation.content;
if (truncation.truncated) {
output += `\n\n[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`;
}
const uniqueFiles = new Set(changes.map((c) => c.file));
return {
content: [{ type: "text", text: output }],
details: { matchCount: matches.length, filesModified: uniqueFiles.size, dryRun: true },
};
}
// Apply mode
const result = await applyRewrites(matches, replacement);
// Notify FileSync about modified files so LSP servers get updated
if (fileChangeCallback && result.filesModified > 0) {
const modifiedFiles = new Set(result.changes.map((c) => c.file));
for (const file of modifiedFiles) {
fileChangeCallback.onFileModified(file);
}
}
const lines: string[] = [];
lines.push(`Applied ${result.changes.length} change${result.changes.length !== 1 ? "s" : ""} across ${result.filesModified} file${result.filesModified !== 1 ? "s" : ""}:\n`);
for (const c of result.changes) {
const relPath = relative(rootDir, c.file);
const beforeShort = c.before.length > 80 ? c.before.slice(0, 80) + "..." : c.before;
const afterShort = c.after.length > 80 ? c.after.slice(0, 80) + "..." : c.after;
lines.push(`${relPath}:${c.line}${beforeShort}${afterShort}`);
}
const text = lines.join("\n");
const truncation = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let output = truncation.content;
if (truncation.truncated) {
output += `\n\n[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`;
}
return {
content: [{ type: "text", text: output }],
details: { matchCount: matches.length, filesModified: result.filesModified, dryRun: false },
};
},
};
}

View File

@@ -0,0 +1,110 @@
/**
* ast_search — Find code matching a structural pattern with metavariables.
*
* Uses tree-sitter AST matching to find code by structure, not text.
* Supports `$NAME` for single-node wildcards and `$$$NAME` for variadic.
*/
import { Type } from "@sinclair/typebox";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { truncateHead, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
import { relative } from "node:path";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import { compilePattern } from "../tree-sitter/pattern-compiler.js";
import { searchFiles } from "../tree-sitter/search-engine.js";
const SearchParams = Type.Object({
pattern: Type.String({ description: "Structural pattern with metavariables ($NAME for single node, $$$NAME for variadic)" }),
language: Type.String({ description: "Target language (typescript, python, rust, java, etc.)" }),
path: Type.Optional(Type.String({ description: "File or directory to search (default: workspace root)" })),
max_results: Type.Optional(Type.Number({ description: "Maximum results to return (default: 50)" })),
});
interface SearchDetails {
matchCount: number;
filesSearched: number;
}
export function createCodeSearchTool(
rootDirOrGetter: string | (() => string),
treeSitter: TreeSitterManager,
): ToolDefinition<typeof SearchParams> {
const getRootDir = typeof rootDirOrGetter === "function" ? rootDirOrGetter : () => rootDirOrGetter;
return {
name: "ast_search",
label: "Code Search",
description:
"Find code matching a structural pattern using AST matching. " +
"Use $NAME to match any single node, $$$NAME to match zero-or-more nodes. " +
"More precise than grep — matches code structure, not text.",
parameters: SearchParams,
async execute(_toolCallId, params) {
const rootDir = getRootDir();
const { pattern: patternStr, language, path, max_results } = params;
// Compile the pattern
let compiled;
try {
compiled = await compilePattern(patternStr, language, treeSitter);
} catch (e: any) {
return {
content: [{ type: "text", text: `Error: ${e.message}` }],
details: { matchCount: 0, filesSearched: 0 },
};
}
// Run the search
const matches = await searchFiles(compiled, rootDir, treeSitter, {
path,
maxResults: max_results ?? 50,
});
if (matches.length === 0) {
return {
content: [{ type: "text", text: "No matches found." }],
details: { matchCount: 0, filesSearched: 0 },
};
}
// Format results
const lines: string[] = [];
lines.push(`Found ${matches.length} match${matches.length !== 1 ? "es" : ""}:\n`);
for (const m of matches) {
const relPath = relative(rootDir, m.file);
const matchText = m.matchedText.length > 200
? m.matchedText.slice(0, 200) + "..."
: m.matchedText;
lines.push(`${relPath}:${m.line}:${m.column}`);
lines.push(` ${matchText.replace(/\n/g, "\n ")}`);
// Show captures
const captureEntries = Object.entries(m.captures);
if (captureEntries.length > 0) {
for (const [name, value] of captureEntries) {
const displayValue = value.length > 100 ? value.slice(0, 100) + "..." : value;
lines.push(` $${name} = ${displayValue}`);
}
}
lines.push("");
}
const text = lines.join("\n");
const uniqueFiles = new Set(matches.map((m) => m.file));
const truncation = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let output = truncation.content;
if (truncation.truncated) {
output += `\n\n[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`;
}
return {
content: [{ type: "text", text: output }],
details: { matchCount: matches.length, filesSearched: uniqueFiles.size },
};
},
};
}

View File

@@ -0,0 +1,421 @@
/**
* lsp_completions — Get completion suggestions at a position.
*
* Returns ranked completion items with type signatures and documentation,
* letting the LLM discover available methods, properties, and APIs.
*/
import { Type } from "@sinclair/typebox";
import type {
CompletionItem,
CompletionList,
CompletionItemKind,
MarkupContent,
} from "vscode-languageserver-protocol";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import type { LspManager } from "../lsp-manager.js";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import { resolveSymbolPosition } from "../shared/resolve-position.js";
import { readFile } from "node:fs/promises";
import { SYNTHETIC_DOT_SETTLE_DELAY_MS } from "../shared/timing.js";
/** Map CompletionItemKind to human-readable labels */
const KIND_LABELS: Record<number, string> = {
1: "text",
2: "method",
3: "function",
4: "constructor",
5: "field",
6: "variable",
7: "class",
8: "interface",
9: "module",
10: "property",
11: "unit",
12: "value",
13: "enum",
14: "keyword",
15: "snippet",
16: "color",
17: "file",
18: "reference",
19: "folder",
20: "enum member",
21: "constant",
22: "struct",
23: "event",
24: "operator",
25: "type param",
};
function kindLabel(kind?: CompletionItemKind): string {
if (!kind) return "unknown";
return KIND_LABELS[kind] ?? "unknown";
}
/** Extract a short doc summary (first 1-2 lines) from documentation */
function docSummary(doc?: string | MarkupContent): string | undefined {
if (!doc) return undefined;
const text = typeof doc === "string" ? doc : doc.value;
if (!text) return undefined;
// Strip markdown code fences and take first 2 non-empty lines
const lines = text
.replace(/```[\s\S]*?```/g, "")
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0);
const summary = lines.slice(0, 2).join(" ");
// Cap at 120 chars
return summary.length > 120 ? summary.slice(0, 117) + "..." : summary;
}
/** Format a single completion item as a compact line */
function formatItem(item: CompletionItem): string {
const kind = kindLabel(item.kind);
const label = item.label;
const detail = item.detail ? ` ${item.detail}` : "";
const labelDetail =
item.labelDetails?.detail ? item.labelDetails.detail : "";
const labelDesc =
item.labelDetails?.description ? `${item.labelDetails.description}` : "";
let line = `${kind.padEnd(12)} ${label}${labelDetail}${detail}${labelDesc}`;
const doc = docSummary(item.documentation);
if (doc) {
line += `\n${"".padEnd(13)}${doc}`;
}
return line;
}
type CompletionResponse = CompletionList | CompletionItem[] | null;
const CompletionParams = Type.Object({
path: Type.String({ description: "File path" }),
line: Type.Optional(Type.Number({ description: "Line number (1-indexed). Required unless query is provided." })),
character: Type.Optional(Type.Number({ description: "Column number (1-indexed). Required unless query is provided." })),
query: Type.Optional(Type.String({ description: "Symbol name to find in the file. Alternative to line/character — resolves the symbol's position automatically." })),
limit: Type.Optional(
Type.Number({ description: "Max results to return (default: 20)" })
),
trigger: Type.Optional(
Type.Union([Type.Literal("auto"), Type.Literal("none")], {
description:
'Synthetic trigger mode (default: "auto"). When "auto", if the position is at the end of an identifier (no trailing dot), a dot is temporarily inserted to trigger member completions. Use "none" to skip this.',
})
),
});
interface CompletionDetails {
count: number;
total: number;
}
/**
* Check if the position is at the end of an identifier (suitable for synthetic dot insertion).
* Returns the position after the identifier where the dot should be inserted, or null.
*/
function shouldSyntheticTrigger(
content: string,
line: number, // 0-indexed
character: number // 0-indexed
): { insertLine: number; insertChar: number } | null {
const lines = content.split("\n");
if (line < 0 || line >= lines.length) return null;
const lineText = lines[line];
if (character < 0 || character > lineText.length) return null;
// The character at `character` (0-indexed) should NOT be a dot already
if (lineText[character] === ".") return null;
// The character before `character` should be an identifier char (or closing paren/bracket)
const charBefore = character > 0 ? lineText[character - 1] : "";
if (!charBefore) return null;
// Accept identifier chars, closing parens/brackets (for chained calls like foo().bar)
if (/[\w\d_\)\]]/.test(charBefore)) {
return { insertLine: line, insertChar: character };
}
return null;
}
/**
* Insert a dot at the given position in the content string.
*/
function insertDot(content: string, line: number, character: number): string {
const lines = content.split("\n");
const lineText = lines[line];
lines[line] = lineText.slice(0, character) + "." + lineText.slice(character);
return lines.join("\n");
}
/** Interface for coordinating document versions with FileSync */
export interface VersionTracker {
getTrackedVersion(uri: string): number | null;
setTrackedVersion(uri: string, version: number): void;
/** Check if a synthetic dot operation is in progress for a URI */
isSyntheticDotActive(uri: string): boolean;
}
/** Simple per-URI lock to prevent concurrent synthetic dot + file write version conflicts */
export const syntheticDotLocks = new Set<string>();
export function createCompletionsTool(
manager: LspManager,
versionTracker?: VersionTracker,
treeSitter?: TreeSitterManager | null,
): ToolDefinition<typeof CompletionParams, CompletionDetails> {
return {
name: "lsp_completions",
label: "LSP Completions",
description:
'Get completion suggestions at a specific position in a file. Returns methods, properties, and other symbols available at that point. Useful for discovering APIs and verifying method names. Line and character are 1-indexed. When trigger is "auto" (default), a dot is temporarily inserted if the position is at the end of an identifier, enabling member completion without editing the file.',
promptSnippet:
'Get code completion suggestions at a file position via LSP. Use to discover available methods, properties, and APIs on objects. Supports automatic dot insertion for exploring members on identifiers.',
parameters: CompletionParams,
async execute(_toolCallId, params) {
const filePath = params.path.replace(/^@/, "");
const limit = params.limit ?? 20;
const trigger = params.trigger ?? "auto";
let line = params.line;
let character = params.character;
let resolvedFrom: string | undefined;
// Resolve position from query if line/character not provided
if ((line === undefined || character === undefined) && params.query) {
const resolved = await resolveSymbolPosition(filePath, params.query, manager, treeSitter);
if (resolved) {
line = resolved.line + 1;
character = resolved.character + 1;
resolvedFrom = `Resolved "${params.query}" → ${resolved.symbolName} at ${line}:${character} [${resolved.source}]`;
} else {
return { content: [{ type: "text", text: `Could not find symbol "${params.query}" in ${filePath}` }], details: { count: 0, total: 0 } };
}
}
if (line === undefined || character === undefined) {
return { content: [{ type: "text", text: "Either line/character or query is required." }], details: { count: 0, total: 0 } };
}
const client = await manager.getClientForFile(filePath).catch(() => null);
if (!client) {
return {
content: [
{ type: "text", text: manager.getUnavailableReason(filePath) },
],
details: { count: 0, total: 0 },
};
}
// Check if server supports completions
const caps = client.serverCapabilities;
if (caps && !caps.completionProvider) {
return {
content: [
{
type: "text",
text: `LSP server for this file does not support completions.`,
},
],
details: { count: 0, total: 0 },
};
}
const uri = manager.getFileUri(filePath);
const position = {
line: line - 1,
character: character - 1,
};
try {
// Synthetic trigger: if position is at end of identifier, temporarily insert "."
let syntheticDot = false;
let originalContent: string | null = null;
let revertVersion = 99991;
let completionPosition = position;
if (trigger === "auto") {
// Acquire per-URI lock to prevent concurrent version mutations
if (syntheticDotLocks.has(uri)) {
// Another synthetic dot operation is in progress — skip synthetic trigger
} else {
try {
syntheticDotLocks.add(uri);
const absPath = manager.resolvePath(filePath);
const fileContent = await readFile(absPath, "utf-8");
const triggerPos = shouldSyntheticTrigger(fileContent, position.line, position.character);
if (triggerPos) {
originalContent = fileContent;
const modifiedContent = insertDot(fileContent, triggerPos.insertLine, triggerPos.insertChar);
// Coordinate version with FileSync to avoid desync
const currentVersion = versionTracker?.getTrackedVersion(uri);
const insertVersion = currentVersion != null ? currentVersion + 1 : 99990;
revertVersion = currentVersion != null ? currentVersion + 2 : 99991;
// Send the modified content to the LSP server
client.didChange(uri, insertVersion, modifiedContent);
// Update FileSync so it knows about the version bump
if (versionTracker && currentVersion != null) {
versionTracker.setTrackedVersion(uri, insertVersion);
}
// The completion position is right after the inserted dot
completionPosition = {
line: triggerPos.insertLine,
character: triggerPos.insertChar + 1,
};
syntheticDot = true;
// Brief delay to let the LSP process the change
await new Promise((r) => setTimeout(r, SYNTHETIC_DOT_SETTLE_DELAY_MS));
}
} catch {
// If reading file or inserting dot fails, fall through to normal completion
} finally {
syntheticDotLocks.delete(uri);
}
}
}
let response: CompletionResponse;
try {
response = await client.sendRequest<CompletionResponse>(
"textDocument/completion",
{ textDocument: { uri }, position: completionPosition }
);
} finally {
// Always revert synthetic dot, even on error
if (syntheticDot && originalContent !== null) {
try {
client.didChange(uri, revertVersion, originalContent);
// Update FileSync with the final version after revert
if (versionTracker) {
versionTracker.setTrackedVersion(uri, revertVersion);
}
} catch {
// Best effort revert
}
}
}
if (!response) {
return {
content: [
{ type: "text", text: "No completions available at this position." },
],
details: { count: 0, total: 0 },
};
}
// Normalize to array
const allItems: CompletionItem[] = Array.isArray(response)
? response
: response.items;
if (allItems.length === 0) {
return {
content: [
{ type: "text", text: "No completions available at this position." },
],
details: { count: 0, total: 0 },
};
}
const total = allItems.length;
// Sort by sortText (LSP ranking), then take top N
const sorted = [...allItems].sort((a, b) => {
const sa = a.sortText ?? a.label;
const sb = b.sortText ?? b.label;
return sa.localeCompare(sb);
});
const topItems = sorted.slice(0, limit);
// Resolve items in parallel for full details (documentation, signatures)
const resolveSupported = caps?.completionProvider?.resolveProvider;
let resolvedItems: CompletionItem[];
if (resolveSupported) {
const resolveResults = await Promise.allSettled(
topItems.map((item) =>
Promise.race([
client.sendRequest<CompletionItem>(
"completionItem/resolve",
item
),
// Timeout per item: 2 seconds
new Promise<CompletionItem>((_, reject) =>
setTimeout(() => reject(new Error("resolve timeout")), 2000)
),
])
)
);
resolvedItems = resolveResults.map((result, i) =>
result.status === "fulfilled" ? result.value : topItems[i]
);
} else {
resolvedItems = topItems;
}
// Format output
const triggerNote = syntheticDot ? " (synthetic dot trigger)" : "";
let header = `${resolvedItems.length} of ${total} completions at ${filePath}:${line}:${character}${triggerNote}\n`;
if (resolvedFrom) header = `${resolvedFrom}\n\n${header}`;
const lines = resolvedItems.map(formatItem);
const text = header + lines.join("\n");
return {
content: [{ type: "text", text }],
details: { count: resolvedItems.length, total },
};
} catch (err: any) {
return {
content: [
{
type: "text",
text: `LSP completion request failed: ${err.message}`,
},
],
details: { count: 0, total: 0 },
};
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("lsp_completions "));
if (args.query && !args.line) {
text += theme.fg("accent", `${args.path}`);
text += theme.fg("muted", ` query="${args.query}"`);
} else {
text += theme.fg("accent", `${args.path}:${args.line}:${args.character}`);
}
const extras: string[] = [];
if (args.limit) extras.push(`limit: ${args.limit}`);
if (args.trigger === "none") extras.push("trigger: none");
if (extras.length > 0) {
text += theme.fg("dim", ` (${extras.join(", ")})`);
}
return new Text(text, 0, 0);
},
renderResult(result, { isPartial }, theme) {
if (isPartial)
return new Text(theme.fg("warning", "Loading completions..."), 0, 0);
if (!result.details || result.details.count === 0) {
const content = result.content[0];
if (content?.type === "text")
return new Text(theme.fg("dim", content.text), 0, 0);
return new Text(theme.fg("dim", "No completions"), 0, 0);
}
const { count, total } = result.details;
const summary = `${count} of ${total} completions`;
return new Text(theme.fg("dim", summary), 0, 0);
},
};
}

View File

@@ -0,0 +1,187 @@
/**
* lsp_definition — Go to the definition of a symbol.
*/
import { Type } from "@sinclair/typebox";
import type { Location, LocationLink } from "vscode-languageserver-protocol";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import type { LspManager } from "../lsp-manager.js";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import type { WorkspaceIndex } from "../tree-sitter/workspace-index.js";
import { resolveProvider } from "../resolve-provider.js";
import { getNodeAtPosition, findDefinition } from "../tree-sitter/symbol-extractor.js";
import { formatLocation, formatLocationLink } from "../shared/format.js";
import { resolveSymbolPosition, getSymbolNames } from "../shared/resolve-position.js";
import { readFile } from "node:fs/promises";
import { relative } from "node:path";
type DefinitionResult = Location | Location[] | LocationLink[] | null;
const DefinitionParams = Type.Object({
path: Type.String({ description: "File path" }),
line: Type.Optional(Type.Number({ description: "Line number (1-indexed). Required unless query is provided." })),
character: Type.Optional(Type.Number({ description: "Column number (1-indexed). Required unless query is provided." })),
query: Type.Optional(Type.String({ description: "Symbol name to find in the file. Alternative to line/character — resolves the symbol's position automatically." })),
});
interface DefinitionDetails { count: number }
export function createDefinitionTool(
manager: LspManager,
treeSitter?: TreeSitterManager | null,
workspaceIndex?: WorkspaceIndex | null,
): ToolDefinition<typeof DefinitionParams, DefinitionDetails> {
return {
name: "lsp_definition",
label: "LSP Definition",
description: "Go to the definition of a symbol at a specific position. Returns the file path and location of the definition. Line and character are 1-indexed.",
promptSnippet: "Jump to the definition of a symbol at a file position via LSP",
parameters: DefinitionParams,
async execute(_toolCallId, params) {
const filePath = params.path.replace(/^@/, "");
let line = params.line;
let character = params.character;
let resolvedFrom: string | undefined;
// Resolve position from query if line/character not provided
if ((line === undefined || character === undefined) && params.query) {
const resolved = await resolveSymbolPosition(filePath, params.query, manager, treeSitter);
if (resolved) {
line = resolved.line;
character = resolved.character;
resolvedFrom = `Resolved "${params.query}" → ${resolved.symbolName} at ${line}:${character} [${resolved.source}]`;
} else {
const names = await getSymbolNames(filePath, manager, treeSitter);
const hint = names.length > 0 ? `\nAvailable symbols: ${names.slice(0, 20).join(", ")}` : "";
return { content: [{ type: "text", text: `Could not find symbol "${params.query}" in ${filePath}${hint}` }], details: { count: 0 } };
}
}
if (line === undefined || character === undefined) {
return { content: [{ type: "text", text: "Either line/character or query is required." }], details: { count: 0 } };
}
const client = await manager.getClientForFile(filePath).catch(() => null);
if (client) {
// LSP path
const uri = manager.getFileUri(filePath);
const position = { line: line - 1, character: character - 1 };
try {
const result = await client.sendRequest<DefinitionResult>("textDocument/definition", {
textDocument: { uri }, position,
});
if (!result) {
const text = resolvedFrom ? `${resolvedFrom}\n\nNo definition found.` : "No definition found.";
return { content: [{ type: "text", text }], details: { count: 0 } };
}
const rootDir = manager.resolvePath(".");
let locations: string[];
if (Array.isArray(result)) {
if (result.length === 0) {
const text = resolvedFrom ? `${resolvedFrom}\n\nNo definition found.` : "No definition found.";
return { content: [{ type: "text", text }], details: { count: 0 } };
}
if ("targetUri" in result[0]) {
locations = (result as LocationLink[]).map((l) => formatLocationLink(l, rootDir));
} else {
locations = (result as Location[]).map((l) => formatLocation(l, rootDir));
}
} else {
locations = [formatLocation(result as Location, rootDir)];
}
let text = locations.length === 1
? `Definition: ${locations[0]}`
: `Definitions:\n${locations.map((l) => ` ${l}`).join("\n")}`;
if (resolvedFrom) text = `${resolvedFrom}\n\n${text}`;
return { content: [{ type: "text", text }], details: { count: locations.length } };
} catch (err: any) {
return { content: [{ type: "text", text: `LSP definition request failed: ${err.message}` }], details: { count: 0 } };
}
}
// Tree-sitter fallback
if (treeSitter) {
const provider = resolveProvider(filePath, manager, treeSitter);
if (provider.type === "tree-sitter") {
try {
const absPath = manager.resolvePath(filePath);
const content = await readFile(absPath, "utf-8");
const tree = await treeSitter.parse(absPath, content);
if (tree) {
// Get the symbol name at the cursor position
const node = getNodeAtPosition(tree, line - 1, character - 1);
if (node) {
const symbolName = node.text;
const rootDir = manager.resolvePath(".");
const relPath = relative(rootDir, absPath);
// Search current file first
const localDefs = findDefinition(tree, symbolName, provider.languageId);
if (localDefs.length > 0) {
const locs = localDefs.map((d) => `${relPath}:${d.line}:1`);
let text = locs.length === 1
? `Definition [tree-sitter]: ${locs[0]}`
: `Definitions [tree-sitter]:\n${locs.map((l) => ` ${l}`).join("\n")}`;
if (resolvedFrom) text = `${resolvedFrom}\n\n${text}`;
return { content: [{ type: "text", text }], details: { count: locs.length } };
}
// Search workspace index
if (workspaceIndex) {
await workspaceIndex.build();
const entries = workspaceIndex.search(symbolName);
const exact = entries.filter((e) => e.name === symbolName);
if (exact.length > 0) {
const rootDir2 = manager.resolvePath(".");
const locs = exact.slice(0, 10).map((e) => {
const rel = relative(rootDir2, e.file);
return `${rel}:${e.line}:1`;
});
let text = locs.length === 1
? `Definition [tree-sitter]: ${locs[0]}`
: `Definitions [tree-sitter]:\n${locs.map((l) => ` ${l}`).join("\n")}`;
if (resolvedFrom) text = `${resolvedFrom}\n\n${text}`;
return { content: [{ type: "text", text }], details: { count: locs.length } };
}
}
const msg = `No definition found for "${symbolName}" [tree-sitter]`;
const text = resolvedFrom ? `${resolvedFrom}\n\n${msg}` : msg;
return { content: [{ type: "text", text }], details: { count: 0 } };
}
}
} catch { /* fall through */ }
}
}
return { content: [{ type: "text", text: manager.getUnavailableReason(filePath) }], details: { count: 0 } };
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("lsp_definition "));
if (args.query && !args.line) {
text += theme.fg("accent", `${args.path}`);
text += theme.fg("muted", ` query="${args.query}"`);
} else {
text += theme.fg("accent", `${args.path}:${args.line}:${args.character}`);
}
return new Text(text, 0, 0);
},
renderResult(result, { isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Resolving..."), 0, 0);
const content = result.content[0];
if (content?.type === "text") return new Text(theme.fg("dim", content.text), 0, 0);
return new Text(theme.fg("dim", "No result"), 0, 0);
},
};
}

View File

@@ -0,0 +1,261 @@
/**
* lsp_diagnostics — Get compilation errors and warnings for a file or the whole workspace.
*
* When `path` is provided: returns diagnostics for that single file.
* When `path` is omitted: returns all cached diagnostics across all running LSP servers.
*/
import { Type } from "@sinclair/typebox";
import { DiagnosticSeverity, type Diagnostic } from "vscode-languageserver-protocol";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { truncateHead, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import type { LspManager } from "../lsp-manager.js";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import { resolveProvider } from "../resolve-provider.js";
import { getSyntaxErrors } from "../tree-sitter/symbol-extractor.js";
import { readFile } from "node:fs/promises";
import { relative } from "node:path";
import { fileURLToPath } from "node:url";
function severityToString(severity: number | undefined): string {
switch (severity) {
case DiagnosticSeverity.Error: return "error";
case DiagnosticSeverity.Warning: return "warning";
case DiagnosticSeverity.Information: return "info";
case DiagnosticSeverity.Hint: return "hint";
default: return "unknown";
}
}
function formatDiagnostic(diag: Diagnostic, filePath: string): string {
const line = diag.range.start.line + 1;
const col = diag.range.start.character + 1;
const sev = severityToString(diag.severity);
const source = diag.source ? ` [${diag.source}]` : "";
const code = diag.code !== undefined ? ` (${diag.code})` : "";
return `${filePath}:${line}:${col} ${sev}: ${diag.message}${code}${source}`;
}
const DiagnosticsParams = Type.Object({
path: Type.String({ description: "File path to get diagnostics for. Pass \"*\" to get all workspace diagnostics from all running LSP servers." }),
});
interface DiagnosticsDetails {
count: number;
errors?: number;
warnings?: number;
files?: number;
}
export function createDiagnosticsTool(
manager: LspManager,
treeSitter?: TreeSitterManager | null,
): ToolDefinition<typeof DiagnosticsParams, DiagnosticsDetails> {
return {
name: "lsp_diagnostics",
label: "LSP Diagnostics",
description: "Get compilation errors and warnings from the LSP server. Pass a file path to check a single file, or pass \"*\" to get all cached diagnostics across the workspace.",
promptSnippet: "Get compiler errors, warnings, and hints for a source file via LSP. Pass path=\"*\" to get all workspace diagnostics.",
promptGuidelines: [
"After making code changes with edit or write, use lsp_diagnostics to check for compilation errors before moving on.",
"To review all workspace diagnostics at once, call lsp_diagnostics with path=\"*\" — this returns all cached diagnostics from running LSP servers without needing to check files individually.",
],
parameters: DiagnosticsParams,
async execute(_toolCallId, params) {
const filePath = params.path.replace(/^@/, "");
// Workspace-wide mode
if (filePath === "*" || filePath === "") {
return executeWorkspaceDiagnostics(manager);
}
const client = await manager.getClientForFile(filePath).catch(() => null);
if (client) {
// LSP path
const uri = manager.getFileUri(filePath);
const diagnostics = client.getDiagnostics(uri);
if (diagnostics.length === 0) {
return { content: [{ type: "text", text: "No diagnostics (clean)." }], details: { count: 0 } };
}
const sorted = [...diagnostics].sort((a, b) => (a.severity ?? 99) - (b.severity ?? 99));
const relPath = relative(manager.resolvePath("."), manager.resolvePath(filePath));
const lines = sorted.map((d) => formatDiagnostic(d, relPath));
const output = lines.join("\n");
const errors = sorted.filter((d) => d.severity === DiagnosticSeverity.Error).length;
const warnings = sorted.filter((d) => d.severity === DiagnosticSeverity.Warning).length;
const other = sorted.length - errors - warnings;
const summary = [
errors > 0 ? `${errors} error(s)` : null,
warnings > 0 ? `${warnings} warning(s)` : null,
other > 0 ? `${other} other` : null,
].filter(Boolean).join(", ");
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let resultText = `${summary}\n\n${truncation.content}`;
if (truncation.truncated) {
resultText += `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} diagnostics]`;
}
return {
content: [{ type: "text", text: resultText }],
details: { count: sorted.length, errors, warnings },
};
}
// Tree-sitter fallback: report syntax errors
if (treeSitter) {
const provider = resolveProvider(filePath, manager, treeSitter);
if (provider.type === "tree-sitter") {
try {
const absPath = manager.resolvePath(filePath);
const content = await readFile(absPath, "utf-8");
const tree = await treeSitter.parse(absPath, content);
if (tree) {
const syntaxErrors = getSyntaxErrors(tree);
if (syntaxErrors.length === 0) {
return { content: [{ type: "text", text: "No syntax errors detected. [tree-sitter — syntax only, no type checking]" }], details: { count: 0 } };
}
const relPath = relative(manager.resolvePath("."), absPath);
const lines = syntaxErrors.map((e) =>
`${relPath}:${e.line + 1}:${e.character + 1} error: ${e.message}`
);
const output = lines.join("\n");
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let resultText = `${syntaxErrors.length} syntax error(s) [tree-sitter — syntax only, no type checking]\n\n${truncation.content}`;
if (truncation.truncated) {
resultText += `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} diagnostics]`;
}
return {
content: [{ type: "text", text: resultText }],
details: { count: syntaxErrors.length, errors: syntaxErrors.length },
};
}
} catch { /* fall through */ }
}
}
return {
content: [{ type: "text", text: manager.getUnavailableReason(filePath) }],
details: { count: 0 },
};
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("lsp_diagnostics "));
if (args.path && args.path !== "*") {
text += theme.fg("accent", args.path);
} else {
text += theme.fg("dim", "(workspace)");
}
return new Text(text, 0, 0);
},
renderResult(result, { expanded, isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Checking..."), 0, 0);
const details = result.details;
if (!details || details.count === 0) {
return new Text(theme.fg("success", "✓ No diagnostics"), 0, 0);
}
let text = "";
if (details.errors && details.errors > 0) text += theme.fg("error", `${details.errors} error(s)`);
if (details.warnings && details.warnings > 0) {
if (text) text += " ";
text += theme.fg("warning", `${details.warnings} warning(s)`);
}
if (details.files && details.files > 0) {
text += theme.fg("dim", ` in ${details.files} file(s)`);
}
if (expanded) {
const content = result.content[0];
if (content?.type === "text") {
const lines = content.text.split("\n").slice(0, 30);
for (const line of lines) text += `\n${theme.fg("dim", line)}`;
}
}
return new Text(text, 0, 0);
},
};
}
/** Collect all diagnostics from all running LSP servers */
function executeWorkspaceDiagnostics(manager: LspManager) {
const statuses = manager.getStatus();
const running = statuses.filter((s) => s.running);
if (running.length === 0) {
return {
content: [{ type: "text" as const, text: "No LSP servers are running. Use lsp_diagnostics with a file path to start a server and check that file." }],
details: { count: 0 },
};
}
const rootDir = manager.resolvePath(".");
const allDiagnostics: { relPath: string; diag: Diagnostic }[] = [];
for (const status of running) {
const client = manager.getRunningClient(status.languageId);
if (!client) continue;
const diagMap = client.getAllDiagnostics();
for (const [uri, diagnostics] of diagMap) {
if (diagnostics.length === 0) continue;
let absPath: string;
try {
absPath = fileURLToPath(uri);
} catch {
absPath = uri;
}
const relPath = relative(rootDir, absPath);
for (const diag of diagnostics) {
allDiagnostics.push({ relPath, diag });
}
}
}
if (allDiagnostics.length === 0) {
const langs = running.map((s) => s.languageId).join(", ");
return {
content: [{ type: "text" as const, text: `No diagnostics across ${running.length} running server(s) (${langs}).` }],
details: { count: 0 },
};
}
// Sort: errors first, then by file path
allDiagnostics.sort((a, b) => {
const sevDiff = (a.diag.severity ?? 99) - (b.diag.severity ?? 99);
if (sevDiff !== 0) return sevDiff;
return a.relPath.localeCompare(b.relPath);
});
const errors = allDiagnostics.filter((d) => d.diag.severity === DiagnosticSeverity.Error).length;
const warnings = allDiagnostics.filter((d) => d.diag.severity === DiagnosticSeverity.Warning).length;
const other = allDiagnostics.length - errors - warnings;
const fileCount = new Set(allDiagnostics.map((d) => d.relPath)).size;
const summary = [
`${allDiagnostics.length} diagnostic(s) in ${fileCount} file(s)`,
errors > 0 ? `${errors} error(s)` : null,
warnings > 0 ? `${warnings} warning(s)` : null,
other > 0 ? `${other} other` : null,
].filter(Boolean).join(", ");
const lines = allDiagnostics.map((d) => formatDiagnostic(d.diag, d.relPath));
const output = lines.join("\n");
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let resultText = `${summary}\n\n${truncation.content}`;
if (truncation.truncated) {
resultText += `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} diagnostics]`;
}
return {
content: [{ type: "text" as const, text: resultText }],
details: { count: allDiagnostics.length, errors, warnings, files: fileCount },
};
}

View File

@@ -0,0 +1,153 @@
/**
* lsp_hover — Get type information and documentation at a position.
*/
import { Type } from "@sinclair/typebox";
import type { Hover, MarkupContent } from "vscode-languageserver-protocol";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import type { LspManager } from "../lsp-manager.js";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import { resolveProvider } from "../resolve-provider.js";
import { getEnclosingDeclaration, getSignatureText } from "../tree-sitter/symbol-extractor.js";
import { resolveSymbolPosition, getSymbolNames } from "../shared/resolve-position.js";
import { readFile } from "node:fs/promises";
function formatHoverContent(hover: Hover): string {
const contents = hover.contents;
if (typeof contents === "string") return contents;
if ("kind" in contents && "value" in contents) return (contents as MarkupContent).value;
if ("language" in contents && "value" in contents) {
return `\`\`\`${(contents as any).language}\n${(contents as any).value}\n\`\`\``;
}
if (Array.isArray(contents)) {
return contents.map((c) => {
if (typeof c === "string") return c;
if ("language" in c && "value" in c) return `\`\`\`${c.language}\n${c.value}\n\`\`\``;
return String(c);
}).join("\n\n");
}
return String(contents);
}
const HoverParams = Type.Object({
path: Type.String({ description: "File path" }),
line: Type.Optional(Type.Number({ description: "Line number (1-indexed). Required unless query is provided." })),
character: Type.Optional(Type.Number({ description: "Column number (1-indexed). Required unless query is provided." })),
query: Type.Optional(Type.String({ description: "Symbol name to find in the file. Alternative to line/character — resolves the symbol's position automatically." })),
});
interface HoverDetails { hasResult: boolean }
export function createHoverTool(
manager: LspManager,
treeSitter?: TreeSitterManager | null,
): ToolDefinition<typeof HoverParams, HoverDetails> {
return {
name: "lsp_hover",
label: "LSP Hover",
description: "Get type information and documentation for a symbol at a specific position in a file. Line and character are 1-indexed.",
promptSnippet: "Get type info and docs for a symbol at a file position via LSP",
parameters: HoverParams,
async execute(_toolCallId, params) {
const filePath = params.path.replace(/^@/, "");
let line = params.line;
let character = params.character;
let resolvedFrom: string | undefined;
// Resolve position from query if line/character not provided
if ((line === undefined || character === undefined) && params.query) {
const resolved = await resolveSymbolPosition(filePath, params.query, manager, treeSitter);
if (resolved) {
line = resolved.line;
character = resolved.character;
resolvedFrom = `Resolved "${params.query}" → ${resolved.symbolName} at ${line}:${character} [${resolved.source}]`;
} else {
const names = await getSymbolNames(filePath, manager, treeSitter);
const hint = names.length > 0 ? `\nAvailable symbols: ${names.slice(0, 20).join(", ")}` : "";
return { content: [{ type: "text", text: `Could not find symbol "${params.query}" in ${filePath}${hint}` }], details: { hasResult: false } };
}
}
if (line === undefined || character === undefined) {
return { content: [{ type: "text", text: "Either line/character or query is required." }], details: { hasResult: false } };
}
const client = await manager.getClientForFile(filePath).catch(() => null);
if (client) {
// LSP path
const uri = manager.getFileUri(filePath);
const position = { line: line - 1, character: character - 1 };
try {
const hover = await client.sendRequest<Hover | null>("textDocument/hover", {
textDocument: { uri }, position,
});
if (!hover) {
const text = resolvedFrom
? `${resolvedFrom}\n\nNo hover information available at this position.`
: "No hover information available at this position.";
return { content: [{ type: "text", text }], details: { hasResult: false } };
}
const hoverText = formatHoverContent(hover);
const text = resolvedFrom ? `${resolvedFrom}\n\n${hoverText}` : hoverText;
return { content: [{ type: "text", text }], details: { hasResult: true } };
} catch (err: any) {
return { content: [{ type: "text", text: `LSP hover request failed: ${err.message}` }], details: { hasResult: false } };
}
}
// Tree-sitter fallback
if (treeSitter) {
const provider = resolveProvider(filePath, manager, treeSitter);
if (provider.type === "tree-sitter") {
try {
const absPath = manager.resolvePath(filePath);
const content = await readFile(absPath, "utf-8");
const tree = await treeSitter.parse(absPath, content);
if (tree) {
const decl = getEnclosingDeclaration(tree, line - 1, character - 1);
if (decl) {
const sig = getSignatureText(decl);
const kindLabel = decl.type.replace(/_/g, " ");
let text = `${kindLabel} [tree-sitter]\n\n\`\`\`\n${sig}\n\`\`\``;
if (resolvedFrom) text = `${resolvedFrom}\n\n${text}`;
return { content: [{ type: "text", text }], details: { hasResult: true } };
}
const text = resolvedFrom
? `${resolvedFrom}\n\nNo hover information available at this position. [tree-sitter]`
: "No hover information available at this position. [tree-sitter]";
return { content: [{ type: "text", text }], details: { hasResult: false } };
}
} catch { /* fall through */ }
}
}
return { content: [{ type: "text", text: manager.getUnavailableReason(filePath) }], details: { hasResult: false } };
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("lsp_hover "));
if (args.query && !args.line) {
text += theme.fg("accent", `${args.path}`);
text += theme.fg("muted", ` query="${args.query}"`);
} else {
text += theme.fg("accent", `${args.path}:${args.line}:${args.character}`);
}
return new Text(text, 0, 0);
},
renderResult(result, { isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Looking up..."), 0, 0);
if (!result.details?.hasResult) return new Text(theme.fg("dim", "No info"), 0, 0);
const content = result.content[0];
if (content?.type === "text") {
const lines = content.text.split("\n").slice(0, 5);
return new Text(lines.map((l) => theme.fg("dim", l)).join("\n"), 0, 0);
}
return new Text(theme.fg("dim", "No info"), 0, 0);
},
};
}

View File

@@ -0,0 +1,116 @@
/**
* lsp_references — Find all references to a symbol.
*/
import { Type } from "@sinclair/typebox";
import type { Location } from "vscode-languageserver-protocol";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { truncateHead, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import type { LspManager } from "../lsp-manager.js";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import { formatLocation } from "../shared/format.js";
import { resolveSymbolPosition, getSymbolNames } from "../shared/resolve-position.js";
const ReferencesParams = Type.Object({
path: Type.String({ description: "File path" }),
line: Type.Optional(Type.Number({ description: "Line number (1-indexed). Required unless query is provided." })),
character: Type.Optional(Type.Number({ description: "Column number (1-indexed). Required unless query is provided." })),
query: Type.Optional(Type.String({ description: "Symbol name to find in the file. Alternative to line/character — resolves the symbol's position automatically." })),
includeDeclaration: Type.Optional(
Type.Boolean({ description: "Include the declaration in results (default: true)" })
),
});
interface ReferencesDetails { count: number }
export function createReferencesTool(
manager: LspManager,
treeSitter?: TreeSitterManager | null,
): ToolDefinition<typeof ReferencesParams, ReferencesDetails> {
return {
name: "lsp_references",
label: "LSP References",
description: "Find all references to a symbol at a specific position. Returns a list of file locations. Line and character are 1-indexed.",
promptSnippet: "Find all references to a symbol at a file position via LSP",
parameters: ReferencesParams,
async execute(_toolCallId, params) {
const filePath = params.path.replace(/^@/, "");
let line = params.line;
let character = params.character;
let resolvedFrom: string | undefined;
// Resolve position from query if line/character not provided
if ((line === undefined || character === undefined) && params.query) {
const resolved = await resolveSymbolPosition(filePath, params.query, manager, treeSitter);
if (resolved) {
line = resolved.line;
character = resolved.character;
resolvedFrom = `Resolved "${params.query}" → ${resolved.symbolName} at ${line}:${character} [${resolved.source}]`;
} else {
const names = await getSymbolNames(filePath, manager, treeSitter);
const hint = names.length > 0 ? `\nAvailable symbols: ${names.slice(0, 20).join(", ")}` : "";
return { content: [{ type: "text", text: `Could not find symbol "${params.query}" in ${filePath}${hint}` }], details: { count: 0 } };
}
}
if (line === undefined || character === undefined) {
return { content: [{ type: "text", text: "Either line/character or query is required." }], details: { count: 0 } };
}
const client = await manager.getClientForFile(filePath).catch(() => null);
if (!client) {
return { content: [{ type: "text", text: manager.getUnavailableReason(filePath) }], details: { count: 0 } };
}
const uri = manager.getFileUri(filePath);
const position = { line: line - 1, character: character - 1 };
try {
const locations = await client.sendRequest<Location[] | null>("textDocument/references", {
textDocument: { uri }, position,
context: { includeDeclaration: params.includeDeclaration ?? true },
});
if (!locations || locations.length === 0) {
const text = resolvedFrom ? `${resolvedFrom}\n\nNo references found.` : "No references found.";
return { content: [{ type: "text", text }], details: { count: 0 } };
}
const rootDir = manager.resolvePath(".");
const formatted = locations.map((l) => formatLocation(l, rootDir));
const output = formatted.join("\n");
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let resultText = `${locations.length} reference(s) found:\n\n${truncation.content}`;
if (truncation.truncated) {
resultText += `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} references]`;
}
if (resolvedFrom) resultText = `${resolvedFrom}\n\n${resultText}`;
return { content: [{ type: "text", text: resultText }], details: { count: locations.length } };
} catch (err: any) {
return { content: [{ type: "text", text: `LSP references request failed: ${err.message}` }], details: { count: 0 } };
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("lsp_references "));
if (args.query && !args.line) {
text += theme.fg("accent", `${args.path}`);
text += theme.fg("muted", ` query="${args.query}"`);
} else {
text += theme.fg("accent", `${args.path}:${args.line}:${args.character}`);
}
return new Text(text, 0, 0);
},
renderResult(result, { isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Searching..."), 0, 0);
const details = result.details;
if (!details || details.count === 0) return new Text(theme.fg("dim", "No references found"), 0, 0);
return new Text(theme.fg("success", `${details.count} reference(s)`), 0, 0);
},
};
}

View File

@@ -0,0 +1,160 @@
/**
* lsp_rename — Preview a rename refactoring across the workspace.
* Returns planned edits but does NOT apply them.
*/
import { Type } from "@sinclair/typebox";
import type { WorkspaceEdit, TextEdit } from "vscode-languageserver-protocol";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { truncateHead, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import type { LspManager } from "../lsp-manager.js";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import { resolveSymbolPosition, getSymbolNames } from "../shared/resolve-position.js";
import { fileURLToPath } from "node:url";
import { relative } from "node:path";
function formatWorkspaceEdit(edit: WorkspaceEdit, rootDir: string): { summary: string; fileCount: number; editCount: number } {
const lines: string[] = [];
let totalEdits = 0;
let fileCount = 0;
if (edit.documentChanges) {
for (const change of edit.documentChanges) {
if ("textDocument" in change && "edits" in change) {
fileCount++;
let relPath: string;
try { relPath = relative(rootDir, fileURLToPath(change.textDocument.uri)); } catch { relPath = change.textDocument.uri; }
lines.push(`${relPath}:`);
for (const textEdit of change.edits as TextEdit[]) {
const line = textEdit.range.start.line + 1;
const col = textEdit.range.start.character + 1;
lines.push(` ${line}:${col} → "${textEdit.newText}"`);
totalEdits++;
}
}
}
}
const changes = edit.changes ?? {};
for (const [uri, edits] of Object.entries(changes)) {
fileCount++;
let relPath: string;
try { relPath = relative(rootDir, fileURLToPath(uri)); } catch { relPath = uri; }
lines.push(`${relPath}:`);
for (const textEdit of edits) {
const line = textEdit.range.start.line + 1;
const col = textEdit.range.start.character + 1;
lines.push(` ${line}:${col} → "${textEdit.newText}"`);
totalEdits++;
}
}
return { summary: lines.join("\n"), fileCount, editCount: totalEdits };
}
const RenameParams = Type.Object({
path: Type.String({ description: "File path" }),
line: Type.Optional(Type.Number({ description: "Line number (1-indexed). Required unless query is provided." })),
character: Type.Optional(Type.Number({ description: "Column number (1-indexed). Required unless query is provided." })),
query: Type.Optional(Type.String({ description: "Symbol name to find in the file. Alternative to line/character — resolves the symbol's position automatically." })),
newName: Type.String({ description: "New name for the symbol" }),
});
interface RenameDetails { fileCount: number; editCount: number }
export function createRenameTool(
manager: LspManager,
treeSitter?: TreeSitterManager | null,
): ToolDefinition<typeof RenameParams, RenameDetails> {
return {
name: "lsp_rename",
label: "LSP Rename",
description: "Preview a rename refactoring for a symbol at a position. Returns the list of changes that would be made across all files. Does NOT apply the changes — use edit/write tools to apply them. Line and character are 1-indexed.",
promptSnippet: "Preview rename refactoring for a symbol (returns planned edits, does not apply them)",
parameters: RenameParams,
async execute(_toolCallId, params) {
const filePath = params.path.replace(/^@/, "");
let line = params.line;
let character = params.character;
let resolvedFrom: string | undefined;
// Resolve position from query if line/character not provided
if ((line === undefined || character === undefined) && params.query) {
const resolved = await resolveSymbolPosition(filePath, params.query, manager, treeSitter);
if (resolved) {
line = resolved.line;
character = resolved.character;
resolvedFrom = `Resolved "${params.query}" → ${resolved.symbolName} at ${line}:${character} [${resolved.source}]`;
} else {
const names = await getSymbolNames(filePath, manager, treeSitter);
const hint = names.length > 0 ? `\nAvailable symbols: ${names.slice(0, 20).join(", ")}` : "";
return { content: [{ type: "text", text: `Could not find symbol "${params.query}" in ${filePath}${hint}` }], details: { fileCount: 0, editCount: 0 } };
}
}
if (line === undefined || character === undefined) {
return { content: [{ type: "text", text: "Either line/character or query is required." }], details: { fileCount: 0, editCount: 0 } };
}
const client = await manager.getClientForFile(filePath).catch(() => null);
if (!client) {
return { content: [{ type: "text", text: manager.getUnavailableReason(filePath) }], details: { fileCount: 0, editCount: 0 } };
}
const uri = manager.getFileUri(filePath);
const position = { line: line - 1, character: character - 1 };
try {
const result = await client.sendRequest<WorkspaceEdit | null>("textDocument/rename", {
textDocument: { uri }, position, newName: params.newName,
});
if (!result) {
const text = resolvedFrom ? `${resolvedFrom}\n\nRename not possible at this position.` : "Rename not possible at this position.";
return { content: [{ type: "text", text }], details: { fileCount: 0, editCount: 0 } };
}
const rootDir = manager.resolvePath(".");
const { summary, fileCount, editCount } = formatWorkspaceEdit(result, rootDir);
if (editCount === 0) {
const text = resolvedFrom ? `${resolvedFrom}\n\nNo edits needed for this rename.` : "No edits needed for this rename.";
return { content: [{ type: "text", text }], details: { fileCount: 0, editCount: 0 } };
}
const truncation = truncateHead(summary, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let text = "";
if (resolvedFrom) text += `${resolvedFrom}\n\n`;
text += `Rename "${params.newName}": ${editCount} edit(s) across ${fileCount} file(s)\n\n`;
text += "NOTE: These changes are NOT applied. Use edit/write tools to make the changes.\n\n";
text += truncation.content;
if (truncation.truncated) text += `\n\n[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`;
return { content: [{ type: "text", text }], details: { fileCount, editCount } };
} catch (err: any) {
return { content: [{ type: "text", text: `LSP rename request failed: ${err.message}` }], details: { fileCount: 0, editCount: 0 } };
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("lsp_rename "));
if (args.query && !args.line) {
text += theme.fg("accent", `${args.path}`);
text += theme.fg("muted", ` query="${args.query}" → ${args.newName}`);
} else {
text += theme.fg("accent", `${args.path}:${args.line}:${args.character}`);
text += theme.fg("muted", `${args.newName}`);
}
return new Text(text, 0, 0);
},
renderResult(result, { isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Computing..."), 0, 0);
const details = result.details;
if (!details || details.editCount === 0) return new Text(theme.fg("dim", "No edits"), 0, 0);
return new Text(theme.fg("success", `${details.editCount} edit(s) in ${details.fileCount} file(s) (preview only)`), 0, 0);
},
};
}

View File

@@ -0,0 +1,234 @@
/**
* lsp_symbols — List symbols in a file or search workspace symbols.
*/
import { Type } from "@sinclair/typebox";
import { SymbolKind, type DocumentSymbol, type SymbolInformation, type WorkspaceSymbol } from "vscode-languageserver-protocol";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { truncateHead, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import type { LspManager } from "../lsp-manager.js";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import type { WorkspaceIndex } from "../tree-sitter/workspace-index.js";
import { resolveProvider } from "../resolve-provider.js";
import { extractSymbols, type SymbolInfo } from "../tree-sitter/symbol-extractor.js";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { relative } from "node:path";
const SYMBOL_KIND_NAMES: Record<number, string> = {
[SymbolKind.File]: "file", [SymbolKind.Module]: "module", [SymbolKind.Namespace]: "namespace",
[SymbolKind.Package]: "package", [SymbolKind.Class]: "class", [SymbolKind.Method]: "method",
[SymbolKind.Property]: "property", [SymbolKind.Field]: "field", [SymbolKind.Constructor]: "constructor",
[SymbolKind.Enum]: "enum", [SymbolKind.Interface]: "interface", [SymbolKind.Function]: "function",
[SymbolKind.Variable]: "variable", [SymbolKind.Constant]: "constant", [SymbolKind.String]: "string",
[SymbolKind.Number]: "number", [SymbolKind.Boolean]: "boolean", [SymbolKind.Array]: "array",
[SymbolKind.Object]: "object", [SymbolKind.Key]: "key", [SymbolKind.Null]: "null",
[SymbolKind.EnumMember]: "enum-member", [SymbolKind.Struct]: "struct", [SymbolKind.Event]: "event",
[SymbolKind.Operator]: "operator", [SymbolKind.TypeParameter]: "type-param",
};
function kindName(kind: SymbolKind): string {
return SYMBOL_KIND_NAMES[kind] ?? `kind(${kind})`;
}
function formatDocumentSymbol(sym: DocumentSymbol, indent: number = 0): string[] {
const prefix = " ".repeat(indent);
const line = sym.range.start.line + 1;
const result = [`${prefix}${kindName(sym.kind)} ${sym.name} (line ${line})`];
if (sym.children) {
for (const child of sym.children) result.push(...formatDocumentSymbol(child, indent + 1));
}
return result;
}
function formatSymbolInfo(sym: SymbolInformation | WorkspaceSymbol, rootDir: string): string {
let location = "";
if ("location" in sym && sym.location) {
try {
const absPath = fileURLToPath(sym.location.uri);
const relPath = relative(rootDir, absPath);
const loc = sym.location as any;
const line = loc.range ? loc.range.start.line + 1 : "?";
location = ` ${relPath}:${line}`;
} catch {
location = ` ${sym.location.uri}`;
}
}
return `${kindName(sym.kind)} ${sym.name}${location}`;
}
const SymbolsParams = Type.Object({
path: Type.Optional(Type.String({ description: "File path for document symbols" })),
query: Type.Optional(Type.String({ description: "Search query for workspace symbols (searches across all files)" })),
});
interface SymbolsDetails { count: number }
function formatTreeSitterSymbol(sym: SymbolInfo, indent: number = 0): string[] {
const prefix = " ".repeat(indent);
const kindStr = SYMBOL_KIND_NAMES[sym.kind] ?? `kind(${sym.kind})`;
const result = [`${prefix}${kindStr} ${sym.name} (line ${sym.line})`];
if (sym.children) {
for (const child of sym.children) result.push(...formatTreeSitterSymbol(child, indent + 1));
}
return result;
}
export function createSymbolsTool(
manager: LspManager,
treeSitter?: TreeSitterManager | null,
workspaceIndex?: WorkspaceIndex | null,
): ToolDefinition<typeof SymbolsParams, SymbolsDetails> {
return {
name: "lsp_symbols",
label: "LSP Symbols",
description: "List symbols in a file (document symbols) or search for symbols across the workspace. Provide 'path' for file symbols or 'query' for workspace search.",
promptSnippet: "List symbols in a file or search workspace symbols via LSP",
parameters: SymbolsParams,
async execute(_toolCallId, params) {
const filePath = params.path?.replace(/^@/, "");
const query = params.query;
if (!filePath && query === undefined) {
return {
content: [{ type: "text", text: "Please provide either 'path' for file symbols or 'query' for workspace symbol search." }],
details: { count: 0 },
};
}
// Document symbols
if (filePath) {
const client = await manager.getClientForFile(filePath).catch(() => null);
if (client) {
// LSP path
const uri = manager.getFileUri(filePath);
try {
const result = await client.sendRequest<DocumentSymbol[] | SymbolInformation[] | null>(
"textDocument/documentSymbol", { textDocument: { uri } }
);
if (!result || result.length === 0) {
return { content: [{ type: "text", text: "No symbols found in this file." }], details: { count: 0 } };
}
let lines: string[];
if ("range" in result[0]) {
lines = (result as DocumentSymbol[]).flatMap((s) => formatDocumentSymbol(s));
} else {
const rootDir = manager.resolvePath(".");
lines = (result as SymbolInformation[]).map((s) => formatSymbolInfo(s, rootDir));
}
const output = lines.join("\n");
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let text = `${lines.length} symbol(s):\n\n${truncation.content}`;
if (truncation.truncated) text += `\n\n[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`;
return { content: [{ type: "text", text }], details: { count: lines.length } };
} catch (err: any) {
return { content: [{ type: "text", text: `LSP document symbols request failed: ${err.message}` }], details: { count: 0 } };
}
}
// Tree-sitter fallback for document symbols
if (treeSitter) {
const provider = resolveProvider(filePath, manager, treeSitter);
if (provider.type === "tree-sitter") {
try {
const absPath = manager.resolvePath(filePath);
const content = await readFile(absPath, "utf-8");
const tree = await treeSitter.parse(absPath, content);
if (tree) {
const symbols = extractSymbols(tree, provider.languageId);
if (symbols.length === 0) {
return { content: [{ type: "text", text: "No symbols found in this file." }], details: { count: 0 } };
}
const lines = symbols.flatMap((s) => formatTreeSitterSymbol(s));
const output = lines.join("\n");
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let text = `${lines.length} symbol(s) [tree-sitter]:\n\n${truncation.content}`;
if (truncation.truncated) text += `\n\n[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`;
return { content: [{ type: "text", text }], details: { count: lines.length } };
}
} catch { /* fall through */ }
}
}
return { content: [{ type: "text", text: manager.getUnavailableReason(filePath) }], details: { count: 0 } };
}
// Workspace symbol search
const statuses = manager.getStatus();
const runningLang = statuses.find((s) => s.running)?.languageId;
if (runningLang) {
const client = await manager.getClientForLanguage(runningLang).catch(() => null);
if (client) {
try {
const result = await client.sendRequest<(SymbolInformation | WorkspaceSymbol)[] | null>(
"workspace/symbol", { query: query ?? "" }
);
if (!result || result.length === 0) {
return { content: [{ type: "text", text: `No workspace symbols found for query: "${query}"` }], details: { count: 0 } };
}
const rootDir = manager.resolvePath(".");
const lines = result.map((s) => formatSymbolInfo(s, rootDir));
const output = lines.join("\n");
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let text = `${result.length} symbol(s) found:\n\n${truncation.content}`;
if (truncation.truncated) text += `\n\n[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`;
return { content: [{ type: "text", text }], details: { count: result.length } };
} catch (err: any) {
return { content: [{ type: "text", text: `LSP workspace symbols request failed: ${err.message}` }], details: { count: 0 } };
}
}
}
// Tree-sitter fallback for workspace symbol search
if (workspaceIndex && query) {
try {
await workspaceIndex.build();
const results = workspaceIndex.search(query);
if (results.length === 0) {
return { content: [{ type: "text", text: `No workspace symbols found for query: "${query}" [tree-sitter]` }], details: { count: 0 } };
}
const rootDir = manager.resolvePath(".");
const lines = results.map((e) => {
const relPath = relative(rootDir, e.file);
return `${kindName(e.kind)} ${e.name} ${relPath}:${e.line}`;
});
const output = lines.join("\n");
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let text = `${results.length} symbol(s) found [tree-sitter]:\n\n${truncation.content}`;
if (truncation.truncated) text += `\n\n[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`;
return { content: [{ type: "text", text }], details: { count: results.length } };
} catch { /* fall through */ }
}
return {
content: [{ type: "text", text: "No LSP servers are currently running and no workspace index is available. Use lsp_diagnostics or lsp_hover on a file first to start a server." }],
details: { count: 0 },
};
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("lsp_symbols "));
if (args.path) text += theme.fg("accent", args.path);
if (args.query) text += theme.fg("accent", `query="${args.query}"`);
return new Text(text, 0, 0);
},
renderResult(result, { isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Loading symbols..."), 0, 0);
const details = result.details;
if (!details || details.count === 0) return new Text(theme.fg("dim", "No symbols"), 0, 0);
return new Text(theme.fg("success", `${details.count} symbol(s)`), 0, 0);
},
};
}

View File

@@ -0,0 +1,212 @@
/**
* Tree-sitter Parser Manager — loads and caches WASM parsers per language.
*
* Uses web-tree-sitter (WASM) so no native compilation is needed.
* Grammar .wasm files come from the tree-sitter-wasms npm package.
*/
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import Parser from "web-tree-sitter";
const require = createRequire(import.meta.url);
import { getLanguageIdFromPath } from "../shared/language-map.js";
type Language = Parser.Language;
type Tree = Parser.Tree;
/** Map language IDs to tree-sitter-wasms grammar file names */
const LANGUAGE_TO_GRAMMAR: Record<string, string> = {
typescript: "tree-sitter-typescript.wasm",
typescriptreact: "tree-sitter-tsx.wasm",
javascript: "tree-sitter-javascript.wasm",
javascriptreact: "tree-sitter-javascript.wasm",
python: "tree-sitter-python.wasm",
rust: "tree-sitter-rust.wasm",
go: "tree-sitter-go.wasm",
java: "tree-sitter-java.wasm",
c: "tree-sitter-c.wasm",
cpp: "tree-sitter-cpp.wasm",
ruby: "tree-sitter-ruby.wasm",
kotlin: "tree-sitter-kotlin.wasm",
scala: "tree-sitter-scala.wasm",
swift: "tree-sitter-swift.wasm",
lua: "tree-sitter-lua.wasm",
bash: "tree-sitter-bash.wasm",
json: "tree-sitter-json.wasm",
html: "tree-sitter-html.wasm",
css: "tree-sitter-css.wasm",
};
// File extension → language ID mapping is in shared/language-map.ts
interface CachedTree {
tree: Tree;
contentLength: number;
hashHead: number;
hashTail: number;
}
export class TreeSitterManager {
private initialized = false;
private initializing: Promise<void> | null = null;
private languages: Map<string, Language> = new Map();
private loadingLanguages: Map<string, Promise<Language | null>> = new Map();
private parsers: Map<string, Parser> = new Map();
private cachedTrees: Map<string, CachedTree> = new Map();
private grammarsDir: string;
constructor() {
// Use Node's module resolution so this works for local, global, and hoisted installs
const wasmsPkgJson = require.resolve("tree-sitter-wasms/package.json");
this.grammarsDir = resolve(dirname(wasmsPkgJson), "out");
}
/** Initialize web-tree-sitter WASM runtime. Must be called before any parsing. */
async init(): Promise<void> {
if (this.initialized) return;
if (this.initializing) {
await this.initializing;
return;
}
this.initializing = Parser.init({
locateFile: (scriptName: string) => {
// Use module resolution to find web-tree-sitter regardless of install layout
const webTsPkgJson = require.resolve("web-tree-sitter/package.json");
return resolve(dirname(webTsPkgJson), scriptName);
},
});
await this.initializing;
this.initialized = true;
}
/** Get the language ID for a file path based on extension */
getLanguageId(filePath: string): string | undefined {
return getLanguageIdFromPath(filePath);
}
/** Check if we have a grammar available for a language */
hasGrammar(languageId: string): boolean {
return languageId in LANGUAGE_TO_GRAMMAR;
}
/** Get all supported language IDs */
getSupportedLanguages(): string[] {
return Object.keys(LANGUAGE_TO_GRAMMAR);
}
/** Load a language grammar, caching the result */
async getLanguage(languageId: string): Promise<Language | null> {
const cached = this.languages.get(languageId);
if (cached) return cached;
// Deduplicate concurrent loads
const loading = this.loadingLanguages.get(languageId);
if (loading) return loading;
const grammarFile = LANGUAGE_TO_GRAMMAR[languageId];
if (!grammarFile) return null;
const loadPromise = (async (): Promise<Language | null> => {
await this.init();
try {
const grammarPath = resolve(this.grammarsDir, grammarFile);
const language = await Parser.Language.load(grammarPath);
this.languages.set(languageId, language);
return language;
} catch {
return null;
} finally {
this.loadingLanguages.delete(languageId);
}
})();
this.loadingLanguages.set(languageId, loadPromise);
return loadPromise;
}
/** Get or create a parser for a language */
private async getParser(languageId: string): Promise<Parser | null> {
const existing = this.parsers.get(languageId);
if (existing) return existing;
const language = await this.getLanguage(languageId);
if (!language) return null;
const parser = new Parser();
parser.setLanguage(language);
this.parsers.set(languageId, parser);
return parser;
}
/** Parse a file's content and return the tree. Caches by file path + content hash. */
async parse(filePath: string, content: string): Promise<Tree | null> {
const languageId = this.getLanguageId(filePath);
if (!languageId) return null;
return this.parseWithLanguage(filePath, content, languageId);
}
/** Parse content with an explicit language ID */
async parseWithLanguage(filePath: string, content: string, languageId: string): Promise<Tree | null> {
const length = content.length;
const head = djb2Hash(content, 0, Math.min(length, 4096));
const tail = length > 4096 ? djb2Hash(content, Math.max(0, length - 4096), length) : head;
const cached = this.cachedTrees.get(filePath);
if (cached && cached.contentLength === length && cached.hashHead === head && cached.hashTail === tail) {
return cached.tree;
}
const parser = await this.getParser(languageId);
if (!parser) return null;
const tree = parser.parse(content);
if (!tree) return null;
// Evict old tree
if (cached) cached.tree.delete();
this.cachedTrees.set(filePath, { tree, contentLength: length, hashHead: head, hashTail: tail });
return tree;
}
/** Invalidate the cached tree for a file */
invalidate(filePath: string): void {
const cached = this.cachedTrees.get(filePath);
if (cached) {
cached.tree.delete();
this.cachedTrees.delete(filePath);
}
}
/** Get a cached tree without re-parsing */
getCachedTree(filePath: string): Tree | null {
return this.cachedTrees.get(filePath)?.tree ?? null;
}
/** Shut down — free all resources */
shutdown(): void {
for (const [, cached] of this.cachedTrees) cached.tree.delete();
this.cachedTrees.clear();
for (const [, parser] of this.parsers) parser.delete();
this.parsers.clear();
this.languages.clear();
}
/** Alias for shutdown() — conventional dispose pattern for extension lifecycle */
dispose(): void {
this.shutdown();
}
}
/**
* Fast non-cryptographic hash (djb2) over a range of a string.
* Hashing head + tail separately gives collision resistance close to
* a full-content hash without iterating every character of large files.
*/
function djb2Hash(str: string, start: number, end: number): number {
let hash = 5381;
for (let i = start; i < end; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
}
return hash;
}

View File

@@ -0,0 +1,353 @@
/**
* Pattern Compiler — parse metavariable patterns into matchable pattern trees.
*
* User-facing patterns use `$NAME` for single-node wildcards and `$$$NAME` for
* variadic (zero-or-more) wildcards. The pattern string is parsed as code using
* tree-sitter, then the AST is walked to build a PatternNode tree where
* metavariable identifiers are replaced with wildcard matchers.
*/
import type Parser from "web-tree-sitter";
import type { TreeSitterManager } from "./parser-manager.js";
type SyntaxNode = Parser.SyntaxNode;
// ── Pattern node types ──────────────────────────────────────────────────────
export type PatternNode =
| LiteralPatternNode
| MetavarPatternNode
| VariadicPatternNode;
/** Matches a concrete AST node — type must match, and either text (leaf) or children (branch) */
export interface LiteralPatternNode {
kind: "literal";
/** tree-sitter node type to match (e.g., "identifier", "call_expression") */
nodeType: string;
/** For leaf nodes: exact text that must match */
text?: string;
/** For branch nodes: child patterns to match (named children only) */
children: PatternNode[];
/** Field name this node occupies in its parent (e.g., "function", "arguments") */
fieldName?: string;
}
/** Matches any single AST node and captures its text */
export interface MetavarPatternNode {
kind: "metavar";
/** Capture name (e.g., "ARG" from "$ARG") */
name: string;
/** Field name this node occupies in its parent */
fieldName?: string;
}
/** Matches zero or more consecutive sibling nodes */
export interface VariadicPatternNode {
kind: "variadic";
/** Capture name (e.g., "PARAMS" from "$$$PARAMS", or "" for anonymous "$$$") */
name: string;
/** Field name context */
fieldName?: string;
}
export interface CompiledPattern {
/** The root pattern node (unwrapped from program/expression_statement wrappers) */
root: PatternNode;
/** All metavariable names found in the pattern */
metavars: string[];
/** The language this pattern was compiled for */
languageId: string;
}
// ── Metavariable detection ──────────────────────────────────────────────────
const VARIADIC_RE = /^\$\$\$([A-Z_][A-Z0-9_]*)?$/;
const METAVAR_RE = /^\$([A-Z_][A-Z0-9_]*)$/;
function isVariadic(text: string): string | null {
const m = VARIADIC_RE.exec(text);
return m ? (m[1] ?? "") : null;
}
function isMetavar(text: string): string | null {
const m = METAVAR_RE.exec(text);
return m ? m[1] : null;
}
// ── Metavar placeholder encoding ────────────────────────────────────────────
const META_PREFIX = "__META_";
const VMETA_PREFIX = "__VMETA_";
const META_SUFFIX = "__";
/**
* Replace `$$$NAME` and `$NAME` with placeholder identifiers valid in any language.
* Returns the preprocessed source and a map from placeholder to {kind, name}.
*/
function preprocessMetavars(source: string): {
preprocessed: string;
placeholders: Map<string, { kind: "metavar" | "variadic"; name: string }>;
} {
const placeholders = new Map<string, { kind: "metavar" | "variadic"; name: string }>();
// Replace variadic first (longer prefix) to avoid $$ matching $
let preprocessed = source.replace(/\$\$\$([A-Z_][A-Z0-9_]*)?/g, (_match, name) => {
const n = name ?? "";
const placeholder = `${VMETA_PREFIX}${n || "ANON"}${META_SUFFIX}`;
placeholders.set(placeholder, { kind: "variadic", name: n });
return placeholder;
});
preprocessed = preprocessed.replace(/\$([A-Z_][A-Z0-9_]*)/g, (_match, name) => {
const placeholder = `${META_PREFIX}${name}${META_SUFFIX}`;
placeholders.set(placeholder, { kind: "metavar", name });
return placeholder;
});
return { preprocessed, placeholders };
}
/** Check if a text is a metavar placeholder */
function decodePlaceholder(
text: string,
placeholders: Map<string, { kind: "metavar" | "variadic"; name: string }>,
): { kind: "metavar" | "variadic"; name: string } | null {
return placeholders.get(text) ?? null;
}
// ── Compile ─────────────────────────────────────────────────────────────────
/**
* Compile a user-facing pattern string into a matchable PatternNode tree.
*
* Metavariables ($NAME, $$$NAME) are replaced with placeholder identifiers
* before parsing, so patterns work in any language regardless of whether `$`
* is a valid identifier character.
*
* Tries parsing the pattern as-is first, then with wrapper contexts if it
* contains syntax errors (e.g., an expression fragment that isn't a valid program).
*/
export async function compilePattern(
source: string,
languageId: string,
treeSitter: TreeSitterManager,
): Promise<CompiledPattern> {
await treeSitter.init();
const { preprocessed, placeholders } = preprocessMetavars(source);
// Language-specific wrappers for fragments that aren't full programs
const wrappers = getWrappersForLanguage(languageId);
for (const { wrap, unwrap } of wrappers) {
const wrapped = wrap(preprocessed);
const tree = await treeSitter.parseWithLanguage(
`__pattern__${Date.now()}`,
wrapped,
languageId,
);
if (!tree) continue;
const root = tree.rootNode;
if (hasErrors(root)) continue;
const unwrapped = unwrap(root);
if (!unwrapped) continue;
const metavars: string[] = [];
const patternNode = buildPatternNode(unwrapped, metavars, undefined, placeholders);
return { root: patternNode, metavars: [...new Set(metavars)], languageId };
}
throw new Error(
`Failed to parse pattern as ${languageId}. The pattern may have syntax errors ` +
`or may not be a valid code fragment in this language.`,
);
}
/** Get wrapper strategies appropriate for a language */
function getWrappersForLanguage(languageId: string): Array<{
wrap: (s: string) => string;
unwrap: (root: SyntaxNode) => SyntaxNode | null;
}> {
const common = [
{ wrap: (s: string) => s, unwrap: unwrapProgram },
{ wrap: (s: string) => `${s};`, unwrap: unwrapProgram },
{ wrap: (s: string) => `(${s})`, unwrap: unwrapExpression },
];
switch (languageId) {
case "python":
return [
{ wrap: (s: string) => s, unwrap: unwrapModule },
{ wrap: (s: string) => `(${s})`, unwrap: unwrapExpression },
{ wrap: (s: string) => `def _():\n ${s}`, unwrap: unwrapPythonFunctionBody },
];
default:
return [
...common,
{ wrap: (s: string) => `function _() { ${s} }`, unwrap: unwrapFunctionBody },
];
}
}
// ── AST → PatternNode conversion ────────────────────────────────────────────
function buildPatternNode(
node: SyntaxNode,
metavars: string[],
fieldName: string | undefined,
placeholders: Map<string, { kind: "metavar" | "variadic"; name: string }>,
): PatternNode {
const text = node.text;
// Check for placeholder-based metavar/variadic (new: language-agnostic)
if (node.namedChildCount === 0) {
const decoded = decodePlaceholder(text, placeholders);
if (decoded) {
if (decoded.name) metavars.push(decoded.name);
if (decoded.kind === "variadic") {
return { kind: "variadic", name: decoded.name, fieldName };
}
return { kind: "metavar", name: decoded.name, fieldName };
}
}
// Also check legacy $-prefixed metavars (for languages where $ is valid)
if (node.namedChildCount === 0) {
const variadicName = isVariadic(text);
if (variadicName !== null) {
if (variadicName) metavars.push(variadicName);
return { kind: "variadic", name: variadicName, fieldName };
}
const metavarName = isMetavar(text);
if (metavarName !== null) {
metavars.push(metavarName);
return { kind: "metavar", name: metavarName, fieldName };
}
}
// Branch node with a single child that is a placeholder → unwrap
if (node.namedChildCount === 1) {
const onlyChild = node.namedChildren[0];
if (onlyChild.namedChildCount === 0) {
const decoded = decodePlaceholder(onlyChild.text, placeholders);
if (decoded) {
if (decoded.name) metavars.push(decoded.name);
if (decoded.kind === "variadic") {
return { kind: "variadic", name: decoded.name, fieldName };
}
return { kind: "metavar", name: decoded.name, fieldName };
}
// Legacy $ check
const childMeta = isMetavar(onlyChild.text);
if (childMeta !== null) {
metavars.push(childMeta);
return { kind: "metavar", name: childMeta, fieldName };
}
const childVariadic = isVariadic(onlyChild.text);
if (childVariadic !== null) {
if (childVariadic) metavars.push(childVariadic);
return { kind: "variadic", name: childVariadic, fieldName };
}
}
}
// Leaf node — literal match
if (node.namedChildCount === 0) {
return { kind: "literal", nodeType: node.type, text, children: [], fieldName };
}
// Branch node — recurse into named children
const children: PatternNode[] = [];
for (const child of node.namedChildren) {
const childFieldName = getFieldName(node, child);
children.push(buildPatternNode(child, metavars, childFieldName, placeholders));
}
return { kind: "literal", nodeType: node.type, children, fieldName };
}
/** Get the field name for a child node relative to its parent */
function getFieldName(parent: SyntaxNode, child: SyntaxNode): string | undefined {
// Walk parent's children to find the field name for this child
for (let i = 0; i < parent.childCount; i++) {
const c = parent.child(i);
if (c && c.id === child.id) {
return parent.fieldNameForChild(i) ?? undefined;
}
}
return undefined;
}
// ── Unwrap helpers ──────────────────────────────────────────────────────────
/** Unwrap from program → first meaningful child */
function unwrapProgram(root: SyntaxNode): SyntaxNode | null {
if (root.type !== "program" || root.namedChildCount === 0) return null;
const child = root.namedChildren[0];
// Unwrap expression_statement wrapper if present
if (child.type === "expression_statement" && child.namedChildCount === 1) {
return child.namedChildren[0];
}
return child;
}
/** Unwrap from module → first meaningful child (Python uses "module" as root) */
function unwrapModule(root: SyntaxNode): SyntaxNode | null {
if (root.type !== "module" || root.namedChildCount === 0) return null;
const child = root.namedChildren[0];
if (child.type === "expression_statement" && child.namedChildCount === 1) {
return child.namedChildren[0];
}
return child;
}
/** Unwrap from program → expression_statement → parenthesized_expression → inner */
function unwrapExpression(root: SyntaxNode): SyntaxNode | null {
if (root.type !== "program" || root.namedChildCount === 0) return null;
let node = root.namedChildren[0];
if (node.type === "expression_statement") node = node.namedChildren[0];
if (node.type === "parenthesized_expression" && node.namedChildCount === 1) {
return node.namedChildren[0];
}
return node;
}
/** Unwrap from program → function_declaration → body → first statement */
function unwrapFunctionBody(root: SyntaxNode): SyntaxNode | null {
if (root.type !== "program" || root.namedChildCount === 0) return null;
const fn = root.namedChildren[0];
if (!fn.type.includes("function")) return null;
const body = fn.childForFieldName("body");
if (!body || body.namedChildCount === 0) return null;
const stmt = body.namedChildren[0];
if (stmt.type === "expression_statement" && stmt.namedChildCount === 1) {
return stmt.namedChildren[0];
}
return stmt;
}
/** Unwrap from module → function_definition → body → first statement (Python) */
function unwrapPythonFunctionBody(root: SyntaxNode): SyntaxNode | null {
if (root.type !== "module" || root.namedChildCount === 0) return null;
const fn = root.namedChildren[0];
if (fn.type !== "function_definition") return null;
const body = fn.childForFieldName("body");
if (!body || body.namedChildCount === 0) return null;
const stmt = body.namedChildren[0];
if (stmt.type === "expression_statement" && stmt.namedChildCount === 1) {
return stmt.namedChildren[0];
}
return stmt;
}
/** Check if an AST node contains any ERROR nodes */
function hasErrors(node: SyntaxNode): boolean {
if (node.type === "ERROR" || node.isMissing) return true;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child && hasErrors(child)) return true;
}
return false;
}

View File

@@ -0,0 +1,138 @@
/**
* Rewrite Engine — apply structural replacements using matched patterns.
*
* Takes search matches and a replacement template with metavariable references,
* substitutes captured values, and applies the text changes.
*/
import { readFile, writeFile } from "node:fs/promises";
import type { SearchMatch } from "./search-engine.js";
// ── Result types ────────────────────────────────────────────────────────────
export interface RewriteChange {
file: string;
line: number;
column: number;
before: string;
after: string;
}
export interface RewriteResult {
changes: RewriteChange[];
filesModified: number;
}
// ── Replacement template substitution ───────────────────────────────────────
const METAVAR_REF_RE = /\$\$\$([A-Z_][A-Z0-9_]*)|\$([A-Z_][A-Z0-9_]*)/g;
/**
* Substitute metavariable references in a replacement template with captured values.
*/
export function substituteCaptures(
template: string,
captures: Record<string, string>,
): string {
return template.replace(METAVAR_REF_RE, (match, variadicName, singleName) => {
const name = variadicName ?? singleName;
if (name in captures) return captures[name];
return match; // Leave unmatched references as-is
});
}
/**
* If the original matched text ends with a semicolon (possibly preceded by whitespace)
* and the replacement doesn't, append the semicolon. This preserves statement terminators
* that are part of the AST node but not part of the pattern/replacement.
*/
function preserveTrailingSemicolon(original: string, replacement: string): string {
const trailingMatch = original.match(/(\s*;)\s*$/);
if (trailingMatch && !replacement.trimEnd().endsWith(";")) {
return replacement + trailingMatch[1];
}
return replacement;
}
// ── Rewrite application ─────────────────────────────────────────────────────
/**
* Compute rewrite changes from matches and a replacement template.
* Returns the list of changes without applying them.
*/
export function computeRewrites(
matches: SearchMatch[],
replacementTemplate: string,
): RewriteChange[] {
return matches.map((m) => {
const raw = substituteCaptures(replacementTemplate, m.captures);
const after = preserveTrailingSemicolon(m.matchedText, raw);
return {
file: m.file,
line: m.line,
column: m.column,
before: m.matchedText,
after,
};
});
}
/**
* Apply rewrite changes to files. Modifies files in-place.
* Applies changes bottom-up (last offset first) within each file to preserve byte offsets.
*/
export async function applyRewrites(
matches: SearchMatch[],
replacementTemplate: string,
): Promise<RewriteResult> {
// Group matches by file
const byFile = new Map<string, SearchMatch[]>();
for (const m of matches) {
const existing = byFile.get(m.file);
if (existing) {
existing.push(m);
} else {
byFile.set(m.file, [m]);
}
}
const changes: RewriteChange[] = [];
let filesModified = 0;
for (const [file, fileMatches] of byFile) {
// Sort by startIndex descending — apply from bottom to top
const sorted = [...fileMatches].sort((a, b) => b.startIndex - a.startIndex);
let content = await readFile(file, "utf-8");
let modified = false;
for (const m of sorted) {
const raw = substituteCaptures(replacementTemplate, m.captures);
const replacement = preserveTrailingSemicolon(m.matchedText, raw);
if (replacement !== m.matchedText) {
content =
content.slice(0, m.startIndex) +
replacement +
content.slice(m.endIndex);
modified = true;
}
changes.push({
file,
line: m.line,
column: m.column,
before: m.matchedText,
after: replacement,
});
}
if (modified) {
await writeFile(file, content, "utf-8");
filesModified++;
}
}
// Reverse so changes appear top-to-bottom
changes.reverse();
return { changes, filesModified };
}

View File

@@ -0,0 +1,359 @@
/**
* Search Engine — run compiled patterns against source files and collect matches.
*
* Uses a recursive AST matcher: the target file's tree-sitter AST is walked
* depth-first, and at each node we attempt to match the compiled pattern tree.
* Metavariable nodes capture any single AST node; variadic nodes capture
* zero-or-more siblings.
*/
import { resolve } from "node:path";
import { readdir, readFile, stat } from "node:fs/promises";
import type Parser from "web-tree-sitter";
import type { TreeSitterManager } from "./parser-manager.js";
import type {
CompiledPattern,
PatternNode,
} from "./pattern-compiler.js";
import { SKIP_DIRS, MAX_FILE_SIZE, MAX_INDEX_FILES } from "../shared/constants.js";
type SyntaxNode = Parser.SyntaxNode;
// ── Result types ────────────────────────────────────────────────────────────
export interface SearchMatch {
/** Absolute file path */
file: string;
/** 1-indexed line number */
line: number;
/** 1-indexed column */
column: number;
/** The matched source text */
matchedText: string;
/** Byte offset of match start */
startIndex: number;
/** Byte offset of match end */
endIndex: number;
/** Metavariable bindings: name → captured text */
captures: Record<string, string>;
}
// ── Directories to skip ─────────────────────────────────────────────────────
// Shared constants imported from ../shared/constants.ts
// ── Public API ──────────────────────────────────────────────────────────────
/**
* Search files for matches against a compiled pattern.
*/
export async function searchFiles(
pattern: CompiledPattern,
rootDir: string,
treeSitter: TreeSitterManager,
options: {
path?: string;
maxResults?: number;
} = {},
): Promise<SearchMatch[]> {
const searchRoot = options.path ? resolve(rootDir, options.path) : rootDir;
const maxResults = options.maxResults ?? 50;
// Determine if searchRoot is a file or directory
const stats = await stat(searchRoot);
let files: string[];
if (stats.isFile()) {
files = [searchRoot];
} else {
files = await collectFilesByLanguage(searchRoot, pattern.languageId, treeSitter);
}
const matches: SearchMatch[] = [];
for (const file of files) {
if (matches.length >= maxResults) break;
try {
const content = await readFile(file, "utf-8");
const tree = await treeSitter.parseWithLanguage(file, content, pattern.languageId);
if (!tree) continue;
const fileMatches = findMatches(tree.rootNode, pattern.root);
for (const m of fileMatches) {
if (matches.length >= maxResults) break;
matches.push({
file,
line: m.node.startPosition.row + 1,
column: m.node.startPosition.column + 1,
matchedText: m.node.text,
startIndex: m.node.startIndex,
endIndex: m.node.endIndex,
captures: m.captures,
});
}
} catch {
// Skip unreadable files
}
}
return matches;
}
// ── File collection ─────────────────────────────────────────────────────────
/**
* Collect all files matching a given language under a directory.
*/
export async function collectFilesByLanguage(
dir: string,
languageId: string,
treeSitter: TreeSitterManager,
collected: string[] = [],
maxFiles: number = MAX_INDEX_FILES,
): Promise<string[]> {
if (collected.length >= maxFiles) return collected;
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (collected.length >= maxFiles) break;
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
await collectFilesByLanguage(resolve(dir, entry.name), languageId, treeSitter, collected, maxFiles);
} else if (entry.isFile()) {
const fileLang = treeSitter.getLanguageId(entry.name);
if (fileLang === languageId) {
const filePath = resolve(dir, entry.name);
try {
const s = await stat(filePath);
if (s.size <= MAX_FILE_SIZE) {
collected.push(filePath);
}
} catch {}
}
}
}
} catch {
// Permission denied or other IO error — skip
}
return collected;
}
// ── Matching engine ─────────────────────────────────────────────────────────
interface RawMatch {
node: SyntaxNode;
captures: Record<string, string>;
}
/**
* Find all non-overlapping matches of the pattern in the target AST.
* Walks the tree depth-first, attempting to match at each node.
*/
function findMatches(root: SyntaxNode, pattern: PatternNode): RawMatch[] {
const matches: RawMatch[] = [];
const visited = new Set<number>(); // node ids already part of a match
function walk(node: SyntaxNode): void {
if (visited.has(node.id)) return;
const captures: Record<string, string> = {};
if (matchNode(node, pattern, captures)) {
matches.push({ node, captures });
// Mark all descendant nodes as visited to prevent overlapping matches
markDescendants(node, visited);
return; // Don't recurse into matched subtree
}
// Recurse into children
for (const child of node.namedChildren) {
walk(child);
}
}
walk(root);
return matches;
}
function markDescendants(node: SyntaxNode, visited: Set<number>): void {
visited.add(node.id);
for (const child of node.namedChildren) {
markDescendants(child, visited);
}
}
/**
* Try to match a target AST node against a pattern node.
* Returns true if matched, populating `captures` with metavariable bindings.
*/
function matchNode(
target: SyntaxNode,
pattern: PatternNode,
captures: Record<string, string>,
): boolean {
switch (pattern.kind) {
case "metavar": {
// A metavar matches any single node
const name = pattern.name;
if (name in captures) {
// Already captured — must match same text
return captures[name] === target.text;
}
captures[name] = target.text;
return true;
}
case "variadic": {
// Variadic at the top level matches any single node
// (variadic is really only meaningful in a children-list context)
const name = pattern.name;
if (name && name in captures) {
return captures[name] === target.text;
}
if (name) captures[name] = target.text;
return true;
}
case "literal": {
// Must match node type
if (target.type !== pattern.nodeType) return false;
// Leaf node: must match text exactly
if (pattern.text !== undefined) {
return target.text === pattern.text;
}
// Branch node: use field-aware matching
// Pattern children that have field names → match against target children with same field name
// This allows the target to have extra children not mentioned in the pattern
return matchChildrenFieldAware(target, pattern.children, captures);
}
}
}
/**
* Match pattern children against target node's children using field-name-aware matching.
*
* Strategy:
* 1. Pattern children WITH field names: find the target child with the same field name and match
* 2. Pattern children WITHOUT field names: match positionally against unmatched target children
* 3. Extra target children not mentioned in the pattern are allowed (implicit wildcards)
*/
function matchChildrenFieldAware(
targetNode: SyntaxNode,
patternChildren: PatternNode[],
captures: Record<string, string>,
): boolean {
// Separate pattern children into field-named and positional
const fieldPatterns: PatternNode[] = [];
const positionalPatterns: PatternNode[] = [];
for (const pc of patternChildren) {
if (pc.fieldName) {
fieldPatterns.push(pc);
} else {
positionalPatterns.push(pc);
}
}
// First, match all field-named pattern children
for (const fp of fieldPatterns) {
const targetChild = targetNode.childForFieldName(fp.fieldName!);
if (!targetChild) return false;
if (!matchNode(targetChild, fp, captures)) return false;
}
// If there are positional patterns, match them against the remaining unnamed target children
if (positionalPatterns.length > 0) {
// Get target children that aren't already matched by field names
const matchedFieldNames = new Set(fieldPatterns.map(fp => fp.fieldName));
const remainingTargets: SyntaxNode[] = [];
for (const child of targetNode.namedChildren) {
const childField = getChildFieldName(targetNode, child);
if (!childField || !matchedFieldNames.has(childField)) {
remainingTargets.push(child);
}
}
return matchChildrenPositional(remainingTargets, 0, positionalPatterns, 0, captures);
}
return true;
}
/** Get the field name of a child node within its parent */
function getChildFieldName(parent: SyntaxNode, child: SyntaxNode): string | null {
for (let i = 0; i < parent.childCount; i++) {
const c = parent.child(i);
if (c && c.id === child.id) {
return parent.fieldNameForChild(i);
}
}
return null;
}
/**
* Match pattern children positionally against target children.
* Handles variadic patterns that can match zero or more consecutive children.
* Extra target children at the end are allowed (pattern doesn't need to cover all children).
*/
function matchChildrenPositional(
targets: SyntaxNode[],
ti: number,
patterns: PatternNode[],
pi: number,
captures: Record<string, string>,
): boolean {
// All patterns consumed — success (extra targets are OK)
if (pi >= patterns.length) return true;
const pat = patterns[pi];
// Variadic pattern: try matching 0, 1, 2, ... consecutive target nodes
if (pat.kind === "variadic") {
const isLast = pi === patterns.length - 1;
// Optimization: if this is the last pattern, consume all remaining
if (isLast) {
const remainingText = targets.slice(ti).map(t => t.text).join(", ");
if (pat.name) {
if (pat.name in captures && captures[pat.name] !== remainingText) return false;
captures[pat.name] = remainingText;
}
return true;
}
// Try consuming 0..N target nodes
for (let take = 0; take <= targets.length - ti; take++) {
const captureSnapshot = { ...captures };
const consumedText = targets.slice(ti, ti + take).map(t => t.text).join(", ");
if (pat.name) {
if (pat.name in captureSnapshot && captureSnapshot[pat.name] !== consumedText) continue;
captureSnapshot[pat.name] = consumedText;
}
if (matchChildrenPositional(targets, ti + take, patterns, pi + 1, captureSnapshot)) {
// Success — commit captures
Object.assign(captures, captureSnapshot);
return true;
}
}
return false;
}
// Non-variadic pattern: must match the current target
if (ti >= targets.length) return false;
const captureSnapshot = { ...captures };
if (matchNode(targets[ti], pat, captureSnapshot)) {
if (matchChildrenPositional(targets, ti + 1, patterns, pi + 1, captureSnapshot)) {
Object.assign(captures, captureSnapshot);
return true;
}
}
return false;
}

View File

@@ -0,0 +1,398 @@
/**
* Symbol Extractor — extracts symbols from tree-sitter ASTs.
*
* Per-language node type mappings for functions, classes, methods, interfaces, etc.
* Output format matches LSP DocumentSymbol shape for compatibility.
*/
import type Parser from "web-tree-sitter";
type Tree = Parser.Tree;
type Node = Parser.SyntaxNode;
/** Symbol kinds matching LSP SymbolKind values */
export const SymbolKind = {
File: 1, Module: 2, Namespace: 3, Package: 4, Class: 5, Method: 6,
Property: 7, Field: 8, Constructor: 9, Enum: 10, Interface: 11,
Function: 12, Variable: 13, Constant: 14, String: 15, Number: 16,
Boolean: 17, Array: 18, Object: 19, Struct: 22,
} as const;
export type SymbolKindValue = typeof SymbolKind[keyof typeof SymbolKind];
export interface SymbolInfo {
name: string;
kind: SymbolKindValue;
line: number; // 1-indexed
endLine: number; // 1-indexed
children?: SymbolInfo[];
}
/** Node types that represent symbol declarations, per language family */
interface SymbolMapping {
nodeType: string;
kind: SymbolKindValue;
/** Field name to extract the symbol name from (default: "name") */
nameField?: string;
/** Whether to recurse into children for nested symbols */
recurse?: boolean;
}
const TS_JS_SYMBOLS: SymbolMapping[] = [
{ nodeType: "function_declaration", kind: SymbolKind.Function },
{ nodeType: "class_declaration", kind: SymbolKind.Class, recurse: true },
{ nodeType: "interface_declaration", kind: SymbolKind.Interface, recurse: true },
{ nodeType: "enum_declaration", kind: SymbolKind.Enum, recurse: true },
{ nodeType: "type_alias_declaration", kind: SymbolKind.Variable },
{ nodeType: "method_definition", kind: SymbolKind.Method },
{ nodeType: "public_field_definition", kind: SymbolKind.Field },
{ nodeType: "abstract_method_signature", kind: SymbolKind.Method },
{ nodeType: "lexical_declaration", kind: SymbolKind.Variable },
{ nodeType: "variable_declaration", kind: SymbolKind.Variable },
];
const PYTHON_SYMBOLS: SymbolMapping[] = [
{ nodeType: "function_definition", kind: SymbolKind.Function },
{ nodeType: "class_definition", kind: SymbolKind.Class, recurse: true },
{ nodeType: "decorated_definition", kind: SymbolKind.Function },
];
const RUST_SYMBOLS: SymbolMapping[] = [
{ nodeType: "function_item", kind: SymbolKind.Function },
{ nodeType: "struct_item", kind: SymbolKind.Struct },
{ nodeType: "enum_item", kind: SymbolKind.Enum, recurse: true },
{ nodeType: "impl_item", kind: SymbolKind.Class, recurse: true },
{ nodeType: "trait_item", kind: SymbolKind.Interface, recurse: true },
{ nodeType: "mod_item", kind: SymbolKind.Module, recurse: true },
{ nodeType: "type_item", kind: SymbolKind.Variable },
{ nodeType: "const_item", kind: SymbolKind.Constant },
{ nodeType: "static_item", kind: SymbolKind.Constant },
{ nodeType: "macro_definition", kind: SymbolKind.Function },
];
const GO_SYMBOLS: SymbolMapping[] = [
{ nodeType: "function_declaration", kind: SymbolKind.Function },
{ nodeType: "method_declaration", kind: SymbolKind.Method },
{ nodeType: "type_declaration", kind: SymbolKind.Class },
{ nodeType: "type_spec", kind: SymbolKind.Class },
{ nodeType: "const_declaration", kind: SymbolKind.Constant },
{ nodeType: "var_declaration", kind: SymbolKind.Variable },
];
const JAVA_SYMBOLS: SymbolMapping[] = [
{ nodeType: "class_declaration", kind: SymbolKind.Class, recurse: true },
{ nodeType: "interface_declaration", kind: SymbolKind.Interface, recurse: true },
{ nodeType: "enum_declaration", kind: SymbolKind.Enum, recurse: true },
{ nodeType: "method_declaration", kind: SymbolKind.Method },
{ nodeType: "constructor_declaration", kind: SymbolKind.Constructor },
{ nodeType: "field_declaration", kind: SymbolKind.Field },
{ nodeType: "annotation_type_declaration", kind: SymbolKind.Interface },
];
const C_CPP_SYMBOLS: SymbolMapping[] = [
{ nodeType: "function_definition", kind: SymbolKind.Function },
{ nodeType: "declaration", kind: SymbolKind.Variable },
{ nodeType: "struct_specifier", kind: SymbolKind.Struct },
{ nodeType: "enum_specifier", kind: SymbolKind.Enum },
{ nodeType: "class_specifier", kind: SymbolKind.Class, recurse: true },
{ nodeType: "namespace_definition", kind: SymbolKind.Namespace, recurse: true },
];
const RUBY_SYMBOLS: SymbolMapping[] = [
{ nodeType: "method", kind: SymbolKind.Method },
{ nodeType: "singleton_method", kind: SymbolKind.Method },
{ nodeType: "class", kind: SymbolKind.Class, recurse: true },
{ nodeType: "module", kind: SymbolKind.Module, recurse: true },
];
const LANGUAGE_SYMBOLS: Record<string, SymbolMapping[]> = {
typescript: TS_JS_SYMBOLS,
typescriptreact: TS_JS_SYMBOLS,
javascript: TS_JS_SYMBOLS,
javascriptreact: TS_JS_SYMBOLS,
python: PYTHON_SYMBOLS,
rust: RUST_SYMBOLS,
go: GO_SYMBOLS,
java: JAVA_SYMBOLS,
c: C_CPP_SYMBOLS,
cpp: C_CPP_SYMBOLS,
ruby: RUBY_SYMBOLS,
};
/**
* Extract symbols from a parsed tree.
*/
export function extractSymbols(tree: Tree, languageId: string): SymbolInfo[] {
const mappings = LANGUAGE_SYMBOLS[languageId];
if (!mappings) return extractGenericSymbols(tree);
return extractFromNode(tree.rootNode, mappings, languageId);
}
function extractFromNode(node: Node, mappings: SymbolMapping[], languageId: string, deep = false): SymbolInfo[] {
const symbols: SymbolInfo[] = [];
for (const child of node.namedChildren) {
const mapping = mappings.find((m) => m.nodeType === child.type);
if (mapping) {
const name = extractName(child, mapping, languageId);
if (name) {
const sym: SymbolInfo = {
name,
kind: mapping.kind,
line: child.startPosition.row + 1,
endLine: child.endPosition.row + 1,
};
if (mapping.recurse) {
const children = extractFromNode(child, mappings, languageId, true);
if (children.length > 0) sym.children = children;
}
symbols.push(sym);
}
} else if (child.type === "export_statement" && (languageId.startsWith("typescript") || languageId.startsWith("javascript"))) {
// Unwrap export statements to find the actual declaration
const inner = extractFromNode(child, mappings, languageId, deep);
symbols.push(...inner);
} else if (deep && child.namedChildCount > 0) {
// Walk through container nodes (class_body, interface_body, etc.)
// to find nested declarations when recursing inside a parent
symbols.push(...extractFromNode(child, mappings, languageId, true));
}
}
return symbols;
}
/** Extract the name of a symbol from a node */
function extractName(node: Node, mapping: SymbolMapping, languageId: string): string | null {
// Try the "name" field first (works for most declarations)
const nameNode = node.childForFieldName(mapping.nameField ?? "name");
if (nameNode) return nameNode.text;
// Language-specific fallbacks
switch (node.type) {
case "lexical_declaration":
case "variable_declaration": {
// const foo = ..., let bar = ...
const declarator = node.namedChildren.find(
(c) => c.type === "variable_declarator" || c.type === "init_declarator"
);
if (declarator) {
const n = declarator.childForFieldName("name");
return n?.text ?? null;
}
return null;
}
case "decorated_definition": {
// Python: @decorator \n def foo(): ...
const inner = node.namedChildren.find(
(c) => c.type === "function_definition" || c.type === "class_definition"
);
if (inner) {
const n = inner.childForFieldName("name");
return n?.text ?? null;
}
return null;
}
case "impl_item": {
// Rust: impl Foo { ... } or impl Trait for Foo { ... }
const typeNode = node.childForFieldName("type");
if (typeNode) return `impl ${typeNode.text}`;
return null;
}
case "type_declaration": {
// Go: type Foo struct { ... }
const spec = node.namedChildren.find((c) => c.type === "type_spec");
if (spec) {
const n = spec.childForFieldName("name");
return n?.text ?? null;
}
return null;
}
case "const_declaration":
case "var_declaration": {
// Go: const/var declarations
const spec = node.namedChildren.find(
(c) => c.type === "const_spec" || c.type === "var_spec"
);
if (spec) {
const n = spec.childForFieldName("name");
return n?.text ?? null;
}
return null;
}
case "field_declaration": {
// Java: field declarations
const declarator = node.namedChildren.find((c) => c.type === "variable_declarator");
if (declarator) {
const n = declarator.childForFieldName("name");
return n?.text ?? null;
}
return null;
}
case "declaration": {
// C/C++: declarations
const declarator = node.namedChildren.find(
(c) => c.type === "init_declarator" || c.type === "function_declarator"
);
if (declarator) {
const n = declarator.childForFieldName("declarator") ?? declarator.childForFieldName("name");
return n?.text ?? null;
}
return null;
}
default:
break;
}
// Last resort: first named child's text (truncated)
const first = node.firstNamedChild;
if (first && first.text.length < 60) return first.text;
return null;
}
/** Generic fallback for languages without specific mappings */
function extractGenericSymbols(tree: Tree): SymbolInfo[] {
const symbols: SymbolInfo[] = [];
const root = tree.rootNode;
for (const child of root.namedChildren) {
// Look for common declaration patterns
if (child.type.includes("function") || child.type.includes("method")) {
const name = child.childForFieldName("name")?.text;
if (name) {
symbols.push({
name,
kind: SymbolKind.Function,
line: child.startPosition.row + 1,
endLine: child.endPosition.row + 1,
});
}
} else if (child.type.includes("class") || child.type.includes("struct")) {
const name = child.childForFieldName("name")?.text;
if (name) {
symbols.push({
name,
kind: SymbolKind.Class,
line: child.startPosition.row + 1,
endLine: child.endPosition.row + 1,
});
}
}
}
return symbols;
}
/**
* Get the named node at a specific position in the tree.
* Returns the smallest named node that contains the position.
*/
export function getNodeAtPosition(tree: Tree, line: number, character: number): Node | null {
// tree-sitter uses 0-indexed positions
const point = { row: line, column: character };
return tree.rootNode.namedDescendantForPosition(point);
}
/**
* Find definition(s) of a symbol name within a tree.
* Searches for declaration nodes whose name matches.
*/
export function findDefinition(tree: Tree, symbolName: string, languageId: string): SymbolInfo[] {
const allSymbols = extractSymbols(tree, languageId);
return findSymbolByName(allSymbols, symbolName);
}
function findSymbolByName(symbols: SymbolInfo[], name: string): SymbolInfo[] {
const results: SymbolInfo[] = [];
for (const sym of symbols) {
if (sym.name === name) results.push(sym);
if (sym.children) results.push(...findSymbolByName(sym.children, name));
}
return results;
}
/**
* Get syntax errors from a parsed tree.
* Returns ERROR and MISSING nodes as diagnostic-like objects.
*/
export function getSyntaxErrors(tree: Tree): Array<{
line: number;
character: number;
endLine: number;
endCharacter: number;
message: string;
}> {
const errors: Array<{
line: number;
character: number;
endLine: number;
endCharacter: number;
message: string;
}> = [];
function walk(node: Node): void {
if (node.isError) {
errors.push({
line: node.startPosition.row,
character: node.startPosition.column,
endLine: node.endPosition.row,
endCharacter: node.endPosition.column,
message: `Syntax error: unexpected "${node.text.slice(0, 50)}${node.text.length > 50 ? "..." : ""}"`,
});
} else if (node.isMissing) {
errors.push({
line: node.startPosition.row,
character: node.startPosition.column,
endLine: node.endPosition.row,
endCharacter: node.endPosition.column,
message: `Missing ${node.type}`,
});
} else if (node.hasError) {
// Recurse into children only if this node contains errors
for (const child of node.children) {
walk(child);
}
}
}
walk(tree.rootNode);
return errors;
}
/**
* Get the signature text for a node (for hover info).
* Extracts the first line of the declaration.
*/
export function getSignatureText(node: Node): string {
const text = node.text;
const firstLine = text.split("\n")[0];
// Trim trailing { or : for cleaner display
return firstLine.replace(/\s*[{:]\s*$/, "").trim();
}
/**
* Get the enclosing declaration node for a position.
* Walks up from the node at position to find the nearest declaration.
*/
export function getEnclosingDeclaration(tree: Tree, line: number, character: number): Node | null {
const point = { row: line, column: character };
let node: Node | null = tree.rootNode.namedDescendantForPosition(point);
const declarationTypes = new Set([
"function_declaration", "function_definition", "function_item",
"method_declaration", "method_definition",
"class_declaration", "class_definition", "class_specifier",
"interface_declaration", "enum_declaration", "enum_item",
"struct_item", "impl_item", "trait_item", "mod_item",
"type_alias_declaration", "type_declaration",
"variable_declarator", "lexical_declaration",
"const_item", "static_item",
"decorated_definition",
]);
while (node) {
if (declarationTypes.has(node.type)) return node;
node = node.parent;
}
return null;
}

View File

@@ -0,0 +1,237 @@
/**
* Workspace Index — project-wide symbol index with incremental updates.
*
* Walks the project tree, parses files with tree-sitter, and maintains
* an in-memory symbol index keyed by name for fast lookup.
*/
import { resolve } from "node:path";
import { readdir, stat, readFile } from "node:fs/promises";
import { TreeSitterManager } from "./parser-manager.js";
import { extractSymbols, type SymbolInfo, type SymbolKindValue } from "./symbol-extractor.js";
import { SKIP_DIRS, MAX_FILE_SIZE, MAX_INDEX_FILES } from "../shared/constants.js";
export interface SymbolEntry {
name: string;
kind: SymbolKindValue;
file: string; // absolute path
line: number; // 1-indexed
}
// Shared constants imported from ../shared/constants.ts
export class WorkspaceIndex {
/** Map from symbol name (lowercase) to entries */
private index: Map<string, SymbolEntry[]> = new Map();
/** Reverse index: file path → set of index keys that have entries for this file */
private fileToKeys: Map<string, Set<string>> = new Map();
/** Set of indexed file paths (absolute) */
private indexedFiles: Set<string> = new Set();
/** Whether the initial build has completed */
private built = false;
private building: Promise<void> | null = null;
constructor(
private rootDir: string,
private treeSitter: TreeSitterManager,
) {}
/** Build the index by walking the project tree. Deduplicates concurrent calls. */
async build(): Promise<void> {
if (this.built) return;
if (this.building) {
await this.building;
return;
}
this.building = this._build();
await this.building;
this.built = true;
this.building = null;
}
private async _build(): Promise<void> {
const files = await this.collectFiles(this.rootDir);
// Parse and index each file
const batchSize = 50;
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
await Promise.all(batch.map((f) => this.indexFile(f).catch(() => {})));
}
}
/** Collect all indexable files under a directory */
private async collectFiles(dir: string, collected: string[] = []): Promise<string[]> {
if (collected.length >= MAX_INDEX_FILES) return collected;
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (collected.length >= MAX_INDEX_FILES) break;
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
await this.collectFiles(resolve(dir, entry.name), collected);
} else if (entry.isFile()) {
const languageId = this.treeSitter.getLanguageId(entry.name);
if (languageId && this.treeSitter.hasGrammar(languageId)) {
collected.push(resolve(dir, entry.name));
}
}
}
} catch {
// Permission denied or other IO error — skip
}
return collected;
}
/** Index (or re-index) a single file */
async indexFile(filePath: string): Promise<void> {
const absPath = resolve(filePath);
// Remove old entries for this file
this.removeFile(absPath);
try {
const stats = await stat(absPath);
if (stats.size > MAX_FILE_SIZE) return;
const content = await readFile(absPath, "utf-8");
const languageId = this.treeSitter.getLanguageId(absPath);
if (!languageId) return;
const tree = await this.treeSitter.parse(absPath, content);
if (!tree) return;
const symbols = extractSymbols(tree, languageId);
this.addSymbols(absPath, symbols);
this.indexedFiles.add(absPath);
} catch {
// File might not exist or be unreadable
}
}
/** Index a file from already-available content (avoids re-reading) */
async indexFileContent(filePath: string, content: string): Promise<void> {
const absPath = resolve(filePath);
this.removeFile(absPath);
const languageId = this.treeSitter.getLanguageId(absPath);
if (!languageId) return;
const tree = await this.treeSitter.parse(absPath, content);
if (!tree) return;
const symbols = extractSymbols(tree, languageId);
this.addSymbols(absPath, symbols);
this.indexedFiles.add(absPath);
}
private addSymbols(filePath: string, symbols: SymbolInfo[]): void {
for (const sym of symbols) {
const entry: SymbolEntry = {
name: sym.name,
kind: sym.kind,
file: filePath,
line: sym.line,
};
const key = sym.name.toLowerCase();
const existing = this.index.get(key);
if (existing) {
existing.push(entry);
} else {
this.index.set(key, [entry]);
}
// Track in reverse index for fast removal
let keys = this.fileToKeys.get(filePath);
if (!keys) {
keys = new Set();
this.fileToKeys.set(filePath, keys);
}
keys.add(key);
// Recurse into children
if (sym.children) {
this.addSymbols(filePath, sym.children);
}
}
}
/** Remove all entries for a file (O(keys-per-file) via reverse index) */
removeFile(filePath: string): void {
const absPath = resolve(filePath);
this.indexedFiles.delete(absPath);
// Use reverse index for targeted removal
const keys = this.fileToKeys.get(absPath);
if (keys) {
for (const key of keys) {
const entries = this.index.get(key);
if (!entries) continue;
const filtered = entries.filter((e) => e.file !== absPath);
if (filtered.length === 0) {
this.index.delete(key);
} else {
this.index.set(key, filtered);
}
}
this.fileToKeys.delete(absPath);
}
}
/** Search for symbols matching a query (fuzzy) */
search(query: string): SymbolEntry[] {
if (!query) return [];
const queryLower = query.toLowerCase();
// Exact match first
const exact = this.index.get(queryLower) ?? [];
// Prefix/substring matches
const fuzzy: SymbolEntry[] = [];
for (const [key, entries] of this.index) {
if (key === queryLower) continue; // already included
if (key.includes(queryLower)) {
fuzzy.push(...entries);
}
}
// Sort: exact matches first, then by name length (shorter = more relevant)
const results = [...exact, ...fuzzy];
results.sort((a, b) => {
const aExact = a.name.toLowerCase() === queryLower ? 0 : 1;
const bExact = b.name.toLowerCase() === queryLower ? 0 : 1;
if (aExact !== bExact) return aExact - bExact;
return a.name.length - b.name.length;
});
return results.slice(0, 100); // Cap results
}
/** Get all symbols for a specific file */
getSymbolsForFile(filePath: string): SymbolEntry[] {
const absPath = resolve(filePath);
const results: SymbolEntry[] = [];
for (const entries of this.index.values()) {
for (const entry of entries) {
if (entry.file === absPath) results.push(entry);
}
}
return results.sort((a, b) => a.line - b.line);
}
/** Get index stats */
getStats(): { files: number; symbols: number } {
let symbolCount = 0;
for (const entries of this.index.values()) {
symbolCount += entries.length;
}
return { files: this.indexedFiles.size, symbols: symbolCount };
}
/** Whether the index has been built */
get isBuilt(): boolean {
return this.built;
}
}

View File

@@ -0,0 +1,56 @@
/**
* Workspace Provider — abstraction for workspace detection and configuration.
*
* Separates workspace-specific concerns (root detection, multi-root folders,
* state directories) from the LSP manager.
*
* External extensions can provide custom implementations via pi.events:
* pi.events.emit("lsp:workspace-provider", myProvider);
*/
export interface WorkspaceProvider {
/** Provider type identifier */
readonly type: string;
/** Workspace root directory (if detected — may differ from cwd) */
readonly workspaceRoot: string | null;
/** Directory for persistent state (daemon sockets, PIDs, locks). Null disables daemon mode. */
readonly stateDir: string | null;
/** Get workspace folders for multi-root LSP initialization */
getWorkspaceFolders(): { uri: string; name: string }[];
/** One-time setup before first LSP server start. Returns true if ready. */
ensureReady(sessionId?: string): Promise<boolean>;
/** Human-readable status for UI */
getStatusText(): string;
/** Clean up resources */
shutdown(): void;
}
/**
* Default provider for standard workspaces.
* No special workspace detection, no daemon support, no multi-root.
*/
export class DefaultWorkspaceProvider implements WorkspaceProvider {
readonly type = "default";
readonly workspaceRoot = null;
readonly stateDir = null;
getWorkspaceFolders(): { uri: string; name: string }[] {
return [];
}
async ensureReady(): Promise<boolean> {
return true;
}
getStatusText(): string {
return "";
}
shutdown(): void {}
}

View File

@@ -0,0 +1,296 @@
/**
* Tests for structural search & rewrite: pattern-compiler, search-engine, rewrite-engine.
*
* Run: npx tsx test-structural-search.ts
*/
import { TreeSitterManager } from "./src/tree-sitter/parser-manager.js";
import { compilePattern, type CompiledPattern } from "./src/tree-sitter/pattern-compiler.js";
import { searchFiles, collectFilesByLanguage } from "./src/tree-sitter/search-engine.js";
import { computeRewrites, applyRewrites, substituteCaptures } from "./src/tree-sitter/rewrite-engine.js";
import { writeFileSync, mkdirSync, rmSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
let passed = 0;
let failed = 0;
function assert(condition: boolean, label: string) {
if (condition) {
console.log(`${label}`);
passed++;
} else {
console.log(`${label}`);
failed++;
}
}
const TEST_DIR = resolve("/tmp/structural-search-test");
async function main() {
const mgr = new TreeSitterManager();
await mgr.init();
// Set up test fixtures
try { rmSync(TEST_DIR, { recursive: true }); } catch {}
mkdirSync(resolve(TEST_DIR, "src"), { recursive: true });
writeFileSync(resolve(TEST_DIR, "src/app.ts"), `
import { readFile } from "fs";
function processData(data: string): string {
console.log("processing", data);
const result = data.trim();
console.log("done");
return result;
}
function helper() {
var x = 10;
var y = "hello";
const z = 42;
}
class UserService {
async getUser(id: string) {
const user = await this.db.find(id);
return user;
}
async deleteUser(id: string) {
console.log("deleting", id);
await this.db.remove(id);
}
}
`);
writeFileSync(resolve(TEST_DIR, "src/utils.ts"), `
export function formatDate(d: Date): string {
console.log("formatting date");
return d.toISOString();
}
export function parseDate(s: string): Date {
return new Date(s);
}
export const doSomething = (a: number, b: number) => {
var result = a + b;
return result;
};
`);
// ── 1. Pattern compiler ──
console.log("\n🔧 Pattern Compiler");
// Simple identifier pattern
const p1 = await compilePattern("console.log($ARG)", "typescript", mgr);
assert(p1.root.kind === "literal", "console.log($ARG) compiles to literal root");
assert(p1.metavars.includes("ARG"), "captures $ARG metavar");
assert(p1.languageId === "typescript", "language is typescript");
// Variadic pattern
const p2 = await compilePattern("console.log($$$ARGS)", "typescript", mgr);
assert(p2.metavars.includes("ARGS"), "captures $$$ARGS variadic metavar");
// Variable declaration pattern
const p3 = await compilePattern("var $N = $V", "typescript", mgr);
assert(p3.metavars.includes("N"), "captures $N");
assert(p3.metavars.includes("V"), "captures $V");
// Pattern with no metavars
const p4 = await compilePattern("return result", "typescript", mgr);
assert(p4.metavars.length === 0, "no metavars in literal pattern");
// Bad pattern should throw
let threw = false;
try {
await compilePattern("{{{{invalid}}}", "typescript", mgr);
} catch {
threw = true;
}
assert(threw, "invalid pattern throws error");
// ── 2. Search engine — basic matching ──
console.log("\n🔍 Search Engine — basic matching");
// Find console.log calls with single arg
const compiled1 = await compilePattern("console.log($ARG)", "typescript", mgr);
const matches1 = await searchFiles(compiled1, TEST_DIR, mgr);
console.log(` Found ${matches1.length} console.log($ARG) matches`);
assert(matches1.length > 0, "found console.log($ARG) matches");
// Check captures
const firstMatch = matches1[0];
assert("ARG" in firstMatch.captures, "match has $ARG capture");
console.log(` First $ARG = "${firstMatch.captures.ARG}"`);
// Find var declarations
const compiled3 = await compilePattern("var $N = $V", "typescript", mgr);
const matches3 = await searchFiles(compiled3, TEST_DIR, mgr);
console.log(` Found ${matches3.length} var $N = $V matches`);
assert(matches3.length >= 3, "found at least 3 var declarations");
// Check that captures are different
const varNames = matches3.map(m => m.captures.N);
console.log(` Variable names: ${varNames.join(", ")}`);
assert(varNames.includes("x"), 'found var x');
assert(varNames.includes("y"), 'found var y');
// ── 3. Search engine — variadic matching ──
console.log("\n🔍 Search Engine — variadic matching");
// Find console.log with any number of args
const compiled2 = await compilePattern("console.log($$$ARGS)", "typescript", mgr);
const matches2 = await searchFiles(compiled2, TEST_DIR, mgr);
console.log(` Found ${matches2.length} console.log($$$ARGS) matches`);
assert(matches2.length >= 4, "found variadic console.log matches (at least 4)");
// ── 4. Search engine — file scoping ──
console.log("\n🔍 Search Engine — file scoping");
const scopedMatches = await searchFiles(compiled1, TEST_DIR, mgr, {
path: "src/utils.ts",
});
console.log(` Found ${scopedMatches.length} matches in utils.ts only`);
assert(scopedMatches.length >= 1, "found matches in scoped file");
assert(scopedMatches.every(m => m.file.endsWith("utils.ts")), "all matches in utils.ts");
// ── 5. Search engine — max results ──
console.log("\n🔍 Search Engine — max results");
const limitedMatches = await searchFiles(compiled2, TEST_DIR, mgr, { maxResults: 2 });
assert(limitedMatches.length <= 2, `max_results respected (got ${limitedMatches.length})`);
// ── 6. File collection ──
console.log("\n📂 File Collection");
const tsFiles = await collectFilesByLanguage(TEST_DIR, "typescript", mgr);
assert(tsFiles.length === 2, `found 2 TypeScript files (got ${tsFiles.length})`);
assert(tsFiles.some(f => f.endsWith("app.ts")), "includes app.ts");
assert(tsFiles.some(f => f.endsWith("utils.ts")), "includes utils.ts");
// Skips node_modules
mkdirSync(resolve(TEST_DIR, "node_modules/pkg"), { recursive: true });
writeFileSync(resolve(TEST_DIR, "node_modules/pkg/index.ts"), "export const x = 1;");
const tsFiles2 = await collectFilesByLanguage(TEST_DIR, "typescript", mgr);
assert(tsFiles2.length === 2, `still 2 files (node_modules skipped, got ${tsFiles2.length})`);
// ── 7. Rewrite engine — substituteCaptures ──
console.log("\n✏ Rewrite Engine — substituteCaptures");
assert(
substituteCaptures("const $N = $V", { N: "x", V: "42" }) === "const x = 42",
"basic substitution works",
);
assert(
substituteCaptures("$$$ARGS", { ARGS: "a, b, c" }) === "a, b, c",
"variadic substitution works",
);
assert(
substituteCaptures("$UNKNOWN stays", {}) === "$UNKNOWN stays",
"unknown metavar left as-is",
);
// ── 8. Rewrite engine — computeRewrites (dry run) ──
console.log("\n✏ Rewrite Engine — computeRewrites (dry run)");
const varMatches = await searchFiles(compiled3, TEST_DIR, mgr);
const rewrites = computeRewrites(varMatches, "const $N = $V");
assert(rewrites.length === varMatches.length, "one rewrite per match");
for (const r of rewrites) {
assert(r.before.startsWith("var "), `before starts with "var" (got: ${r.before})`);
assert(r.after.startsWith("const "), `after starts with "const" (got: ${r.after})`);
console.log(` ${r.file.split("/").pop()}:${r.line}${r.before}${r.after}`);
}
// ── 9. Rewrite engine — applyRewrites ──
console.log("\n✏ Rewrite Engine — applyRewrites");
// Work on a copy to not mess up other tests
const rewriteDir = resolve(TEST_DIR, "rewrite-test");
mkdirSync(rewriteDir, { recursive: true });
writeFileSync(resolve(rewriteDir, "test.ts"), `
function example() {
var a = 1;
var b = "hello";
const c = true;
var d = [];
}
`);
const varPattern = await compilePattern("var $N = $V", "typescript", mgr);
const varResults = await searchFiles(varPattern, rewriteDir, mgr);
console.log(` Found ${varResults.length} var declarations to rewrite`);
assert(varResults.length === 3, `found 3 var declarations (got ${varResults.length})`);
const result = await applyRewrites(varResults, "const $N = $V");
assert(result.filesModified === 1, `modified 1 file (got ${result.filesModified})`);
assert(result.changes.length === 3, `3 changes (got ${result.changes.length})`);
// Verify the file was actually rewritten
const rewritten = readFileSync(resolve(rewriteDir, "test.ts"), "utf-8");
assert(!rewritten.includes("var a"), "var a replaced");
assert(!rewritten.includes("var b"), "var b replaced");
assert(!rewritten.includes("var d"), "var d replaced");
assert(rewritten.includes("const a = 1"), "const a = 1 present");
assert(rewritten.includes('const b = "hello"'), 'const b = "hello" present');
assert(rewritten.includes("const d = []"), "const d = [] present");
assert(rewritten.includes("const c = true"), "original const c preserved");
console.log(" Rewritten file content:");
console.log(rewritten.split("\n").map(l => ` ${l}`).join("\n"));
// ── 10. Rewrite — no-op when before === after ──
console.log("\n✏ Rewrite Engine — no-op detection");
const constPattern = await compilePattern("const $N = $V", "typescript", mgr);
const constMatches = await searchFiles(constPattern, rewriteDir, mgr);
const noopResult = await applyRewrites(constMatches, "const $N = $V");
assert(noopResult.filesModified === 0, "no files modified when replacement matches original");
// ── 11. Cross-language — Python ──
console.log("\n🐍 Cross-language — Python");
mkdirSync(resolve(TEST_DIR, "py"), { recursive: true });
writeFileSync(resolve(TEST_DIR, "py/main.py"), `
def process(data):
print("start")
result = data.strip()
print("done")
return result
def helper():
print("helper called")
`);
const pyPattern = await compilePattern("print($ARG)", "python", mgr);
const pyMatches = await searchFiles(pyPattern, TEST_DIR, mgr, { path: "py" });
console.log(` Found ${pyMatches.length} print($ARG) matches in Python`);
assert(pyMatches.length >= 3, "found Python print matches");
assert(pyMatches.every(m => m.file.endsWith(".py")), "all matches are Python files");
// ── 12. Search — return value structure ──
console.log("\n📋 Search — result structure");
const singleMatch = (await searchFiles(compiled1, TEST_DIR, mgr, { maxResults: 1 }))[0];
assert(typeof singleMatch.file === "string", "match.file is string");
assert(typeof singleMatch.line === "number" && singleMatch.line > 0, "match.line is positive number");
assert(typeof singleMatch.column === "number" && singleMatch.column > 0, "match.column is positive number");
assert(typeof singleMatch.matchedText === "string", "match.matchedText is string");
assert(typeof singleMatch.startIndex === "number", "match.startIndex exists");
assert(typeof singleMatch.endIndex === "number", "match.endIndex exists");
assert(singleMatch.endIndex > singleMatch.startIndex, "endIndex > startIndex");
assert(typeof singleMatch.captures === "object", "match.captures is object");
// ── Cleanup ──
try { rmSync(TEST_DIR, { recursive: true }); } catch {}
mgr.shutdown();
// ── Summary ──
console.log(`\n${"═".repeat(50)}`);
console.log(` ${passed} passed, ${failed} failed`);
console.log(`${"═".repeat(50)}\n`);
process.exit(failed > 0 ? 1 : 0);
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});

View File

@@ -0,0 +1,397 @@
/**
* Quick smoke test for the tree-sitter integration.
* Exercises: parser-manager, symbol-extractor, workspace-index
*
* Run: npx tsx test-tree-sitter.ts
*/
import { TreeSitterManager } from "./src/tree-sitter/parser-manager.js";
import { extractSymbols, getNodeAtPosition, findDefinition, getSyntaxErrors, getSignatureText, getEnclosingDeclaration } from "./src/tree-sitter/symbol-extractor.js";
import { WorkspaceIndex } from "./src/tree-sitter/workspace-index.js";
import { writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
let passed = 0;
let failed = 0;
function assert(condition: boolean, label: string) {
if (condition) {
console.log(`${label}`);
passed++;
} else {
console.log(`${label}`);
failed++;
}
}
async function main() {
const mgr = new TreeSitterManager();
// ── 1. Init & language detection ──
console.log("\n🔧 Parser Manager — init & language detection");
await mgr.init();
assert(mgr.getLanguageId("foo.ts") === "typescript", "foo.ts → typescript");
assert(mgr.getLanguageId("bar.py") === "python", "bar.py → python");
assert(mgr.getLanguageId("baz.rs") === "rust", "baz.rs → rust");
assert(mgr.getLanguageId("main.go") === "go", "main.go → go");
assert(mgr.getLanguageId("App.java") === "java", "App.java → java");
assert(mgr.getLanguageId("README.md") === undefined, "README.md → undefined");
assert(mgr.hasGrammar("typescript"), "has grammar: typescript");
assert(!mgr.hasGrammar("haskell"), "no grammar: haskell");
assert(mgr.getSupportedLanguages().length >= 19, `supported languages >= 19 (got ${mgr.getSupportedLanguages().length})`);
// ── 2. Parse TypeScript ──
console.log("\n🌳 Parse TypeScript");
const tsCode = `
export function greet(name: string): string {
return \`Hello, \${name}!\`;
}
export class UserService {
private users: Map<string, User> = new Map();
getUser(id: string): User | undefined {
return this.users.get(id);
}
addUser(user: User): void {
this.users.set(user.id, user);
}
}
export interface User {
id: string;
name: string;
email?: string;
}
export const MAX_USERS = 1000;
type UserMap = Map<string, User>;
export default function main() {
const svc = new UserService();
svc.addUser({ id: "1", name: "Alice" });
}
`;
const tsTree = await mgr.parse("/tmp/test.ts", tsCode);
assert(tsTree !== null, "parsed TypeScript successfully");
const tsSymbols = extractSymbols(tsTree!, "typescript");
const names = tsSymbols.map(s => s.name);
console.log(" Symbols found:", names);
assert(names.includes("greet"), "found function greet");
assert(names.includes("UserService"), "found class UserService");
assert(names.includes("User"), "found interface User");
assert(names.includes("MAX_USERS"), "found const MAX_USERS");
assert(names.includes("main"), "found default export function main");
assert(names.includes("UserMap"), "found type alias UserMap");
// Check nested methods
const userServiceSym = tsSymbols.find(s => s.name === "UserService");
assert(userServiceSym?.children !== undefined, "UserService has children");
const methodNames = userServiceSym?.children?.map(c => c.name) ?? [];
assert(methodNames.includes("getUser"), "UserService.getUser found");
assert(methodNames.includes("addUser"), "UserService.addUser found");
// ── 3. Parse Python ──
console.log("\n🐍 Parse Python");
const pyCode = `
import os
class Calculator:
def __init__(self):
self.history = []
def add(self, a, b):
result = a + b
self.history.append(result)
return result
def multiply(self, a, b):
return a * b
def main():
calc = Calculator()
print(calc.add(2, 3))
MAX_HISTORY = 100
`;
const pyTree = await mgr.parse("/tmp/test.py", pyCode);
assert(pyTree !== null, "parsed Python successfully");
const pySymbols = extractSymbols(pyTree!, "python");
const pyNames = pySymbols.map(s => s.name);
console.log(" Symbols found:", pyNames);
assert(pyNames.includes("Calculator"), "found class Calculator");
assert(pyNames.includes("main"), "found function main");
const calcSym = pySymbols.find(s => s.name === "Calculator");
const calcMethods = calcSym?.children?.map(c => c.name) ?? [];
assert(calcMethods.includes("__init__"), "Calculator.__init__ found");
assert(calcMethods.includes("add"), "Calculator.add found");
// ── 4. Parse Rust ──
console.log("\n🦀 Parse Rust");
const rsCode = `
pub fn process(data: &[u8]) -> Result<Vec<u8>, Error> {
Ok(data.to_vec())
}
pub struct Config {
pub name: String,
pub value: i32,
}
impl Config {
pub fn new(name: &str) -> Self {
Config { name: name.to_string(), value: 0 }
}
}
pub trait Processor {
fn process(&self, input: &str) -> String;
}
pub enum Status {
Active,
Inactive,
Error(String),
}
const MAX_SIZE: usize = 1024;
`;
const rsTree = await mgr.parse("/tmp/test.rs", rsCode);
assert(rsTree !== null, "parsed Rust successfully");
const rsSymbols = extractSymbols(rsTree!, "rust");
const rsNames = rsSymbols.map(s => s.name);
console.log(" Symbols found:", rsNames);
assert(rsNames.includes("process"), "found fn process");
assert(rsNames.includes("Config"), "found struct Config");
assert(rsNames.some(n => n.startsWith("impl")), "found impl block");
assert(rsNames.includes("Processor"), "found trait Processor");
assert(rsNames.includes("Status"), "found enum Status");
assert(rsNames.includes("MAX_SIZE"), "found const MAX_SIZE");
// ── 5. Parse Go ──
console.log("\n🔵 Parse Go");
const goCode = `
package main
import "fmt"
func Add(a, b int) int {
return a + b
}
type Server struct {
Port int
Host string
}
func (s *Server) Start() error {
return nil
}
const MaxRetries = 3
var DefaultServer = Server{Port: 8080}
`;
const goTree = await mgr.parse("/tmp/test.go", goCode);
assert(goTree !== null, "parsed Go successfully");
const goSymbols = extractSymbols(goTree!, "go");
const goNames = goSymbols.map(s => s.name);
console.log(" Symbols found:", goNames);
assert(goNames.includes("Add"), "found func Add");
assert(goNames.includes("Server"), "found type Server");
assert(goNames.includes("Start"), "found method Start");
assert(goNames.includes("MaxRetries"), "found const MaxRetries");
// ── 6. Parse Java ──
console.log("\n☕ Parse Java");
const javaCode = `
package com.example;
public class Handler {
private final String name;
public Handler(String name) {
this.name = name;
}
public String handle(String input) {
return name + ": " + input;
}
}
interface Processor {
void process(String data);
}
enum Status {
OK, ERROR, PENDING
}
`;
const javaTree = await mgr.parse("/tmp/Test.java", javaCode);
assert(javaTree !== null, "parsed Java successfully");
const javaSymbols = extractSymbols(javaTree!, "java");
const javaNames = javaSymbols.map(s => s.name);
console.log(" Symbols found:", javaNames);
assert(javaNames.includes("Handler"), "found class Handler");
assert(javaNames.includes("Processor"), "found interface Processor");
assert(javaNames.includes("Status"), "found enum Status");
const handlerSym = javaSymbols.find(s => s.name === "Handler");
const handlerMethods = handlerSym?.children?.map(c => c.name) ?? [];
assert(handlerMethods.includes("handle"), "Handler.handle found");
// ── 7. Node at position & definition finding ──
console.log("\n📍 Node at position & definition lookup");
// "greet" starts at line 2 col 17 in tsCode (0-indexed: row=1, col=16)
const node = getNodeAtPosition(tsTree!, 1, 17);
assert(node !== null, "found node at position");
assert(node?.text === "greet", `node text is "greet" (got "${node?.text}")`);
const defs = findDefinition(tsTree!, "greet", "typescript");
assert(defs.length > 0, "found definition of greet");
assert(defs[0].name === "greet", "definition name matches");
// ── 8. Syntax errors ──
console.log("\n🔴 Syntax error detection");
const badCode = `function foo( { return 42; }`;
const badTree = await mgr.parse("/tmp/bad.ts", badCode);
assert(badTree !== null, "parsed bad code (with errors)");
const errors = getSyntaxErrors(badTree!);
assert(errors.length > 0, `found ${errors.length} syntax error(s)`);
console.log(" Errors:", errors.map(e => e.message));
// ── 9. Enclosing declaration ──
console.log("\n🏗 Enclosing declaration");
// Line 10 (0-indexed: 9) is inside UserService.getUser
const enclosing = getEnclosingDeclaration(tsTree!, 9, 10);
assert(enclosing !== null, "found enclosing declaration");
if (enclosing) {
const sig = getSignatureText(enclosing);
console.log(` Signature: ${sig}`);
assert(sig.includes("getUser"), `enclosing is getUser (got: ${sig})`);
}
// ── 10. Workspace index ──
console.log("\n📚 Workspace index");
const testDir = resolve("/tmp/tree-sitter-test-workspace");
try { rmSync(testDir, { recursive: true }); } catch {}
mkdirSync(resolve(testDir, "src"), { recursive: true });
writeFileSync(resolve(testDir, "src/app.ts"), `
export class AppService {
start(): void {}
stop(): void {}
}
export function createApp(): AppService {
return new AppService();
}
`);
writeFileSync(resolve(testDir, "src/utils.ts"), `
export function formatDate(d: Date): string {
return d.toISOString();
}
export function parseDate(s: string): Date {
return new Date(s);
}
export const VERSION = "1.0.0";
`);
writeFileSync(resolve(testDir, "src/main.py"), `
class Database:
def connect(self):
pass
def run_server(port):
db = Database()
db.connect()
`);
const wsIndex = new WorkspaceIndex(testDir, mgr);
await wsIndex.build();
const stats = wsIndex.getStats();
console.log(` Indexed ${stats.files} files, ${stats.symbols} symbols`);
assert(stats.files === 3, `indexed 3 files (got ${stats.files})`);
assert(stats.symbols > 0, `has symbols (got ${stats.symbols})`);
// Search
const appResults = wsIndex.search("AppService");
assert(appResults.length > 0, "found AppService in index");
assert(appResults[0].name === "AppService", "first result is AppService");
const formatResults = wsIndex.search("format");
assert(formatResults.length > 0, "found format* in index");
assert(formatResults.some(r => r.name === "formatDate"), "found formatDate");
// Cross-language search
const dbResults = wsIndex.search("Database");
assert(dbResults.length > 0, "found Python class Database in index");
// File-specific symbols
const appSyms = wsIndex.getSymbolsForFile(resolve(testDir, "src/app.ts"));
assert(appSyms.length > 0, `app.ts has symbols (got ${appSyms.length})`);
// Re-index after change
writeFileSync(resolve(testDir, "src/utils.ts"), `
export function formatDate(d: Date): string {
return d.toISOString();
}
export function newHelper(): void {}
`);
await wsIndex.indexFile(resolve(testDir, "src/utils.ts"));
const newResults = wsIndex.search("newHelper");
assert(newResults.length > 0, "found newHelper after re-index");
const versionResults = wsIndex.search("VERSION");
assert(versionResults.length === 0, "VERSION removed after re-index");
// ── 11. Tree caching ──
console.log("\n💾 Tree caching");
const tree1 = await mgr.parse("/tmp/cache-test.ts", "function a() {}");
const tree2 = await mgr.parse("/tmp/cache-test.ts", "function a() {}");
assert(tree1 === tree2, "same content returns cached tree");
const tree3 = await mgr.parse("/tmp/cache-test.ts", "function b() {}");
assert(tree3 !== tree1, "different content returns new tree");
mgr.invalidate("/tmp/cache-test.ts");
assert(mgr.getCachedTree("/tmp/cache-test.ts") === null, "invalidate clears cache");
// ── 12. Windows path regression (fileURLToPath vs .pathname) ──
// On Windows, import.meta.url is 'file:///C:/path/...'.
// Using .pathname gives '/C:/path/...' — path.resolve() treats the leading
// slash as 'root of current drive', so if CWD is on E:\ you get E:\C:\...
// Using fileURLToPath() correctly produces 'C:\path\...' on Windows.
console.log("\n🪟 Windows path regression (fileURLToPath vs .pathname)");
const moduleDir = fileURLToPath(new URL(".", import.meta.url));
const wasmPath = resolve(moduleDir, "node_modules/web-tree-sitter/tree-sitter.wasm");
assert(existsSync(wasmPath), `wasm resolves to a real file: ${wasmPath}`);
// Detect drive-doubling: a sign that .pathname was used on Windows.
// e.g. 'E:\\C:\\Users\\...' when CWD drive != module drive.
const hasDriveDoubling = /[A-Za-z]:\\[A-Za-z]:\\/.test(wasmPath);
assert(!hasDriveDoubling, `path has no doubled drive letter: ${wasmPath}`);
// Also verify the grammar dir resolves cleanly to real .wasm grammar files.
const grammarPath = resolve(moduleDir, "node_modules/tree-sitter-wasms/out/tree-sitter-typescript.wasm");
assert(existsSync(grammarPath), `grammar wasm resolves to a real file: ${grammarPath}`);
// ── Cleanup ──
try { rmSync(testDir, { recursive: true }); } catch {}
mgr.shutdown();
// ── Summary ──
console.log(`\n${"═".repeat(50)}`);
console.log(` ${passed} passed, ${failed} failed`);
console.log(`${"═".repeat(50)}\n`);
process.exit(failed > 0 ? 1 : 0);
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true
},
"include": ["src/**/*.ts"]
}