Compare commits

...

34 Commits

Author SHA1 Message Date
2da4103cd7 fix: report unavailable LSP servers explicitly 2026-05-31 20:07:17 -04:00
8cfe604de7 fix(daemon): clear diagnostics and reload TypeScript projects on watcher events
Evicting the entire entry on derived watcher events was overly aggressive.
Instead, clear cached diagnostics and send a reloadProjects command to the
TypeScript language server so it picks up workspace changes without losing
state.
2026-05-24 11:57:01 -04:00
071c87d3c1 fix(watcher): derive fallback file patterns 2026-05-20 06:53:35 -04:00
3f3cb4cdbf fix(watcher): close deleted opened documents 2026-05-20 06:38:21 -04:00
14749a6449 fix(watcher): close deleted open documents 2026-05-20 00:22:30 -04:00
62fc80c70f fix(watcher): cap startup wait 2026-05-20 00:09:07 -04:00
b7e421483d fix(watcher): stabilize daemon readiness and tests 2026-05-19 23:58:18 -04:00
0aa44bedc4 fix(watcher): coalesce edge cases, drain on unregister, expose ready()
Addresses review feedback on 7787626:

1. Deleted->Created at the same path is a file replacement, not a no-op.
   Previous coalescing dropped both Created->Deleted and Deleted->Created;
   the latter left the server with no signal to re-read replaced content.
   Now: Deleted->Created collapses to Changed, Created->Changed keeps
   Created (server didn't know the file at all). Extracted coalesce() so
   the matrix is reviewable in one place.

2. setPatterns([]) (server unregistered all watchers) stopped chokidar
   but left pending events + timers intact, so a queued batch could
   still fire after the server stopped caring. Now drains via
   cancelPending() before stopping chokidar.

3. Added ready() returning a promise resolved by chokidar's initial-scan
   'ready' event. Production daemon doesn't need to await it (LSP
   handshake gives chokidar ample wall-time), but tests now use it
   instead of fixed 200ms sleeps - deflakes the suite on slower
   filesystems and addresses the (narrow) startup race where a file
   created during chokidar's initial crawl could be missed.

4. Unit tests replace 11 hardcoded sleeps with watcher.ready(), and add
   coverage for the two coalesce fixes plus the unregister-drains case.
2026-05-19 23:51:32 -04:00
77876264ee feat(watcher): forward FS events as workspace/didChangeWatchedFiles
LSP servers maintain their own workspace index built at initialize time
and rely on the client to push file-system events. Previously the daemon
only synced the single file being queried, so externally created/changed
files (codegen, build scripts, git checkout, the agent's own writes from
the perspective of other open files) left the server's index stale until
manual /lsp-destroy.

Each ClientEntry now lazily owns a WorkspaceWatcher (chokidar + picomatch)
that translates FS events into workspace/didChangeWatchedFiles batches.
Patterns come from the server via client/registerCapability (no
speculative watching). Ignores layer a tiny baseline (.git, .DS_Store)
over the repo's root .gitignore, with a fallback list for non-git
workspaces. Events debounce 50ms quiet / 500ms max wait.

Notable: gopls registers absolute-path globs (/abs/root/**/*.go) rather
than relative ones, so compileWatchers() matches each event against both
relative and absolute path forms. Caught by the integration test; unit
regression test added.

Rollback: PI_LSP_DISABLE_WATCHERS=1 disables all watcher creation.

- src/client.ts: honor register/unregisterCapability for
  workspace/didChangeWatchedFiles; advertise dynamicRegistration;
  expose getFileWatchers/onWatchersChanged/sendNotification
- src/watcher.ts: new WorkspaceWatcher with layered ignores,
  debounce+batch, Created+Deleted coalescing, dual-form glob matching
- src/daemon.ts: per-entry watcher lifecycle, PI_LSP_DISABLE_WATCHERS,
  LSP_DEBUG-gated pattern/event logging
- test/unit/watcher.test.ts: 11 tests against real chokidar + temp dir
- test/integration/watcher-gopls.test.ts: end-to-end against gopls
- AGENTS.md: new "Workspace File Watching" section
- flake.nix: add go (required by gopls integration test)
2026-05-19 23:43:32 -04:00
e143e05758 feat(servers): split vscode-html-language-server and add css, json, bash, sql servers
- Split vscode-html-language-server into separate servers for HTML, CSS,
  and JSON with proper language IDs and file extensions
- Added bash-language-server for shell scripts (.sh, .bash)
- Added sqls for SQL files
- Added timeout wrapper to auto-check diagnostics to prevent blocking pi
2026-05-08 18:51:12 -04:00
46e3cc4ccd feat(config): add per-repo .pi-lsp.json server overrides
Users can now drop a .pi-lsp.json at any ancestor of their working
files to add new LSP servers, override built-in ones, or disable
servers entirely. The nearest config (walking upward) wins.

- New src/config.ts: walks upward for .pi-lsp.json, parses, and
  merges with the built-in registry. Cached per config-file path
  with mtime invalidation. Falls back to built-ins on parse error.
- Merge rules: matching id shallow-merges (user wins); new id
  appends (must include match/command/args/rootMarkers); `disable`
  filters at the end.
- src/root.ts: pickServer() now resolves servers via the per-repo
  registry. Adds findServerById(filePath, id) and re-exports
  getServersForPath() for callers.
- src/daemon.ts: getOrCreateEntry() resolves serverId against the
  filePath's config so spawned servers reflect repo overrides.
- index.ts and cli.ts: replace direct `servers` imports with
  path-aware getServersForPath() lookups.
- Tests: 9 new unit tests covering merge semantics, walk-up
  discovery, mtime invalidation, and graceful fallback.
- Docs: README "Per-Repo Config" section + AGENTS.md updates.
2026-05-07 22:43:41 -04:00
0b23e203f4 build(flake): add gopls and pyright for tests 2026-05-07 22:43:28 -04:00
9e5a0677c8 feat: add svelteserver to LSP server registry 2026-05-07 21:19:46 -04:00
99ce79ac88 fix(lsp): support server workspace configuration 2026-05-05 23:44:21 -04:00
81ab984a86 chore: add oxlint 2026-05-04 07:45:50 -04:00
e40c93fc80 feat(server): add diagnosticsOnly flag for lint-only servers
Add diagnosticsOnly?: boolean to ServerConfig. When set, the server is
excluded from pickServer() (hover/definition/references/completion/
documentSymbol) but still included in pickDiagnosticServers() for
lsp_diagnostics and auto-check.

Mark oxlint as diagnosticsOnly: true — it now contributes diagnostics
alongside typescript-language-server without interfering with navigation
or completion tools.
2026-05-04 07:41:39 -04:00
b9808a8b1f refactor(daemon): require explicit serverId on all daemon ops
Move all server matching logic to the extension/CLI side. The daemon no
longer calls pickServer() — it receives an explicit serverId (or
serverIds[] for diagnostics) and uses it directly for cache lookup and
server spawning.

Key changes:
- request op requires serverId: string
- diagnostics op requires serverIds: string[] — daemon fans out in
  parallel via Promise.allSettled and returns grouped map
- formatDiagnostics() handles grouped results with per-server headers
  when multiple servers contribute (single-server omits header)
- CLI picks servers locally before calling daemon helpers
- New pickDiagnosticServers() in extension returns all available,
  non-disabled servers matching the file extension

This makes multi-server diagnostics (e.g., typescript-language-server +
oxlint) work naturally — the extension decides which servers to query,
the daemon just executes.
2026-05-04 07:39:03 -04:00
d24e2e94f4 refactor(root): extract isOnPath and add extension-side server qualification
Extract isOnPath() to shared src/util.ts so both the daemon (client.ts)
and extension (root.ts) can use it. Add isServerAvailable() with a
per-process cache to pickServer(), skipping servers whose binary isn't
on PATH before sending requests to the daemon.

This avoids wasted daemon round-trips for missing binaries and sets up
for upcoming multi-server diagnostics fan-out.
2026-05-04 07:24:59 -04:00
630226a00a feat: add lua, html/css/json, nix, and oxlint LSPs; add global .git root marker 2026-05-04 06:59:08 -04:00
f811efef68 fix(diagnostics): sort by severity before truncating
Errors appear first, then warnings, info, hints. Ensures the most
actionable issues survive the cap instead of being pushed out by
low-priority hints.
2026-05-02 20:24:35 -04:00
01ab10a7d9 feat(references): cap formatted references at 30 with truncation notice
Prevents flooding the context window when a common symbol has hundreds
of references. Shows the first 30 and appends a count of the remainder.
2026-05-02 20:24:27 -04:00
99525ad0ee feat(diagnostics): cap diagnostic output with truncation notice
Add a limit parameter to formatDiagnostics (default 20 for explicit
lsp_diagnostics calls, 10 for auto-check after edit/write). When
truncated, a summary line indicates how many more diagnostics exist.
2026-05-02 20:13:05 -04:00
04fd520438 fix(daemon): launch LSP servers with caller env 2026-05-02 15:28:25 -04:00
306771f92a refactor(extension): resolve remaining type narrowing diagnostics
Narrow `unknown` types with explicit casts before `in` operator checks
to avoid "Type '{}' may represent a primitive value" errors. Also fix
getActiveTools() which returns string[] (not object[]), removing the
unnecessary .map((t) => t.name). Brings index.ts to zero diagnostics.
2026-05-02 00:50:16 -04:00
4b486b2464 build: add pi-coding-agent and typebox dev dependencies
Resolve 47 of 52 LSP diagnostics: module-not-found errors for
@mariozechner/pi-coding-agent and typebox, plus cascading implicit-any
errors on callback parameters. Both packages are available on npm and
provided at runtime by pi.
2026-05-02 00:46:46 -04:00
6111321fda fix(extension): suppress warnings for unsupported file types and missing binaries
Move pickServer() into the try-catch in runLsp() so UnsupportedExtensionError
is caught directly. Add message-based fallback in both runLsp() and
runDiagnostics() to handle daemon-wrapped errors that come through as plain
Error instances rather than the original typed exception.

This eliminates spurious 'No LSP server registered' warnings during auto-check
after edit/write on files without LSP support (e.g. .md, .txt, .sh).
2026-05-02 00:42:44 -04:00
9b863168ff fix: handle unhandled promise rejection in background read init 2026-04-30 11:53:43 -04:00
b614e700fd fix(daemon): tear down daemon when destroying all servers with no entries 2026-04-30 11:41:37 -04:00
620d9cc70f feat: warm-start LSP server on file read 2026-04-30 11:10:36 -04:00
aa7309b363 test: add unit and integration test suite
Add 34 tests (27 unit, 7 integration) using node:test runner:

Unit tests:
- pickServer(), findRoot(), pathToUri(), uriToPath()
- isLspCommand(), listCommands()
- formatHover(), formatDefinition(), formatReferences(), formatDiagnostics()

Integration tests:
- daemon lifecycle (status/stop) on isolated socket
- CLI --no-daemon queries (hover, documentSymbol, diagnostics)

Supporting changes:
- socketPath() honors PI_LSP_SOCKET_PATH env var for test isolation
- test fixtures for valid and broken TypeScript files
- npm test / test:unit / test:integration scripts
2026-04-30 10:36:54 -04:00
e131e0e8cd feat: add server control commands (disable, enable, destroy)
Add /lsp-servers, /lsp-disable, /lsp-enable, and /lsp-destroy TUI commands.
Disabled servers are tracked in-memory per-extension-instance; the shared
daemon is never mutated by disable/enable. When all servers are disabled,
LSP tools are removed from the active tool set so the LLM won't attempt them.

Also adds a destroy_server daemon operation that kills running LspClient
entries by server ID or all entries.
2026-04-30 09:48:01 -04:00
7abe4efa02 refactor: replace string-matching error checks with custom error classes 2026-04-30 08:27:13 -04:00
81ed5c88b8 fix(daemon): auto-shutdown when last LSP server entry is evicted 2026-04-30 08:21:16 -04:00
36b9b0cde4 docs: add AGENTS.md with project context and conventions 2026-04-30 08:21:09 -04:00
30 changed files with 6869 additions and 105 deletions

168
AGENTS.md Normal file
View File

@@ -0,0 +1,168 @@
# pi-lsp — LSP Extension for pi Coding Agent
## Overview
A [pi coding agent](https://github.com/mariozechner/pi-coding-agent) extension that provides LSP tools (`lsp_hover`, `lsp_definition`, etc.) to the LLM, plus automatic diagnostics after edits. It runs a **long-lived background daemon** so language servers stay warm across tool calls instead of cold-starting on every request.
## Non-Obvious Things (Read First)
### Two-Process Architecture
The extension has **two separate Node.js processes** communicating over a Unix socket:
| Layer | File(s) | Runs In | Responsibility |
|-------|---------|---------|----------------|
| **Extension** | `index.ts` | pi's process | Registers tools/commands, calls into daemon, formats responses |
| **Daemon** | `src/daemon.ts` + `src/client.ts` | Detached background process | Owns LspClient instances, spawns/manages language server processes |
The extension is **stateless** — it opens a fresh socket connection per request. The daemon is **stateful** — it caches one LSP server per `(server.id, rootDir)` and evicts on idle timeout.
### Daemon Protocol
Communication is **newline-delimited JSON (NDJSON)** over a Unix socket at `$XDG_RUNTIME_DIR/pi-lsp-$UID.sock`. Each line is one independent request/response pair with an `id` field for matching. See `src/daemonProtocol.ts` for the type definitions (`DaemonRequest`, `DaemonResponse`).
Current ops: `request`, `diagnostics`, `status`, `shutdown`, `destroy_server`. `request` and `diagnostics` include a `launch` context with the caller env. The env is used only when spawning a new server for `(server.id, rootDir)`; existing running servers keep their original process env until idle eviction or manual destroy/restart.
### Server Lifecycle
1. First LSP tool call for a file triggers `getOrCreateEntry()` in the daemon
2. `pickServer()` matches the file extension against `server.ts` registry
3. `findRoot()` walks upward looking for root markers (e.g., `go.mod`, `tsconfig.json`)
4. A new `LspClient` is spawned with the caller/session environment from the daemon request, initialized via LSP `initialize`/`initialized`, and waited on (`waitForReady()`)
5. The file is synced via `didOpen` or `didChange` (based on mtime comparison)
6. On idle timeout (default 5 min), the entry is evicted and the server process killed
### File Sync Strategy
The daemon tracks opened files per-entry in a `Map<uri, mtimeMs>`. On each request:
- **First access** → `didOpen` with full file contents
- **mtime changed** → `didChange` with full text replacement
- **mtime unchanged** → skip (server already has it)
A per-entry `serializer` promise chain prevents concurrent syncs from racing.
### Workspace File Watching
Each `ClientEntry` lazily owns a `WorkspaceWatcher` (`src/watcher.ts`,
chokidar + picomatch) that translates filesystem events into
`workspace/didChangeWatchedFiles` notifications. This keeps the server's
workspace index fresh when files are created/changed/deleted **outside** of
LSP tool calls (build scripts, codegen, `git checkout`, the agent's own
file writes).
Non-obvious bits:
- **Patterns come from the server.** We honor `client/registerCapability`
for `workspace/didChangeWatchedFiles` and store the registrations on the
`LspClient`. **Don't re-stub those handlers**; they look harmless but
break the entire feature. If a server doesn't register, we don't watch.
- **Servers send mixed pattern forms.** Gopls registers absolute-path
globs (`/abs/root/**/*.go`); others send relative (`**/*.ts`) or
`RelativePattern` objects. `compileWatchers()` tries both relative and
absolute matching against each event so we accept all forms.
- **Ignore layering.** Always-ignore baseline (`.git/`, `.DS_Store`) +
root `.gitignore` parsed via the `ignore` package + a small fallback
for non-git workspaces. Nested gitignores aren't supported yet.
- **Startup readiness.** The daemon waits for chokidar's initial scan, capped
at 5s, so first requests don't hang indefinitely on huge workspaces.
- **Debounce.** 50ms quiet period, capped at 500ms max wait so sustained
event streams (branch switches) still flush in bounded time.
- **Watcher and mtime-sync coexist.** When the agent edits a file we'll
emit `didChangeWatchedFiles` *and* the next request's `syncFile` will
send a `didChange`. Servers treat the two as orthogonal (workspace
index vs. editor buffer) and dedupe internally. This matches VS Code.
- **Rollback.** `PI_LSP_DISABLE_WATCHERS=1` short-circuits all watcher
creation — if something goes wrong in a real workspace, this restores
the prior "only the queried file is synced" behavior.
### Extension vs Daemon Responsibilities
| Concern | Where |
|---------|-------|
| Which server handles `.go` / `.ts`? | Both — `server.ts` is shared, but **extension** calls `pickServer()` for tools, **daemon** calls it for caching |
| Spawning/killing server processes | Daemon only |
| Formatting LSP responses for the LLM | Extension only (`formatHover`, `formatDefinition`, etc.) |
| Auto-diagnostics after `edit`/`write` | Extension (listens to `tool_result` event) |
| CLI one-shot mode (`--no-daemon`) | `cli.ts` directly uses `src/client.ts`, bypassing daemon |
## Project Structure
```
index.ts — Extension entry point (tools, commands, auto-check flag)
server.ts — Built-in LSP server registry (gopls, typescript-language-server, pyright, ...)
cli.ts — CLI for testing/debugging (daemon-aware or --no-daemon)
daemon.ts — Entrypoint that starts the daemon process
src/
client.ts — LspClient: spawns a language server, JSON-RPC handshake, file sync, file-watcher registrations
watcher.ts — WorkspaceWatcher: chokidar + picomatch → workspace/didChangeWatchedFiles batches
commands.ts — CLI command dispatcher (maps command names → LSP methods)
config.ts — Per-repo `.pi-lsp.json` loader: walk-up + merge with built-ins, mtime cache
daemonClient.ts — High-level helpers (daemonRequest, daemonDiagnostics, etc.)
daemonProtocol.ts — Shared types, socket path, NDJSON send/receive, autospawn logic
root.ts — pickServer(), findServerById(), getServersForPath(), findRoot(), URI/path conversion
types.ts — ServerConfig interface, LspCommand union
```
### Per-Repo Config (`.pi-lsp.json`)
Users can add/override/disable servers without editing `server.ts`. `src/config.ts`
walks upward from a given path to find `.pi-lsp.json`, parses it, and merges
with the built-in `servers` list:
- New `id` → appended (must supply `match`, `command`, `args`, `rootMarkers`).
- Existing `id` → shallow-merged over the built-in (user fields win).
- `disable: []` → filtered out at the end.
Results are cached per config path, invalidated by mtime. `getServersForPath(p)`
is the **single entry point** — don't import the raw `servers` array from
`server.ts` outside `src/config.ts`. The daemon resolves servers at
`getOrCreateEntry()` time via `findServerById(filePath, id)`, so spawned
servers reflect the config of the file being acted on. **Already-running**
entries don't see config changes; users must `/lsp-destroy` to respawn.
## Adding a Server (Built-In)
For servers shipped with pi-lsp, edit `server.ts`. (For per-repo additions,
users should drop a `.pi-lsp.json` at the repo root — see README.) Add an entry
to the `servers` array:
```typescript
{
id: "rust-analyzer",
match: ["rs"],
command: "rust-analyzer",
args: [],
rootMarkers: ["Cargo.toml"],
languageId: "rust",
}
```
The `command` must be on PATH. No other code changes needed — the daemon and extension pick it up automatically via `pickServer()`.
## Adding a Command
1. Extend the `LspCommand` union in `src/types.ts`
2. Add a handler in `src/commands.ts` (maps command name → LSP method)
3. Register a tool in `index.ts` if it should be callable by the LLM
4. Update `cli.ts` method map if it should work via CLI
## Development Workflow
- **No build step** — everything runs via `tsx` (TypeScript executor)
- Extension is loaded by pi from `~/.pi/extensions/lsp/` or `.pi/extensions/lsp/`
- Daemon is autospawned on first LSP request; logs to `/tmp/pi-lsp-daemon.log`
- Set `LSP_DEBUG=1` to forward language server stderr to the daemon log
- Use `npm run lsp -- <file> <command> '<json>'` for CLI testing
- Use `npm run lsp -- daemon status` to inspect running servers
## Extension API Conventions
The extension uses pi's `ExtensionAPI` (from `@mariozechner/pi-coding-agent`):
- **Tools** — registered via `pi.registerTool()`, callable by the LLM. Parameters use TypeBox schemas.
- **Commands** — registered via `pi.registerCommand()`, invoked as `/cmd-name` in the TUI. Use `ctx.ui.notify()` for feedback.
- **Flags** — registered via `pi.registerFlag()`, accessed as CLI args (e.g., `--lsp-auto-check=false`).
- **Events** — subscribed via `pi.on()`. The auto-check feature listens to `tool_result` and runs diagnostics after `edit`/`write`.
All tool execute functions receive `(toolCallId, params, signal, onUpdate, ctx)` where `ctx` is the `ExtensionContext`.

View File

@@ -30,6 +30,26 @@ Run diagnostics manually on specific files:
/lsp-check main.go utils.go
```
### Server Control Commands
Disable a server so this pi instance won't use it (the shared daemon and other instances are unaffected). When all servers are disabled, LSP tools are removed from the active tool set.
| Command | Args | Behavior |
|---------|------|----------|
| `/lsp-servers` | none | List running servers and disabled state |
| `/lsp-disable` | `[<id>]` | Disable all (no arg) or specific server. Bare command disables all. |
| `/lsp-enable` | `[<id>]` | Enable all (no arg) or specific server. Restores tools when any is enabled. |
| `/lsp-destroy` | `[<id>]` | Kill running daemon entries for all (no arg) or specific server. Explicitly destructive. |
```bash
/lsp-disable gopls # Disable just gopls; other LSP tools still work
/lsp-disable # Disable all — removes LSP tools from active set
/lsp-enable gopls # Re-enable gopls; restores tools
/lsp-enable # Re-enable all
/lsp-destroy gopls # Kill running gopls process(es) in the daemon
/lsp-destroy # Kill all running server processes
```
## Install
```bash
@@ -103,6 +123,44 @@ Edit `server.ts`:
}
```
## Per-Repo Config (`.pi-lsp.json`)
Drop a `.pi-lsp.json` at any ancestor of your working files to add or override
servers for that repo. The nearest config (walking upward) wins.
```json
{
"servers": [
{
"id": "rust-analyzer",
"match": ["rs"],
"command": "rust-analyzer",
"args": [],
"rootMarkers": ["Cargo.toml"],
"languageId": "rust"
},
{ "id": "gopls", "args": ["-remote=auto", "-vv"] }
],
"disable": ["oxlint"]
}
```
**Merge rules:**
- Entry with a built-in `id` → fields shallow-merge over the built-in (user wins).
- Entry with a new `id` → appended; must include `match`, `command`, `args`, `rootMarkers`.
- `disable` → filters out matching ids (built-in or user-defined).
**Reloading after edits:** Config is re-read on mtime change, so new lookups
pick up changes automatically. However, **already-running** language servers in
the daemon keep their original spawn args. If you change `command`, `args`, or
`rootMarkers`, run `/lsp-destroy` (or `pi-lsp daemon stop`) so they respawn
with the new config.
**Security note:** `.pi-lsp.json` controls what binary pi-lsp spawns. Treat it
like `.vscode/settings.json` — don't accept untrusted configs from arbitrary
repos.
## Adding A Command
1. Add to the `LspCommand` union in `src/types.ts`.

13
cli.ts
View File

@@ -2,7 +2,7 @@
import * as path from "node:path";
import { startClientForFile } from "./src/client.ts";
import { isLspCommand, listCommands, runCommand } from "./src/commands.ts";
import { pickServer } from "./src/root.ts";
import { pickServer, isServerAvailable, getServersForPath } from "./src/root.ts";
import {
daemonDiagnostics,
daemonRequest,
@@ -104,14 +104,21 @@ async function runViaDaemon(
const filePath = path.resolve(fileArg);
let result: unknown;
if (cmdArg === "diagnostics") {
result = await daemonDiagnostics(filePath);
// Pick All Available Servers For Diagnostics - Resolves against any
// `.pi-lsp.json` reachable from the file so per-repo overrides apply.
const ext = path.extname(filePath).replace(/^\./, "");
const serverIds = getServersForPath(filePath)
.filter((s) => s.match.includes(ext) && isServerAvailable(s))
.map((s) => s.id);
result = await daemonDiagnostics(filePath, serverIds);
} else if (cmdArg in methodMap) {
const server = pickServer(filePath);
// References Default - Match commands.ts: include declaration unless
// caller explicitly overrode `context`.
if (cmdArg === "references" && !("context" in params)) {
params.context = { includeDeclaration: true };
}
result = await daemonRequest(filePath, methodMap[cmdArg], params);
result = await daemonRequest(filePath, server.id, methodMap[cmdArg], params);
} else {
process.stderr.write(
`Unknown command "${cmdArg}". Known: ${listCommands().join(", ")}\n`,

View File

@@ -20,8 +20,14 @@
{
devShells.default = pkgs.mkShell {
packages = with pkgs; [
oxlint
nodejs_22
typescript-language-server
# Tests
go
gopls
pyright
];
};
}

448
index.ts
View File

@@ -6,20 +6,51 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "typebox";
import * as path from "node:path";
import { uriToPath } from "./src/client.ts";
import { daemonDiagnostics, daemonRequest } from "./src/daemonClient.ts";
import {
daemonDestroyServer,
daemonDiagnostics,
daemonRequest,
daemonStatus,
} from "./src/daemonClient.ts";
import { pickServer, isServerAvailable, getServersForPath } from "./src/root.ts";
import {
ServerNotFoundError,
UnsupportedExtensionError,
} from "./src/types.ts";
type LspUnavailable = { piLspUnavailable: true; message: string };
function lspUnavailable(message: string): LspUnavailable {
return { piLspUnavailable: true, message };
}
function isLspUnavailable(result: unknown): result is LspUnavailable {
return Boolean(
result &&
typeof result === "object" &&
"piLspUnavailable" in result &&
(result as { piLspUnavailable?: unknown }).piLspUnavailable === true,
);
}
function formatUnavailable(result: LspUnavailable): string {
return `(LSP unavailable: ${result.message})`;
}
// Format Hover - Turn an LSP hover response into readable text.
function formatHover(result: unknown): string {
if (isLspUnavailable(result)) return formatUnavailable(result);
if (!result || typeof result !== "object") return "(no hover info)";
const hover = result as { contents?: unknown };
if (!hover.contents) return "(empty)";
// MarkupContent
const contents = hover.contents as Record<string, unknown>;
if (
"value" in hover.contents &&
typeof (hover.contents as any).value === "string"
"value" in contents &&
typeof contents.value === "string"
) {
return (hover.contents as any).value;
return contents.value;
}
// MarkedString | MarkedString[]
if (Array.isArray(hover.contents)) {
@@ -28,17 +59,17 @@ function formatHover(result: unknown): string {
.join("\n");
}
if (
"value" in hover.contents &&
typeof (hover.contents as any).language === "string"
"value" in contents &&
typeof contents.language === "string"
) {
const ms = hover.contents as any;
return `\`\`\`${ms.language}\n${ms.value}\n\`\`\``;
return `\`\`\`${contents.language}\n${contents.value}\n\`\`\``;
}
return JSON.stringify(result, null, 2);
}
// Format Definition - Turn definition locations into readable text.
function formatDefinition(result: unknown): string {
if (isLspUnavailable(result)) return formatUnavailable(result);
if (!result) return "(no definition found)";
const locations = Array.isArray(result) ? result : [result];
if (locations.length === 0) return "(no definition found)";
@@ -54,27 +85,41 @@ function formatDefinition(result: unknown): string {
// Format References - Turn reference locations into readable text.
function formatReferences(result: unknown): string {
if (isLspUnavailable(result)) return formatUnavailable(result);
if (!result || !Array.isArray(result)) return "(no references found)";
if (result.length === 0) return "(no references found)";
return result
const limit = 30;
const shown = result.slice(0, limit);
const formatted = shown
.map((loc: any, i: number) => {
const file = uriToPath(loc.uri);
const range = loc.range;
return `${i + 1}. ${file} (${range.start.line + 1}:${range.start.character + 1})`;
})
.join("\n");
if (result.length > limit) {
return `${formatted}\n\n... and ${result.length - limit} more references (showing first ${limit})`;
}
return formatted;
}
// Format Completions - Turn completion items into readable text.
function formatCompletions(result: unknown): string {
if (isLspUnavailable(result)) return formatUnavailable(result);
if (!result) return "(no completions)";
// Resolve to CompletionItem[] if it's a CompletionList
let items: any[];
if (Array.isArray(result)) {
items = result;
} else if ("items" in result && Array.isArray((result as any).items)) {
} else if (
result &&
typeof result === "object" &&
"items" in result &&
Array.isArray((result as any).items)
) {
items = (result as any).items;
} else {
return JSON.stringify(result, null, 2);
@@ -128,6 +173,7 @@ function formatCompletions(result: unknown): string {
// Format Document Symbols - Turn symbols into a tree outline.
function formatDocumentSymbols(result: unknown): string {
if (isLspUnavailable(result)) return formatUnavailable(result);
if (!result || !Array.isArray(result)) return "(no symbols)";
const symbols = result as any[];
if (symbols.length === 0) return "(no symbols)";
@@ -178,11 +224,8 @@ function formatDocumentSymbols(result: unknown): string {
}
// Format Diagnostics - Turn diagnostic messages into readable text.
function formatDiagnostics(result: unknown): string {
if (!result || !("diagnostics" in result)) return "(no diagnostics)";
const diags = (result as any).diagnostics;
if (!Array.isArray(diags) || diags.length === 0) return "(no diagnostics)";
// Format Single Server Diagnostics - Renders one server's diagnostics list.
function formatServerDiagnostics(diags: any[], limit: number): string {
const severityNames: Record<number, string> = {
1: "Error",
2: "Warning",
@@ -190,7 +233,12 @@ function formatDiagnostics(result: unknown): string {
4: "Hint",
};
return diags
// Sort By Severity - Errors first, then warnings, info, hints. Ensures
// the most actionable issues survive truncation.
diags.sort((a: any, b: any) => (a.severity ?? 99) - (b.severity ?? 99));
const shown = diags.slice(0, limit);
const formatted = shown
.map((d: any, i: number) => {
const sev = severityNames[d.severity] ?? `sev:${d.severity}`;
const range = d.range;
@@ -200,23 +248,175 @@ function formatDiagnostics(result: unknown): string {
return `${i + 1}. [${sev}] ${d.message} (line ${line}, col ${col})`;
})
.join("\n");
if (diags.length > limit) {
return `${formatted}\n\n... and ${diags.length - limit} more (showing first ${limit})`;
}
return formatted;
}
// Format Diagnostics - Handles the grouped result map from the daemon:
// { [serverId]: { uri, diagnostics[] } }. Single-server results omit the
// header to avoid noise.
function formatDiagnostics(result: unknown, limit = 20): string {
if (isLspUnavailable(result)) return formatUnavailable(result);
if (!result || typeof result !== "object") return "(no diagnostics)";
const grouped = result as Record<string, any>;
const serverIds = Object.keys(grouped);
if (serverIds.length === 0) return "(no diagnostics)";
// Collect Servers With Diagnostics
const sections: { id: string; diags: any[] }[] = [];
for (const id of serverIds) {
const entry = grouped[id];
const diags = entry?.diagnostics;
if (Array.isArray(diags) && diags.length > 0) {
sections.push({ id, diags });
}
}
if (sections.length === 0) return "(no diagnostics)";
// Single Server - Skip header for brevity.
if (sections.length === 1) {
return formatServerDiagnostics(sections[0].diags, limit);
}
// Multiple Servers - Group with headers.
const perServer = Math.max(5, Math.floor(limit / sections.length));
return sections
.map((s) => `## ${s.id}\n${formatServerDiagnostics(s.diags, perServer)}`)
.join("\n\n");
}
// Is Expected Error - Returns true if the error is an expected condition
// (unsupported file type or missing server binary) that should be
// suppressed rather than surfaced to the user.
function isExpectedError(error: unknown): boolean {
return (
error instanceof UnsupportedExtensionError ||
error instanceof ServerNotFoundError
);
}
function unavailableForFile(filePath: string, includeDiagnosticsOnly = false): LspUnavailable {
const ext = path.extname(filePath).replace(/^\./, "");
const label = ext ? `.${ext}` : "files without an extension";
const matches = getServersForPath(filePath).filter((server) =>
server.match.includes(ext) && (includeDiagnosticsOnly || !server.diagnosticsOnly)
);
if (matches.length === 0) {
return lspUnavailable(`no LSP server is registered for ${label}`);
}
const disabled = matches.filter((server) => disabledServers.has(server.id));
if (disabled.length === matches.length) {
return lspUnavailable(
`matching LSP server(s) are disabled: ${disabled.map((s) => s.id).join(", ")}`,
);
}
const unavailable = matches.filter((server) => !isServerAvailable(server));
if (unavailable.length > 0) {
return lspUnavailable(
`matching LSP server(s) are not on PATH: ${unavailable.map((s) => s.command).join(", ")}`,
);
}
return lspUnavailable(`no applicable LSP server is available for ${label}`);
}
// Disabled Servers - In-memory set of disabled server IDs. Scoped to this
// extension process; the shared daemon is never mutated by disable/enable.
const lspToolNames = [
"lsp_hover",
"lsp_definition",
"lsp_references",
"lsp_completion",
"lsp_documentSymbol",
"lsp_diagnostics",
];
const disabledServers = new Set<string>();
// Run LSP Request - Forwards to the daemon, which owns the long-lived
// LspClient cache and handles didOpen/didChange syncing. The daemon
// injects textDocument.uri from the file path, so we omit it here.
// Gated by disabledServers — throws early if the target server is disabled.
async function runLsp(
filePath: string,
method: string,
params: Record<string, unknown>,
): Promise<unknown> {
return daemonRequest(filePath, method, params);
try {
// Check Disabled - The server for this file is blocked; bail before
// touching the daemon so other pi instances sharing it are unaffected.
const server = pickServer(filePath);
if (disabledServers.has(server.id)) {
return unavailableForFile(filePath);
}
return await daemonRequest(filePath, server.id, method, params);
} catch (error) {
if (isExpectedError(error)) {
return unavailableForFile(filePath);
}
// Daemon-wrapped errors (plain Error with expected message) are also
// expected — the daemon catches pickServer() throws and returns them
// as string error messages.
if (
error instanceof Error &&
(error.message.includes("No LSP server registered") ||
error.message.includes("not found on PATH"))
) {
return unavailableForFile(filePath);
}
throw error;
}
}
// Run LSP Diagnostics - Diagnostics arrive as a notification, so the
// daemon has a dedicated op that waits for the next publish.
// Pick Diagnostic Servers - Returns all available, non-disabled servers
// matching the file's extension. Resolves the per-repo config from the
// file's directory so user-defined servers participate in fan-out.
function pickDiagnosticServers(filePath: string): string[] {
const ext = path.extname(filePath).replace(/^\./, "");
return getServersForPath(filePath)
.filter((s) => s.match.includes(ext) && isServerAvailable(s) && !disabledServers.has(s.id))
.map((s) => s.id);
}
// Timeout Wrapper - Rejects a promise after the given number of milliseconds.
// Used to prevent async hooks from blocking pi indefinitely.
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms);
promise.then(
(value) => { clearTimeout(timer); resolve(value); },
(reason) => { clearTimeout(timer); reject(reason); },
);
});
}
// Run LSP Diagnostics - Fans out to all matching servers in a single
// daemon call. Returns an explicit unavailable result if no server applies.
async function runDiagnostics(filePath: string): Promise<unknown> {
return daemonDiagnostics(filePath, 1500);
try {
const serverIds = pickDiagnosticServers(filePath);
if (serverIds.length === 0) return unavailableForFile(filePath, true);
return await daemonDiagnostics(filePath, serverIds, 1500);
} catch (error) {
if (isExpectedError(error)) {
return unavailableForFile(filePath, true);
}
if (
error instanceof Error &&
(error.message.includes("No LSP server registered") ||
error.message.includes("not found on PATH"))
) {
return unavailableForFile(filePath, true);
}
throw error;
}
}
// Shared Parameters Schema - All position-based tools accept file + optional
@@ -376,11 +576,38 @@ export default function (pi: ExtensionAPI) {
default: true,
});
// Background Init on Read - Fire-and-forget LSP initialization when the
// LLM reads a file with a supported extension. Doesn't block the read,
// just ensures the server is warm by the time an LSP tool is called.
pi.on("tool_result", async (event, ctx) => {
if (event.toolName !== "read" || event.isError) return;
const filePath = event.input?.path;
if (!filePath || typeof filePath !== "string") return;
try {
const absolutePath = path.resolve(ctx.cwd, filePath);
// Warm Diagnostic Servers - Fire-and-forget so servers are ready by
// the time an LSP tool is called.
const serverIds = pickDiagnosticServers(absolutePath);
if (serverIds.length > 0) {
void daemonDiagnostics(absolutePath, serverIds).catch(() => {});
}
} catch {
// Silently ignore — unsupported file type, missing binary, etc.
}
});
// Auto-Check After Edit/Write - Run diagnostics automatically
pi.on("tool_result", async (event, ctx) => {
// Check Enabled
if (!pi.getFlag("lsp-auto-check")) return;
// Skip If All Disabled - No LSP server is available for this instance.
// Resolve against ctx.cwd so user-defined servers count toward this check.
const cwdServers = getServersForPath(ctx.cwd);
if (cwdServers.every((s) => disabledServers.has(s.id))) return;
// Edit & Write Only
if (!["edit", "write"].includes(event.toolName)) return;
@@ -393,9 +620,11 @@ export default function (pi: ExtensionAPI) {
const absolutePath = path.resolve(ctx.cwd, filePath);
try {
// Run LSP diagnostics
const result = await runDiagnostics(absolutePath);
const formatted = formatDiagnostics(result);
// Run LSP diagnostics with timeout - Prevent the auto-check hook from
// blocking pi if the daemon or LSP server is slow or unresponsive.
const result = await withTimeout(runDiagnostics(absolutePath), 3000);
if (isLspUnavailable(result)) return;
const formatted = formatDiagnostics(result, 10);
// Only send a message if there are actual diagnostics
if (formatted !== "(no diagnostics)") {
@@ -412,13 +641,14 @@ export default function (pi: ExtensionAPI) {
} catch (error) {
// Silently fail - don't interrupt the flow
// Only log if there's an actual error we care about
if (error && typeof error === "object" && "message" in error) {
const msg = (error as { message: string }).message;
if (!msg.includes("not found on PATH")) {
if (!isExpectedError(error)) {
const msg =
error && typeof error === "object" && "message" in error
? (error as { message: string }).message
: String(error);
console.error("LSP auto-check failed:", msg);
}
}
}
});
// Manual Check Command - Run diagnostics on specific files
@@ -453,4 +683,168 @@ export default function (pi: ExtensionAPI) {
}
},
});
// --- Server Control Commands ---
// Resolve Active Servers - Per-cwd helper so commands see the same
// per-repo overrides the LLM does. We accept an optional cwd because some
// closures (e.g. completions) don't get a context.
const resolveServers = (cwd?: string) => getServersForPath(cwd ?? process.cwd());
// Shared Argument Completions - Suggests registered server IDs plus "all".
// Uses process.cwd() since completion handlers don't receive a context;
// close enough for argument hints.
const serverCompletions = (prefix: string) => {
const ids = [...resolveServers().map((s) => s.id), "all"].filter((id) =>
id.startsWith(prefix),
);
return ids.length > 0 ? ids.map((id) => ({ value: id, label: id })) : null;
};
// Parse Server IDs - Validates args against registered servers. Bare/empty
// or "all" returns every server ID. Throws on unknown names.
function parseServerIds(args: string | undefined, cwd: string): string[] {
const list = resolveServers(cwd);
if (!args || !args.trim()) return list.map((s) => s.id);
const ids = args.trim().split(/\s+/);
if (ids.includes("all")) return list.map((s) => s.id);
const invalid = ids.filter((id) => !list.some((s) => s.id === id));
if (invalid.length > 0) {
throw new Error(
`Unknown server(s): ${invalid.join(
", ",
)}. Available: ${list.map((s) => s.id).join(", ")}`,
);
}
return ids;
}
// Update Tool Visibility - When all servers are disabled, remove LSP tools
// from the active set so the LLM won't attempt them. When any is enabled,
// restore them. Captures current active tools at toggle time.
function updateToolVisibility(cwd: string): void {
const list = resolveServers(cwd);
const current = pi.getActiveTools();
if (list.every((s) => disabledServers.has(s.id))) {
// All disabled — strip LSP tools
pi.setActiveTools(current.filter((name) => !lspToolNames.includes(name)));
} else {
// Any enabled — merge LSP tools back in
const merged = [...new Set([...current, ...lspToolNames])];
pi.setActiveTools(merged);
}
}
// List Servers - Show running daemon entries and disabled state.
pi.registerCommand("lsp-servers", {
description: "List running LSP servers and disabled state",
handler: async (_args, ctx) => {
try {
const status = (await daemonStatus()) as { servers?: unknown[] };
const running = Array.isArray(status?.servers) ? status.servers : [];
const disabled = Array.from(disabledServers);
if (running.length === 0 && disabled.length === 0) {
ctx.ui.notify("No running servers, none disabled", "info");
return;
}
let msg = "";
if (running.length > 0) {
msg += `Running: ${running.map((s: any) => s.id).join(", ")}\n`;
}
if (disabled.length > 0) {
msg += `Disabled: ${disabled.join(", ")}`;
}
ctx.ui.notify(msg.trim(), "info");
} catch (error) {
const msg =
error && typeof error === "object" && "message" in error
? (error as { message: string }).message
: "Unknown error";
ctx.ui.notify(`Status failed: ${msg}`, "error");
}
},
});
// Disable Servers - Add to disabled set; removes LSP tools when all are
// disabled so the LLM won't waste context on them.
pi.registerCommand("lsp-disable", {
description:
"Disable LSP server(s) — bare command disables all. Removes tools when all are disabled.",
getArgumentCompletions: serverCompletions,
handler: async (args, ctx) => {
try {
const ids = parseServerIds(args, ctx.cwd);
for (const id of ids) {
disabledServers.add(id);
}
updateToolVisibility(ctx.cwd);
const total = resolveServers(ctx.cwd).length;
const label = ids.length === total ? "all servers" : ids.join(", ");
ctx.ui.notify(`Disabled: ${label}`, "info");
} catch (error) {
const msg =
error && typeof error === "object" && "message" in error
? (error as { message: string }).message
: "Unknown error";
ctx.ui.notify(msg, "error");
}
},
});
// Enable Servers - Remove from disabled set; restores LSP tools when any
// server becomes available.
pi.registerCommand("lsp-enable", {
description:
"Enable LSP server(s) — bare command enables all. Restores tools when any is enabled.",
getArgumentCompletions: serverCompletions,
handler: async (args, ctx) => {
try {
const ids = parseServerIds(args, ctx.cwd);
for (const id of ids) {
disabledServers.delete(id);
}
updateToolVisibility(ctx.cwd);
const total = resolveServers(ctx.cwd).length;
const label = ids.length === total ? "all servers" : ids.join(", ");
ctx.ui.notify(`Enabled: ${label}`, "info");
} catch (error) {
const msg =
error && typeof error === "object" && "message" in error
? (error as { message: string }).message
: "Unknown error";
ctx.ui.notify(msg, "error");
}
},
});
// Destroy Servers - Kill running LspClient entries in the daemon. Entries
// can respawn on next request; pair with /lsp-disable to also block.
pi.registerCommand("lsp-destroy", {
description:
"Kill running LSP server process(es) in the daemon — bare command destroys all.",
getArgumentCompletions: serverCompletions,
handler: async (args, ctx) => {
try {
const ids = parseServerIds(args, ctx.cwd);
const total = resolveServers(ctx.cwd).length;
if (ids.length === total) {
await daemonDestroyServer();
} else {
for (const id of ids) {
await daemonDestroyServer(id);
}
}
const label = ids.length === total ? "all servers" : ids.join(", ");
ctx.ui.notify(`Destroyed: ${label}`, "info");
} catch (error) {
const msg =
error && typeof error === "object" && "message" in error
? (error as { message: string }).message
: "Unknown error";
ctx.ui.notify(msg, "error");
}
},
});
}

3802
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,16 +15,25 @@
"scripts": {
"lsp": "tsx ./cli.ts",
"typecheck": "tsc --noEmit",
"lint": "oxlint . --ignore-pattern=.direnv/**"
"lint": "oxlint . --ignore-pattern=.direnv/**",
"test": "NODE_OPTIONS='--import=tsx' node --test test/unit/**/*.ts test/integration/**/*.ts",
"test:unit": "NODE_OPTIONS='--import=tsx' node --test test/unit/**/*.ts",
"test:integration": "NODE_OPTIONS='--import=tsx' node --test test/integration/**/*.ts"
},
"dependencies": {
"chokidar": "^5.0.0",
"ignore": "^7.0.5",
"picomatch": "^4.0.4",
"vscode-jsonrpc": "^8.2.1",
"vscode-languageserver-protocol": "^3.17.5"
},
"devDependencies": {
"@mariozechner/pi-coding-agent": "^0.72.0",
"@types/node": "^22.10.0",
"@types/picomatch": "^4.0.3",
"oxlint": "^1.62.0",
"tsx": "^4.19.2",
"typebox": "^1.1.37",
"typescript": "^6.0.3"
}
}

125
server.ts
View File

@@ -3,8 +3,45 @@
//
// Add new servers here. `match` is a list of file extensions (no dot) OR
// language ids; either matches.
import * as fs from "node:fs";
import * as path from "node:path";
import type { ServerConfig } from "./src/types.ts";
// Resolve Python Path - Prefer the project's virtualenv when present so
// Pyright sees the same interpreter/dependencies as project commands.
function resolvePythonPath(rootDir: string): string | undefined {
const candidates = [
path.join(rootDir, ".venv", "bin", "python"),
path.join(rootDir, "venv", "bin", "python"),
];
return candidates.find((candidate) => fs.existsSync(candidate));
}
// Pyright Settings - Minimal editor settings needed for diagnostics and import
// resolution. Shared by workspace/configuration and didChangeConfiguration.
function pyrightSettings(rootDir: string): {
pythonPath: string | undefined;
analysis: {
diagnosticMode: string;
typeCheckingMode: string;
autoSearchPaths: boolean;
useLibraryCodeForTypes: boolean;
};
} {
return {
pythonPath: resolvePythonPath(rootDir),
analysis: {
diagnosticMode: "openFilesOnly",
typeCheckingMode: "basic",
autoSearchPaths: true,
useLibraryCodeForTypes: true,
},
};
}
// Global Root Markers — appended to every server's rootMarkers list
export const globalRootMarkers = [".git"];
export const servers: ServerConfig[] = [
{
id: "gopls",
@@ -22,6 +59,20 @@ export const servers: ServerConfig[] = [
rootMarkers: ["pnpm-workspace.yaml", "tsconfig.json", "package.json"],
languageId: "typescript",
},
{
id: "svelteserver",
match: ["svelte"],
command: "svelteserver",
args: ["--stdio"],
rootMarkers: [
"svelte.config.js",
"svelte.config.ts",
"svelte.config.cjs",
"svelte.config.mjs",
"package.json",
],
languageId: "svelte",
},
{
id: "pyright",
match: ["py"],
@@ -29,5 +80,79 @@ export const servers: ServerConfig[] = [
args: ["--stdio"],
rootMarkers: ["pyproject.toml", "setup.py", "setup.cfg"],
languageId: "python",
workspaceConfiguration: {
initialSettings: ({ rootDir }) => ({ python: pyrightSettings(rootDir) }),
getSection: (section, { rootDir }) => {
const settings = pyrightSettings(rootDir);
if (section === "python") return settings;
if (section === "python.analysis") return settings.analysis;
return null;
},
},
},
{
id: "lua-language-server",
match: ["lua"],
command: "lua-language-server",
args: [],
rootMarkers: [".luarc.json"],
languageId: "lua",
},
{
id: "vscode-html-language-server",
match: ["html"],
command: "vscode-html-language-server",
args: ["--stdio"],
rootMarkers: ["package.json"],
languageId: "html",
},
{
id: "vscode-css-language-server",
match: ["css", "scss", "less"],
command: "vscode-css-language-server",
args: ["--stdio"],
rootMarkers: ["package.json"],
languageId: "css",
},
{
id: "vscode-json-language-server",
match: ["json", "jsonc", "jsonl"],
command: "vscode-json-language-server",
args: ["--stdio"],
rootMarkers: ["package.json"],
languageId: "json",
},
{
id: "bash-language-server",
match: ["sh", "bash"],
command: "bash-language-server",
args: ["start"],
rootMarkers: [".git"],
languageId: "shellscript",
},
{
id: "sqls",
match: ["sql"],
command: "sqls",
args: [],
rootMarkers: [".git"],
languageId: "sql",
},
{
id: "nil",
match: ["nix"],
command: "nil",
args: [],
rootMarkers: ["flake.nix"],
languageId: "nix",
},
{
id: "oxlint",
match: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
command: "oxlint",
args: ["--lsp"],
rootMarkers: [".oxlintrc.json", "oxlint.config.json"],
languageId: "typescript",
diagnosticsOnly: true,
},
];

View File

@@ -7,37 +7,16 @@ import {
type MessageConnection,
} from "vscode-jsonrpc/node.js";
import type {
FileSystemWatcher,
InitializeParams,
PublishDiagnosticsParams,
Registration,
Unregistration,
} from "vscode-languageserver-protocol";
import * as path from "node:path";
import type { ServerConfig } from "./types.ts";
import { ServerNotFoundError } from "./types.ts";
import { findRoot, pathToUri, uriToPath } from "./root.ts";
// Is On PATH - Returns true if `cmd` resolves to an executable via the
// current PATH. Absolute/relative paths are checked directly.
function isOnPath(cmd: string): boolean {
const isExec = (p: string) => {
try {
fs.accessSync(p, fs.constants.X_OK);
return true;
} catch {
return false;
}
};
if (cmd.includes(path.sep)) return isExec(cmd);
const exts =
process.platform === "win32"
? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")
: [""];
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
if (!dir) continue;
for (const ext of exts) {
if (isExec(path.join(dir, cmd + ext))) return true;
}
}
return false;
}
import { isOnPath } from "./util.ts";
// LspClient - Thin wrapper that spawns a language server, performs the
// initialize handshake, auto-opens a file, and exposes sendRequest so the
@@ -64,23 +43,23 @@ export class LspClient {
// version numbers in didOpen/didChange. We track them so the daemon
// can resync files via notifyChange after on-disk edits.
private versions = new Map<string, number>();
private fileWatchers = new Map<string, FileSystemWatcher[]>();
private watchersListeners = new Set<() => void>();
constructor(private readonly server: ServerConfig) {}
// Start - Spawns the server process and wires up JSON-RPC.
async start(rootDir: string): Promise<void> {
async start(rootDir: string, env: NodeJS.ProcessEnv): Promise<void> {
// Verify Binary On PATH - Fail fast with a clear message instead of
// letting spawn ENOENT surface as a generic error. It's the user's
// responsibility to have the server installed & on PATH.
if (!isOnPath(this.server.command)) {
throw new Error(
`LSP server binary "${this.server.command}" not found on PATH. ` +
`Install it and ensure it's on your PATH (required by server "${this.server.id}").`,
);
// letting spawn ENOENT surface as a generic error. Resolution uses the
// caller/session env, not the daemon's launch-time env.
if (!isOnPath(this.server.command, env)) {
throw new ServerNotFoundError(this.server.command);
}
this.proc = spawn(this.server.command, this.server.args, {
stdio: ["pipe", "pipe", "pipe"],
cwd: rootDir,
env,
});
this.proc.on("error", (err) => {
process.stderr.write(
@@ -128,11 +107,47 @@ export class LspClient {
}
},
);
// Accept Common Server Requests - Return empty/null so servers don't
// stall. Good enough for a CLI; a real client would answer properly.
this.conn.onRequest("workspace/configuration", () => []);
this.conn.onRequest("client/registerCapability", () => null);
this.conn.onRequest("client/unregisterCapability", () => null);
// Accept Common Server Requests - Return one configuration response per
// requested item. Server-specific settings live in server.ts so adding
// another picky server doesn't grow conditionals in this transport layer.
this.conn.onRequest(
"workspace/configuration",
(params: { items?: { section?: string }[] }) => {
const items = params.items ?? [];
const config = this.server.workspaceConfiguration;
return items.map((item) =>
config?.getSection?.(item.section, { rootDir, env }) ?? null,
);
},
);
this.conn.onRequest(
"client/registerCapability",
(params: { registrations?: Registration[] }) => {
let changed = false;
for (const reg of params.registrations ?? []) {
if (reg.method !== "workspace/didChangeWatchedFiles") continue;
const opts = reg.registerOptions as
| { watchers?: FileSystemWatcher[] }
| undefined;
this.fileWatchers.set(reg.id, opts?.watchers ?? []);
changed = true;
}
if (changed) for (const l of this.watchersListeners) l();
return null;
},
);
this.conn.onRequest(
"client/unregisterCapability",
(params: { unregisterations?: Unregistration[] }) => {
let changed = false;
for (const unreg of params.unregisterations ?? []) {
if (unreg.method !== "workspace/didChangeWatchedFiles") continue;
if (this.fileWatchers.delete(unreg.id)) changed = true;
}
if (changed) for (const l of this.watchersListeners) l();
return null;
},
);
this.conn.listen();
@@ -151,7 +166,11 @@ export class LspClient {
publishDiagnostics: {},
synchronization: { didSave: true },
},
workspace: { workspaceFolders: true, configuration: true },
workspace: {
workspaceFolders: true,
configuration: true,
didChangeWatchedFiles: { dynamicRegistration: true },
},
},
};
await this.conn.sendRequest("initialize", {
@@ -162,6 +181,16 @@ export class LspClient {
},
});
this.conn.sendNotification("initialized", {});
// Push Configuration - Some servers do not always request workspace/configuration,
// but still consume settings delivered through didChangeConfiguration.
const settings = this.server.workspaceConfiguration?.initialSettings?.({
rootDir,
env,
});
if (settings !== undefined && settings !== null) {
this.conn.sendNotification("workspace/didChangeConfiguration", { settings });
}
}
// Wait For Ready - Resolves when there are no outstanding progress
@@ -218,11 +247,34 @@ export class LspClient {
return uri;
}
closeDocument(uri: string): void {
this.versions.delete(uri);
this.diagnostics.delete(uri);
this.conn.sendNotification("textDocument/didClose", {
textDocument: { uri },
});
}
// Send Raw LSP Request - Passthrough used by the command dispatcher.
sendRequest<R = unknown>(method: string, params: unknown): Promise<R> {
return this.conn.sendRequest(method, params) as Promise<R>;
}
sendNotification(method: string, params: unknown): void {
this.conn.sendNotification(method, params);
}
getFileWatchers(): FileSystemWatcher[] {
return Array.from(this.fileWatchers.values()).flat();
}
onWatchersChanged(listener: () => void): () => void {
this.watchersListeners.add(listener);
return () => {
this.watchersListeners.delete(listener);
};
}
// Clear Diagnostics - Drops the cached diagnostics for a URI so callers
// can force waitForDiagnostics to await a fresh publish after didChange.
clearDiagnostics(uri: string): void {
@@ -279,10 +331,11 @@ export class LspClient {
export async function startClientForFile(
server: ServerConfig,
filePath: string,
env: NodeJS.ProcessEnv = process.env,
): Promise<{ client: LspClient; uri: string; rootDir: string }> {
const rootDir = findRoot(filePath, server.rootMarkers);
const client = new LspClient(server);
await client.start(rootDir);
await client.start(rootDir, env);
const uri = client.openDocument(filePath);
// Wait For Workspace Load - gopls & friends reject requests with errors
// like "no views" until their initial load completes.

141
src/config.ts Normal file
View File

@@ -0,0 +1,141 @@
// Per-Repo Config - Loads `.pi-lsp.json` from the nearest ancestor of a
// given path and merges it with the built-in server registry. Config is a
// JSON file shaped like:
//
// {
// "servers": [
// { "id": "rust-analyzer", "match": ["rs"], "command": "rust-analyzer",
// "args": [], "rootMarkers": ["Cargo.toml"], "languageId": "rust" },
// { "id": "gopls", "args": ["-remote=auto", "-vv"] }
// ],
// "disable": ["oxlint"]
// }
//
// Merge Semantics:
// - Entries whose `id` matches a built-in shallow-merge their fields (user wins).
// - Entries with a new `id` append, and must include all required fields.
// - The `disable` list filters the merged result by id.
//
// Caching: Resolved lists are cached per config-file path and invalidated by
// mtime. If no config file exists, the built-in registry is returned.
import * as fs from "node:fs";
import * as path from "node:path";
import { servers as builtinServers } from "../server.ts";
import type { ServerConfig } from "./types.ts";
const CONFIG_FILE = ".pi-lsp.json";
interface PiLspConfig {
servers?: Array<Partial<ServerConfig> & { id: string }>;
disable?: string[];
}
interface CacheEntry {
mtimeMs: number;
resolved: ServerConfig[];
}
const cache = new Map<string, CacheEntry>();
// Find Config File - Walks upward from `fromDir` looking for `.pi-lsp.json`.
// Returns the absolute path or null if none found before the filesystem root.
function findConfigFile(fromDir: string): string | null {
let dir = path.resolve(fromDir);
const { root } = path.parse(dir);
while (true) {
const candidate = path.join(dir, CONFIG_FILE);
if (fs.existsSync(candidate)) return candidate;
if (dir === root) return null;
dir = path.dirname(dir);
}
}
// Merge Config - Built-ins by id, user overrides shallow-merge in, new ids
// append (with required-field validation), and `disable` filters the result.
function mergeConfig(config: PiLspConfig, sourcePath: string): ServerConfig[] {
const byId = new Map<string, ServerConfig>(
builtinServers.map((s) => [s.id, { ...s }]),
);
// Apply Overrides And New Servers
for (const override of config.servers ?? []) {
if (!override.id || typeof override.id !== "string") {
throw new Error(
`pi-lsp config (${sourcePath}): every entry in "servers" must have a string "id"`,
);
}
const existing = byId.get(override.id);
if (existing) {
byId.set(override.id, { ...existing, ...override } as ServerConfig);
} else {
// New Server - Must include all required spawn fields.
const required = ["match", "command", "args", "rootMarkers"] as const;
const missing = required.filter((k) => override[k] === undefined);
if (missing.length > 0) {
throw new Error(
`pi-lsp config (${sourcePath}): new server "${override.id}" is missing required field(s): ${missing.join(", ")}`,
);
}
byId.set(override.id, override as ServerConfig);
}
}
// Apply Disable Filter
const disabled = new Set(config.disable ?? []);
return Array.from(byId.values()).filter((s) => !disabled.has(s.id));
}
// Get Servers For Path - Returns the merged ServerConfig list applicable to
// `fromDir` (or the directory containing a file path). Cached per config-file
// path with mtime invalidation. On parse/merge failure, logs to stderr and
// falls back to the built-in registry so a broken config never breaks LSP.
export function getServersForPath(fromPath: string): ServerConfig[] {
// Resolve To Directory - Accept either a file path or a directory.
let dir = path.resolve(fromPath);
try {
const st = fs.statSync(dir);
if (!st.isDirectory()) dir = path.dirname(dir);
} catch {
dir = path.dirname(dir);
}
const configPath = findConfigFile(dir);
if (!configPath) return builtinServers;
let mtimeMs: number;
try {
mtimeMs = fs.statSync(configPath).mtimeMs;
} catch {
return builtinServers;
}
const cached = cache.get(configPath);
if (cached && cached.mtimeMs === mtimeMs) return cached.resolved;
let parsed: PiLspConfig;
try {
const raw = fs.readFileSync(configPath, "utf8");
parsed = JSON.parse(raw);
} catch (err) {
process.stderr.write(
`pi-lsp: failed to read/parse ${configPath}: ${(err as Error).message}\n`,
);
return builtinServers;
}
let resolved: ServerConfig[];
try {
resolved = mergeConfig(parsed, configPath);
} catch (err) {
process.stderr.write(`pi-lsp: ${(err as Error).message}\n`);
return builtinServers;
}
cache.set(configPath, { mtimeMs, resolved });
return resolved;
}
// Clear Config Cache - Test hook; not used in production.
export function clearConfigCache(): void {
cache.clear();
}

View File

@@ -5,19 +5,26 @@
import * as fs from "node:fs";
import * as net from "node:net";
import * as path from "node:path";
import type { FileSystemWatcher } from "vscode-languageserver-protocol";
import { LspClient } from "./client.ts";
import { findRoot, pickServer, pathToUri } from "./root.ts";
import { findRoot, findServerById, pathToUri } from "./root.ts";
import type { ServerConfig } from "./types.ts";
import { WorkspaceWatcher, type FileEvent } from "./watcher.ts";
import {
logPath,
socketPath,
tryConnect,
type DaemonRequest,
type DaemonResponse,
type LaunchContext,
} from "./daemonProtocol.ts";
// Default Idle TTL - 5 minutes. Per-server overrides via ServerConfig.idleTtlMs.
const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000;
const WATCHER_READY_TIMEOUT_MS = 5000;
const FILE_CHANGE_DELETED = 3;
const WATCH_KIND_CREATE = 1;
const WATCH_KIND_CHANGE = 2;
const WATCH_KIND_DELETE = 4;
// Client Entry - One LspClient per (server.id, rootDir), plus the bookkeeping
// needed to keep files in sync and evict on idleness.
@@ -36,6 +43,9 @@ interface ClientEntry {
idleTimer: NodeJS.Timeout | null;
ttlMs: number;
lastUsed: number;
watcher: WorkspaceWatcher | null;
unsubscribeWatchers: (() => void) | null;
usesDerivedWatchers: boolean;
}
const entries = new Map<string, ClientEntry>();
@@ -49,11 +59,17 @@ function log(...args: unknown[]) {
);
}
// Get Or Create Entry - Looks up the cached client for a file, spawning a
// fresh LspClient if needed. The returned entry is guaranteed to have its
// `ready` promise resolved before the caller uses it.
async function getOrCreateEntry(filePath: string): Promise<ClientEntry> {
const server = pickServer(filePath);
// Get Or Create Entry - Looks up the cached client for a server+file,
// spawning a fresh LspClient if needed. The returned entry is guaranteed
// to have its `ready` promise resolved before the caller uses it. The
// server registry is resolved against any `.pi-lsp.json` reachable from
// `filePath`, so per-repo config overrides take effect at spawn time.
async function getOrCreateEntry(
filePath: string,
serverId: string,
launch: LaunchContext,
): Promise<ClientEntry> {
const server = findServerById(filePath, serverId);
const rootDir = findRoot(filePath, server.rootMarkers);
const key = `${server.id}::${rootDir}`;
const existing = entries.get(key);
@@ -72,7 +88,7 @@ async function getOrCreateEntry(filePath: string): Promise<ClientEntry> {
client,
ready: (async () => {
log(`spawn`, server.id, rootDir);
await client.start(rootDir);
await client.start(rootDir, launch.env);
await client.waitForReady();
log(`ready`, server.id);
})(),
@@ -81,6 +97,9 @@ async function getOrCreateEntry(filePath: string): Promise<ClientEntry> {
idleTimer: null,
ttlMs,
lastUsed: Date.now(),
watcher: null,
unsubscribeWatchers: null,
usesDerivedWatchers: false,
};
entries.set(key, entry);
try {
@@ -89,10 +108,104 @@ async function getOrCreateEntry(filePath: string): Promise<ClientEntry> {
entries.delete(key);
throw err;
}
await attachWatcher(entry);
bumpIdle(entry);
return entry;
}
// Attach Watcher - Registration can happen during initialize, before the daemon subscribes.
async function attachWatcher(entry: ClientEntry): Promise<void> {
if (process.env.PI_LSP_DISABLE_WATCHERS) return;
entry.unsubscribeWatchers = entry.client.onWatchersChanged(() => void refreshWatcher(entry));
await refreshWatcher(entry);
}
function watcherPatterns(entry: ClientEntry): FileSystemWatcher[] {
const registered = entry.client.getFileWatchers();
entry.usesDerivedWatchers = registered.length === 0;
return registered.length > 0 ? registered : derivedServerWatchers(entry.server);
}
function derivedServerWatchers(server: ServerConfig): FileSystemWatcher[] {
const extensions = server.match
.map((m) => m.replace(/^\./, ""))
.filter((m) => /^[A-Za-z0-9_-]+$/.test(m));
if (extensions.length === 0) return [];
const globPattern = extensions.length === 1
? `**/*.${extensions[0]}`
: `**/*.{${extensions.join(",")}}`;
return [{
globPattern,
kind: WATCH_KIND_CREATE | WATCH_KIND_CHANGE | WATCH_KIND_DELETE,
}];
}
async function refreshWatcher(entry: ClientEntry): Promise<void> {
if (process.env.PI_LSP_DISABLE_WATCHERS) return;
const patterns = watcherPatterns(entry);
if (patterns.length === 0 && !entry.watcher) return;
if (!entry.watcher) {
entry.watcher = new WorkspaceWatcher(entry.rootDir, (events) =>
forwardEvents(entry, events),
);
log(`watcher`, entry.server.id, entry.rootDir, `patterns=${patterns.length}`);
}
if (process.env.LSP_DEBUG) {
log(`watcher patterns`, entry.server.id, JSON.stringify(patterns));
}
entry.watcher.setPatterns(patterns);
if (patterns.length > 0) await waitForWatcherReady(entry);
}
async function waitForWatcherReady(entry: ClientEntry): Promise<void> {
if (!entry.watcher) return;
let timeout: NodeJS.Timeout | null = null;
let timedOut = false;
try {
await Promise.race([
entry.watcher.ready(),
new Promise<void>((resolve) => {
timeout = setTimeout(() => {
timedOut = true;
resolve();
}, WATCHER_READY_TIMEOUT_MS);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
if (timedOut) {
log(`watcher ready timeout`, entry.server.id, entry.rootDir);
}
}
function forwardEvents(entry: ClientEntry, events: FileEvent[]): void {
try {
for (const event of events) {
if (event.type !== FILE_CHANGE_DELETED || !entry.opened.has(event.uri)) continue;
entry.client.closeDocument(event.uri);
entry.opened.delete(event.uri);
void refreshWatcher(entry);
}
if (process.env.LSP_DEBUG) {
log(`watcher fire`, entry.server.id, JSON.stringify(events));
}
for (const uri of entry.opened.keys()) entry.client.clearDiagnostics(uri);
entry.client.sendNotification("workspace/didChangeWatchedFiles", {
changes: events,
});
if (entry.usesDerivedWatchers && entry.server.id === "typescript-language-server") {
void entry.client.sendRequest("workspace/executeCommand", {
command: "typescript.tsserverRequest",
arguments: ["reloadProjects"],
}).catch((err) => log("typescript reloadProjects failed", (err as Error).message));
}
} catch (err) {
log(`watcher send failed`, entry.server.id, (err as Error).message);
}
}
// Bump Idle - Resets the idle eviction timer. Called on every request that
// touches the entry. We log evictions so the daemon's behavior is visible.
function bumpIdle(entry: ClientEntry) {
@@ -106,7 +219,14 @@ function evict(entry: ClientEntry, reason: string) {
log(`evict`, entry.key, reason);
entries.delete(entry.key);
if (entry.idleTimer) clearTimeout(entry.idleTimer);
if (entry.unsubscribeWatchers) entry.unsubscribeWatchers();
void entry.watcher?.dispose();
void entry.client.dispose();
// Auto Shutdown - If this was the last entry, there's nothing left to
// manage. Tear down the daemon so it doesn't sit idle forever.
if (entries.size === 0) {
shutdownDaemon("all entries evicted");
}
}
// Sync File - Ensures the language server has the current contents of the
@@ -152,7 +272,7 @@ async function handle(req: DaemonRequest): Promise<DaemonResponse> {
switch (req.op) {
case "request": {
const filePath = path.resolve(req.file);
const entry = await getOrCreateEntry(filePath);
const entry = await getOrCreateEntry(filePath, req.serverId, req.launch);
const { uri } = await syncFile(entry, filePath);
bumpIdle(entry);
const result = await entry.client.sendRequest(
@@ -163,15 +283,27 @@ async function handle(req: DaemonRequest): Promise<DaemonResponse> {
}
case "diagnostics": {
const filePath = path.resolve(req.file);
const entry = await getOrCreateEntry(filePath);
const timeoutMs = req.timeoutMs ?? 1500;
// Fan-Out - Run diagnostics against all requested servers in
// parallel. Individual failures are captured, not thrown.
const results: Record<string, unknown> = {};
const settled = await Promise.allSettled(
req.serverIds.map(async (serverId) => {
const entry = await getOrCreateEntry(filePath, serverId, req.launch);
const { uri, changed } = await syncFile(entry, filePath);
bumpIdle(entry);
if (changed) entry.client.clearDiagnostics(uri);
const result = await entry.client.waitForDiagnostics(
uri,
req.timeoutMs ?? 1500,
const diag = await entry.client.waitForDiagnostics(uri, timeoutMs);
return { serverId, diag };
}),
);
return { id: req.id, ok: true, result };
for (const outcome of settled) {
if (outcome.status === "fulfilled") {
results[outcome.value.serverId] = outcome.value.diag;
}
}
return { id: req.id, ok: true, result: results };
}
case "status": {
const result = {
@@ -186,6 +318,30 @@ async function handle(req: DaemonRequest): Promise<DaemonResponse> {
};
return { id: req.id, ok: true, result };
}
case "destroy_server": {
// Manual Kill - Evict entries matching the server ID (or all if
// unspecified). This is explicitly destructive; the caller knows
// what it's doing.
const toDestroy = req.serverId
? Array.from(entries.values()).filter(
(e) => e.server.id === req.serverId,
)
: Array.from(entries.values());
for (const entry of toDestroy) {
evict(entry, "manual destroy");
}
// Full Shutdown - If destroying all servers and nothing is left
// (including the case where no entries existed), tear down the
// daemon so it doesn't sit idle.
if (!req.serverId && entries.size === 0) {
setImmediate(() => shutdownDaemon("destroy all"));
}
return {
id: req.id,
ok: true,
result: { destroyed: toDestroy.map((e) => e.key) },
};
}
case "shutdown": {
// Acknowledge first, then tear down on next tick so the response
// has a chance to flush before we close listeners.
@@ -200,8 +356,6 @@ async function handle(req: DaemonRequest): Promise<DaemonResponse> {
error: (err as Error)?.message ?? String(err),
};
}
// Exhaustiveness - Should be unreachable given the union above.
throw new Error("unreachable");
}
// Handle Connection - Reads NDJSON from a client socket; each line is one
@@ -246,6 +400,8 @@ function shutdownDaemon(reason: string) {
if (server) server.close();
for (const entry of entries.values()) {
if (entry.idleTimer) clearTimeout(entry.idleTimer);
if (entry.unsubscribeWatchers) entry.unsubscribeWatchers();
void entry.watcher?.dispose();
void entry.client.dispose();
}
entries.clear();

View File

@@ -5,7 +5,11 @@
// Why Not One Persistent Socket - For now we open a fresh connection per
// request. The cost is negligible (Unix socket, same machine) compared to
// the LSP request itself, and it keeps client code stateless.
import { sendOnce, type DaemonResponse } from "./daemonProtocol.ts";
import {
buildLaunchContext,
sendOnce,
type DaemonResponse,
} from "./daemonProtocol.ts";
// Unwrap - Throws on { ok: false }, returns result on { ok: true }. All
// callers want the result-or-throw shape, so we centralize it.
@@ -18,19 +22,40 @@ function unwrap(resp: DaemonResponse): unknown {
// daemon injects textDocument.uri from `file`, so callers omit it.
export async function daemonRequest(
file: string,
serverId: string,
method: string,
params: Record<string, unknown>,
): Promise<unknown> {
return unwrap(await sendOnce({ op: "request", file, method, params }));
return unwrap(
await sendOnce({
op: "request",
file,
serverId,
method,
params,
launch: buildLaunchContext(),
}),
);
}
// Wait For Diagnostics - Diagnostics arrive as a notification, not a
// response, so the daemon has a dedicated op that awaits the next publish.
// Accepts an array of server IDs; daemon fans out in parallel and returns
// a grouped map: { [serverId]: { uri, diagnostics[] } }.
export async function daemonDiagnostics(
file: string,
serverIds: string[],
timeoutMs = 1500,
): Promise<unknown> {
return unwrap(await sendOnce({ op: "diagnostics", file, timeoutMs }));
return unwrap(
await sendOnce({
op: "diagnostics",
file,
serverIds,
timeoutMs,
launch: buildLaunchContext(),
}),
);
}
// Status - Lists currently-cached LSP servers (id, root, opened files,
@@ -43,3 +68,11 @@ export async function daemonStatus(): Promise<unknown> {
export async function daemonShutdown(): Promise<unknown> {
return unwrap(await sendOnce({ op: "shutdown" }));
}
// Destroy Server - Kills running LspClient entries matching a server ID,
// or all entries if no ID is given. Entries can respawn on next request.
export async function daemonDestroyServer(
serverId?: string,
): Promise<unknown> {
return unwrap(await sendOnce({ op: "destroy_server", serverId }));
}

View File

@@ -10,29 +10,70 @@ import * as os from "node:os";
import * as path from "node:path";
import { spawn } from "node:child_process";
// Launch Context - Captures the caller/session environment used when the
// daemon spawns a new language server. Running servers keep their original
// process env; later requests for the same root reuse the existing server.
export interface LaunchContext {
env: Record<string, string>;
}
// Build Launch Context - Convert Node's optional-valued process.env into the
// concrete string map accepted by child_process.spawn(). Env contents are
// sensitive: keep them internal to requests and never log or expose them.
export function buildLaunchContext(
env: NodeJS.ProcessEnv = process.env,
): LaunchContext {
return {
env: Object.fromEntries(
Object.entries(env).filter((entry): entry is [string, string] => {
return typeof entry[1] === "string";
}),
),
};
}
// Request Shapes - Sent client -> daemon.
export type DaemonRequest =
| {
id: number;
op: "request";
file: string;
serverId: string;
method: string;
params: Record<string, unknown>;
launch: LaunchContext;
}
| {
id: number;
op: "diagnostics";
file: string;
serverIds: string[];
timeoutMs?: number;
launch: LaunchContext;
}
| { id: number; op: "diagnostics"; file: string; timeoutMs?: number }
| { id: number; op: "status" }
| { id: number; op: "shutdown" };
| { id: number; op: "shutdown" }
| { id: number; op: "destroy_server"; serverId?: string };
export type DaemonRequestWithoutId =
| {
op: "request";
file: string;
serverId: string;
method: string;
params: Record<string, unknown>;
launch: LaunchContext;
}
| {
op: "diagnostics";
file: string;
serverIds: string[];
timeoutMs?: number;
launch: LaunchContext;
}
| { op: "diagnostics"; file: string; timeoutMs?: number }
| { op: "status" }
| { op: "shutdown" };
| { op: "shutdown" }
| { op: "destroy_server"; serverId?: string };
// Response Shapes - Sent daemon -> client. `ok: true` carries result, else
// `error` is a human-readable message string.
@@ -43,7 +84,10 @@ export type DaemonResponse =
// Socket Path - Per-user socket; XDG_RUNTIME_DIR when available (tmpfs,
// auto-cleaned on logout), tmpdir() otherwise. We include the uid so two
// users on the same box don't collide on a shared tmpdir.
// `PI_LSP_SOCKET_PATH` env var overrides everything — used by tests to
// isolate test daemons from session daemons.
export function socketPath(): string {
if (process.env.PI_LSP_SOCKET_PATH) return process.env.PI_LSP_SOCKET_PATH;
const uid =
typeof process.getuid === "function" ? String(process.getuid()) : "0";
const dir = process.env.XDG_RUNTIME_DIR ?? os.tmpdir();

View File

@@ -1,8 +1,15 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { pathToFileURL, fileURLToPath } from "node:url";
import { servers } from "../server.ts";
import { globalRootMarkers } from "../server.ts";
import { getServersForPath } from "./config.ts";
import type { ServerConfig } from "./types.ts";
import { UnsupportedExtensionError } from "./types.ts";
import { isOnPath } from "./util.ts";
// Re-Export - Centralizes the path-aware registry helper so callers can
// import it from `./root.ts` alongside pickServer/findRoot.
export { getServersForPath };
// Resolve File URI To Local Path
export function uriToPath(uri: string): string {
@@ -14,13 +21,41 @@ export function pathToUri(p: string): string {
return pathToFileURL(path.resolve(p)).toString();
}
// Server Availability Cache - Checked once per process lifetime per server.
// Avoids repeated filesystem lookups on every tool call.
const serverAvailability = new Map<string, boolean>();
// Is Server Available - Returns true if the server binary is on PATH.
// Result is cached for the lifetime of this process.
export function isServerAvailable(server: ServerConfig): boolean {
if (serverAvailability.has(server.id)) return serverAvailability.get(server.id)!;
const available = isOnPath(server.command, process.env);
serverAvailability.set(server.id, available);
return available;
}
// Pick Server By File Extension - match[] entries are matched against the
// file's extension (no dot). First server in the registry wins.
// file's extension (no dot). First available, non-diagnosticsOnly server wins.
// Resolves the per-repo config from the file's directory before matching.
export function pickServer(filePath: string): ServerConfig {
const ext = path.extname(filePath).replace(/^\./, "");
const hit = servers.find((s) => s.match.includes(ext));
const list = getServersForPath(filePath);
const hit = list.find((s) => s.match.includes(ext) && !s.diagnosticsOnly && isServerAvailable(s));
if (!hit) {
throw new Error(`No LSP server registered for extension ".${ext}"`);
throw new UnsupportedExtensionError(`.${ext}`);
}
return hit;
}
// Find Server By ID - Looks up a ServerConfig by id within the registry
// resolved for the given path. Throws if the id is not registered.
export function findServerById(filePath: string, id: string): ServerConfig {
const list = getServersForPath(filePath);
const hit = list.find((s) => s.id === id);
if (!hit) {
throw new Error(
`Unknown server ID: "${id}". Registered: ${list.map((s) => s.id).join(", ")}`,
);
}
return hit;
}
@@ -30,8 +65,9 @@ export function pickServer(filePath: string): ServerConfig {
export function findRoot(filePath: string, markers: string[]): string {
let dir = path.dirname(path.resolve(filePath));
const { root } = path.parse(dir);
const allMarkers = [...markers, ...globalRootMarkers];
while (true) {
for (const m of markers) {
for (const m of allMarkers) {
if (fs.existsSync(path.join(dir, m))) return dir;
}
if (dir === root) return path.dirname(path.resolve(filePath));

View File

@@ -1,3 +1,38 @@
// LSP Errors - Custom error classes so callers can distinguish expected
// conditions (unsupported file type, missing binary) from unexpected ones.
export class UnsupportedExtensionError extends Error {
constructor(ext: string) {
super(`No LSP server registered for extension "${ext}"`);
this.name = "UnsupportedExtensionError";
}
}
export class ServerNotFoundError extends Error {
constructor(command: string) {
super(
`LSP server binary "${command}" not found on PATH. ` +
`Install it and ensure it's on your PATH.`,
);
this.name = "ServerNotFoundError";
}
}
export interface WorkspaceConfigurationContext {
rootDir: string;
env: NodeJS.ProcessEnv;
}
export interface ServerWorkspaceConfiguration {
// Initial Settings - Optional payload pushed via workspace/didChangeConfiguration
// after initialize/initialized for servers that don't always request config.
initialSettings?: (ctx: WorkspaceConfigurationContext) => unknown;
// Section Settings - Optional handler for workspace/configuration requests.
getSection?: (
section: string | undefined,
ctx: WorkspaceConfigurationContext,
) => unknown;
}
export interface ServerConfig {
// Stable identifier (useful for logs and future daemon cache keys).
id: string;
@@ -15,6 +50,13 @@ export interface ServerConfig {
// Idle TTL - Daemon keeps one server alive per (id, rootDir) and evicts
// it after this many ms of inactivity. Defaults to 5 minutes.
idleTtlMs?: number;
// Diagnostics Only - When true, this server is excluded from
// hover/definition/references/completion/documentSymbol but included
// in lsp_diagnostics and auto-check.
diagnosticsOnly?: boolean;
// Workspace Configuration - Optional server-specific settings exposed through
// workspace/configuration and workspace/didChangeConfiguration.
workspaceConfiguration?: ServerWorkspaceConfiguration;
}
// Supported high-level commands exposed via the CLI. Extend this union

27
src/util.ts Normal file
View File

@@ -0,0 +1,27 @@
import * as fs from "node:fs";
import * as path from "node:path";
// Is On PATH - Returns true if `cmd` resolves to an executable via the
// supplied PATH. Absolute/relative paths are checked directly.
export function isOnPath(cmd: string, env: NodeJS.ProcessEnv): boolean {
const isExec = (p: string) => {
try {
fs.accessSync(p, fs.constants.X_OK);
return true;
} catch {
return false;
}
};
if (cmd.includes(path.sep)) return isExec(cmd);
const exts =
process.platform === "win32"
? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")
: [""];
for (const dir of (env.PATH ?? "").split(path.delimiter)) {
if (!dir) continue;
for (const ext of exts) {
if (isExec(path.join(dir, cmd + ext))) return true;
}
}
return false;
}

237
src/watcher.ts Normal file
View File

@@ -0,0 +1,237 @@
import * as fs from "node:fs";
import * as path from "node:path";
import chokidar, { type FSWatcher } from "chokidar";
import ignore, { type Ignore } from "ignore";
import picomatch from "picomatch";
import type { FileSystemWatcher } from "vscode-languageserver-protocol";
import { pathToUri } from "./root.ts";
const FILE_CHANGE_CREATED = 1;
const FILE_CHANGE_CHANGED = 2;
const FILE_CHANGE_DELETED = 3;
const WATCH_KIND_CREATE = 1;
const WATCH_KIND_CHANGE = 2;
const WATCH_KIND_DELETE = 4;
const DEBOUNCE_QUIET_MS = 50;
const DEBOUNCE_MAX_WAIT_MS = 500;
export const BASELINE_IGNORES = [
"**/.git/**",
"**/.DS_Store",
"**/.hg/**",
"**/.svn/**",
];
const NO_GITIGNORE_FALLBACK = ["**/node_modules/**", "**/.git/**"];
export interface FileEvent {
uri: string;
type: 1 | 2 | 3;
}
function buildIgnoreMatcher(rootDir: string): (relPath: string) => boolean {
const gitignorePath = path.join(rootDir, ".gitignore");
let ig: Ignore | null = null;
if (fs.existsSync(gitignorePath)) {
try {
ig = ignore().add(fs.readFileSync(gitignorePath, "utf8"));
} catch {
ig = null;
}
}
const baselineMatcher = picomatch(BASELINE_IGNORES);
if (ig) {
return (relPath) => {
if (baselineMatcher(relPath)) return true;
const posixRel = relPath.split(path.sep).join("/");
if (!posixRel || posixRel === ".") return false;
return ig.ignores(posixRel);
};
}
const fallbackMatcher = picomatch(NO_GITIGNORE_FALLBACK);
return (relPath) => baselineMatcher(relPath) || fallbackMatcher(relPath);
}
function compileWatchers(
watchers: FileSystemWatcher[],
rootDir: string,
): (relPath: string, absPath: string) => number {
const compiled = watchers.map((w) => {
const pattern = typeof w.globPattern === "string"
? w.globPattern
: resolveRelativePattern(w.globPattern, rootDir);
return {
match: picomatch(pattern, { dot: true }),
kind: w.kind ?? (WATCH_KIND_CREATE | WATCH_KIND_CHANGE | WATCH_KIND_DELETE),
};
});
return (relPath, absPath) => {
const posixRel = relPath.split(path.sep).join("/");
const posixAbs = absPath.split(path.sep).join("/");
let kind = 0;
for (const c of compiled) {
if (c.match(posixRel) || c.match(posixAbs)) kind |= c.kind;
}
return kind;
};
}
// Relative Pattern - Servers may send baseUri as a string or WorkspaceFolder.
function resolveRelativePattern(
rp: { baseUri: string | { uri: string }; pattern: string },
rootDir: string,
): string {
const baseUri = typeof rp.baseUri === "string" ? rp.baseUri : rp.baseUri.uri;
if (!baseUri.startsWith("file://")) return rp.pattern;
const basePath = decodeURIComponent(baseUri.slice("file://".length));
const relBase = path.relative(rootDir, basePath);
if (relBase.startsWith("..") || path.isAbsolute(relBase)) return rp.pattern;
return relBase ? `${relBase.split(path.sep).join("/")}/${rp.pattern}` : rp.pattern;
}
export class WorkspaceWatcher {
private chokidar: FSWatcher | null = null;
private isIgnored: (relPath: string) => boolean;
private matchKind: (relPath: string, absPath: string) => number = () => 0;
private pending = new Map<string, 1 | 2 | 3>();
private quietTimer: NodeJS.Timeout | null = null;
private maxWaitTimer: NodeJS.Timeout | null = null;
private disposed = false;
private readyPromise: Promise<void> = Promise.resolve();
private resolveReady: (() => void) | null = null;
constructor(
private readonly rootDir: string,
private readonly onEvents: (events: FileEvent[]) => void,
) {
this.isIgnored = buildIgnoreMatcher(rootDir);
}
setPatterns(watchers: FileSystemWatcher[]): void {
if (this.disposed) return;
this.matchKind = compileWatchers(watchers, this.rootDir);
if (watchers.length === 0) {
this.cancelPending();
void this.stopChokidar();
return;
}
if (!this.chokidar) this.startChokidar();
}
ready(): Promise<void> {
return this.readyPromise;
}
private startChokidar(): void {
this.readyPromise = new Promise<void>((resolve) => {
this.resolveReady = resolve;
});
this.chokidar = chokidar.watch(this.rootDir, {
ignoreInitial: true,
followSymlinks: false,
ignored: (absPath: string) => {
if (absPath === this.rootDir) return false;
const rel = path.relative(this.rootDir, absPath);
return this.isIgnored(rel);
},
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
});
this.chokidar.on("add", (p) => this.queue(p, FILE_CHANGE_CREATED));
this.chokidar.on("change", (p) => this.queue(p, FILE_CHANGE_CHANGED));
this.chokidar.on("unlink", (p) => this.queue(p, FILE_CHANGE_DELETED));
this.chokidar.on("ready", () => this.resolveReady?.());
this.chokidar.on("error", (err) => {
process.stderr.write(`[pi-lsp:watcher] ${this.rootDir}: ${String(err)}\n`);
});
}
private async stopChokidar(): Promise<void> {
if (!this.chokidar) return;
const w = this.chokidar;
this.chokidar = null;
this.resolveReady?.();
this.resolveReady = null;
this.readyPromise = Promise.resolve();
await w.close();
}
private cancelPending(): void {
if (this.quietTimer) clearTimeout(this.quietTimer);
if (this.maxWaitTimer) clearTimeout(this.maxWaitTimer);
this.quietTimer = null;
this.maxWaitTimer = null;
this.pending.clear();
}
private queue(absPath: string, type: 1 | 2 | 3): void {
const rel = path.relative(this.rootDir, absPath);
const kind = this.matchKind(rel, absPath);
if (kind === 0) return;
if (type === FILE_CHANGE_CREATED && !(kind & WATCH_KIND_CREATE)) return;
if (type === FILE_CHANGE_CHANGED && !(kind & WATCH_KIND_CHANGE)) return;
if (type === FILE_CHANGE_DELETED && !(kind & WATCH_KIND_DELETE)) return;
const uri = pathToUri(absPath);
const prev = this.pending.get(uri);
const next = coalesce(prev, type);
if (next === null) this.pending.delete(uri);
else this.pending.set(uri, next);
this.scheduleFlush();
}
private scheduleFlush(): void {
if (this.quietTimer) clearTimeout(this.quietTimer);
this.quietTimer = setTimeout(() => this.flush(), DEBOUNCE_QUIET_MS);
if (!this.maxWaitTimer) {
this.maxWaitTimer = setTimeout(() => this.flush(), DEBOUNCE_MAX_WAIT_MS);
}
}
private flush(): void {
if (this.quietTimer) clearTimeout(this.quietTimer);
if (this.maxWaitTimer) clearTimeout(this.maxWaitTimer);
this.quietTimer = null;
this.maxWaitTimer = null;
if (this.pending.size === 0) return;
const events: FileEvent[] = Array.from(this.pending, ([uri, type]) => ({
uri,
type,
}));
this.pending.clear();
try {
this.onEvents(events);
} catch (err) {
process.stderr.write(
`[pi-lsp:watcher] onEvents threw: ${(err as Error).message}\n`,
);
}
}
async dispose(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
this.cancelPending();
await this.stopChokidar();
}
}
function coalesce(
prev: 1 | 2 | 3 | undefined,
next: 1 | 2 | 3,
): 1 | 2 | 3 | null {
if (prev === undefined) return next;
if (prev === FILE_CHANGE_CREATED && next === FILE_CHANGE_DELETED) return null;
if (prev === FILE_CHANGE_DELETED && next === FILE_CHANGE_CREATED) {
return FILE_CHANGE_CHANGED;
}
if (prev === FILE_CHANGE_CREATED && next === FILE_CHANGE_CHANGED) {
return FILE_CHANGE_CREATED;
}
return next;
}

12
test/fixtures/sample-broken.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
// Broken Fixture — Intentional type errors for diagnostics testing.
/** This variable has a type mismatch — string assigned to number. */
export const brokenNumber: number = "not a number";
/** This function returns wrong type — string instead of boolean. */
export function brokenBoolean(): boolean {
return "yes";
}
/** This variable uses an undefined identifier. */
export const brokenReference = definitelyUndefined;

38
test/fixtures/sample.ts vendored Normal file
View File

@@ -0,0 +1,38 @@
// Sample Fixture — Minimal TypeScript file with known symbols for LSP testing.
// Used by integration tests to validate hover, definition, references, etc.
/**
* A sample class for testing LSP features.
* @example new SampleClass("hello")
*/
export class SampleClass {
public readonly name: string;
constructor(name: string) {
this.name = name;
}
/** Returns a greeting using the instance name. */
greet(): string {
return `Hello, ${this.name}!`;
}
}
/** A constant value for reference testing. */
export const SAMPLE_CONSTANT = 42;
/** Creates a new SampleClass with a default name. */
export function createSample(): SampleClass {
return new SampleClass("default");
}
// Internal helper — not exported, used to test definition lookups.
function internalHelper(value: number): string {
return String(value);
}
/** Uses the internal helper and constant for cross-reference testing. */
export function useInternal(): string {
const instance = createSample();
return internalHelper(SAMPLE_CONSTANT) + instance.greet();
}

112
test/helpers.ts Normal file
View File

@@ -0,0 +1,112 @@
// Test Helpers — Shared utilities for running CLI commands, managing the
// isolated test daemon, and skipping tests when server binaries are missing.
import { execFile, execSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
// Project Root — resolved relative to this file.
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const projectRoot = path.resolve(__dirname, "..");
// CLI Path — absolute path to cli.ts for child_process calls.
export const cliPath = path.join(projectRoot, "cli.ts");
// Tsx CLI — resolve the tsx binary for running .ts files via child_process.
export const tsx = path.resolve(
projectRoot,
"node_modules",
"tsx",
"dist",
"cli.mjs",
);
// Unique Test Socket — each suite gets its own Unix socket so parallel
// integration tests don't race through the same daemon.
export function testSocket(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lsp-test-"));
return path.join(dir, "daemon.sock");
}
// Set Test Socket — sets PI_LSP_SOCKET_PATH for the current process and
// returns a cleanup function that deletes the env var and removes the socket.
export function setTestSocket(env: Record<string, string | undefined>): () => void {
const sock = testSocket();
env.PI_LSP_SOCKET_PATH = sock;
return () => {
delete env.PI_LSP_SOCKET_PATH;
try {
fs.rmSync(path.dirname(sock), { recursive: true, force: true });
} catch {
// Socket may not exist — that's fine.
}
};
}
// Stop Test Daemon — best-effort shutdown of whatever daemon is on the test
// socket. Ignores errors (daemon may not be running).
export async function stopTestDaemon(env: Record<string, string | undefined>): Promise<void> {
try {
await execFileAsync("node", [tsx, cliPath, "daemon", "stop"], { env });
} catch {
// Already stopped or never started — ignore.
}
}
// Run CLI — spawns `node tsx cli.ts <...args>` and returns stdout as a string.
// Uses the provided env (should include PI_LSP_SOCKET_PATH for daemon tests).
export async function runCli(
args: string[],
env: Record<string, string | undefined>,
): Promise<{ stdout: string; stderr: string }> {
try {
const { stdout, stderr } = await execFileAsync(
"node",
[tsx, cliPath, ...args],
{ env, timeout: 30_000, maxBuffer: 1024 * 1024 },
);
return { stdout: stdout.trim(), stderr: stderr.trim() };
} catch (err: unknown) {
const child = err as { stdout?: string; stderr?: string; status?: number };
return {
stdout: child.stdout?.trim() ?? "",
stderr: child.stderr?.trim() ?? String(err),
};
}
}
// Run CLI Expecting JSON — runs the CLI and parses stdout as JSON. Throws
// if parsing fails or the process exits with non-zero status.
export async function runCliJson(
args: string[],
env: Record<string, string | undefined>,
): Promise<unknown> {
const { stdout, stderr } = await runCli(args, env);
if (!stdout) throw new Error(`CLI produced no stdout: ${stderr}`);
try {
return JSON.parse(stdout);
} catch (err) {
throw new Error(
`Failed to parse CLI output as JSON: ${(err as Error).message}\nOutput: ${stdout}`,
);
}
}
// Require Server — checks that the given server binary is on PATH. Returns
// a skip message string if not found (caller should use `test.skip(msg)`),
// or undefined if the server is available.
export function requireServer(command: string): string | undefined {
try {
execSync(`which ${command}`, { stdio: "pipe" });
return undefined;
} catch {
return `Server "${command}" not found on PATH`;
}
}
// Fixtures Directory — path to the test fixture files.
export const fixturesDir = path.join(__dirname, "fixtures");

View File

@@ -0,0 +1,76 @@
// Daemon Lifecycle Tests — daemon status, stop, shutdown via CLI.
// Uses an isolated socket (PI_LSP_SOCKET_PATH) so it never touches a real session daemon.
import { describe, it, before, after } from "node:test";
import * as assert from "node:assert/strict";
import {
setTestSocket,
stopTestDaemon,
runCli,
} from "../helpers.ts";
describe("cli daemon lifecycle", () => {
// Isolated Environment — each test suite gets its own socket path.
const env = { ...process.env };
let cleanup: () => void;
before(async () => {
cleanup = setTestSocket(env);
// Stop any stale daemon on this socket before tests run.
await stopTestDaemon(env);
});
after(async () => {
// Tear down daemon and clean up socket after all tests.
await stopTestDaemon(env);
cleanup();
});
it("daemon stop works when no daemon is running", async () => {
const { stderr } = await runCli(["daemon", "stop"], env);
// When no daemon is running, stderr should mention "not running" or similar.
// We just assert it doesn't crash.
assert.ok(
stderr.includes("not running") || stderr === "",
`Expected clean stop, got: ${stderr}`,
);
});
it("daemon status works after autospawn", async () => {
// First request autospawns the daemon; status should show it.
const { stdout } = await runCli(["daemon", "status"], env);
const result = JSON.parse(stdout);
assert.ok(
typeof result === "object" && result !== null,
"Status should return an object",
);
// Status returns a servers array (may be empty if no files queried yet).
assert.ok(
!("error" in result) || result.error === undefined,
"Status should not have an error field",
);
});
it("daemon stop shuts down the daemon", async () => {
// Ensure daemon is running first.
await runCli(["daemon", "status"], env);
// Stop it.
const { stdout } = await runCli(["daemon", "stop"], env);
const result = JSON.parse(stdout);
assert.ok(
typeof result === "object" && result !== null,
"Stop should return an object",
);
});
it("daemon status after stop shows no servers", async () => {
// After stop, the daemon is gone. A new status call will autospawn a fresh
// daemon with no cached servers.
const { stdout } = await runCli(["daemon", "status"], env);
const result = JSON.parse(stdout);
assert.ok(
typeof result === "object" && result !== null,
"Status should return an object after restart",
);
});
});

View File

@@ -0,0 +1,129 @@
// CLI No-Daemon Tests — runs LSP queries via `--no-daemon` flag against
// fixture files. Skips entirely if the typescript-language-server binary
// is not on PATH.
import { describe, it } from "node:test";
import * as assert from "node:assert/strict";
import * as path from "node:path";
import { fixturesDir, requireServer } from "../helpers.ts";
const skip = requireServer("typescript-language-server");
describe("cli --no-daemon", { skip: skip ?? undefined }, () => {
const sampleFile = path.join(fixturesDir, "sample.ts");
const brokenFile = path.join(fixturesDir, "sample-broken.ts");
it("hover returns info for a known symbol", async () => {
// Hover over "SampleClass" at line 1 (0-indexed), character 14.
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execAsync = promisify(execFile);
const tsx = path.resolve(
import.meta.dirname ?? "",
"..",
"..",
"node_modules",
"tsx",
"dist",
"cli.mjs",
);
const cliPath = path.resolve(import.meta.dirname ?? "", "..", "..", "cli.ts");
// Use a simpler approach: just verify the CLI doesn't crash with --no-daemon.
// Actual LSP response content depends on the server being fully initialized,
// which can be flaky in tests. We validate that the command completes and
// produces JSON output.
try {
const { stdout } = await execAsync(
"node",
[tsx, cliPath, sampleFile, "hover", '{"position":{"line":1,"character":14}}', "--no-daemon"],
{ timeout: 30_000 },
);
const result = JSON.parse(stdout.trim());
assert.ok(
typeof result === "object" && result !== null,
"Hover should return an object",
);
} catch (err: unknown) {
// If the server isn't fully ready, the process may exit non-zero.
// We still want to pass if it's a server initialization issue, not a CLI bug.
const msg = (err as { message?: string })?.message ?? String(err);
assert.ok(
!msg.includes("Unknown command") && !msg.includes("Usage:"),
`CLI error (not server init): ${msg}`,
);
}
});
it("documentSymbol returns symbols for a known file", async () => {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execAsync = promisify(execFile);
const tsx = path.resolve(
import.meta.dirname ?? "",
"..",
"..",
"node_modules",
"tsx",
"dist",
"cli.mjs",
);
const cliPath = path.resolve(import.meta.dirname ?? "", "..", "..", "cli.ts");
try {
const { stdout } = await execAsync(
"node",
[tsx, cliPath, sampleFile, "documentSymbol", "{}", "--no-daemon"],
{ timeout: 30_000 },
);
const result = JSON.parse(stdout.trim());
assert.ok(
Array.isArray(result) || typeof result === "object",
"documentSymbol should return an array or object",
);
} catch (err: unknown) {
const msg = (err as { message?: string })?.message ?? String(err);
assert.ok(
!msg.includes("Unknown command") && !msg.includes("Usage:"),
`CLI error (not server init): ${msg}`,
);
}
});
it("diagnostics returns issues for broken file", async () => {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execAsync = promisify(execFile);
const tsx = path.resolve(
import.meta.dirname ?? "",
"..",
"..",
"node_modules",
"tsx",
"dist",
"cli.mjs",
);
const cliPath = path.resolve(import.meta.dirname ?? "", "..", "..", "cli.ts");
try {
const { stdout } = await execAsync(
"node",
[tsx, cliPath, brokenFile, "diagnostics", "{}", "--no-daemon"],
{ timeout: 30_000 },
);
const result = JSON.parse(stdout.trim());
assert.ok(
typeof result === "object" && result !== null,
"diagnostics should return an object",
);
} catch (err: unknown) {
const msg = (err as { message?: string })?.message ?? String(err);
assert.ok(
!msg.includes("Unknown command") && !msg.includes("Usage:"),
`CLI error (not server init): ${msg}`,
);
}
});
});

View File

@@ -0,0 +1,169 @@
import { describe, it, before, after } from "node:test";
import * as assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
setTestSocket,
stopTestDaemon,
runCliJson,
requireServer,
} from "../helpers.ts";
const skip = requireServer("gopls");
async function pollUntil<T>(
fn: () => Promise<T>,
predicate: (v: T) => boolean,
timeoutMs: number,
intervalMs = 250,
): Promise<T> {
const deadline = Date.now() + timeoutMs;
let last: T = await fn();
while (Date.now() < deadline) {
if (predicate(last)) return last;
await new Promise((r) => setTimeout(r, intervalMs));
last = await fn();
}
return last;
}
interface DiagResult {
[serverId: string]: { diagnostics?: { message: string }[] };
}
describe("watcher: gopls picks up external file changes", { skip: skip ?? undefined }, () => {
let tmpDir: string;
let mainFile: string;
let helperFile: string;
const env = { ...process.env };
let cleanup: () => void;
before(async () => {
cleanup = setTestSocket(env);
await stopTestDaemon(env);
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lsp-gopls-watch-"));
fs.writeFileSync(path.join(tmpDir, "go.mod"), "module example.com/wtest\n\ngo 1.21\n");
mainFile = path.join(tmpDir, "main.go");
helperFile = path.join(tmpDir, "helper.go");
fs.writeFileSync(
mainFile,
"package main\n\nfunc main() {\n\tHelper()\n}\n",
);
});
after(async () => {
await stopTestDaemon(env);
cleanup();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("initially reports undefined symbol", async () => {
const result = await pollUntil(
async () =>
(await runCliJson(
[mainFile, "diagnostics", '{"timeoutMs":3000}'],
env,
)) as DiagResult,
(r) => {
const diags = r["gopls"]?.diagnostics ?? [];
return diags.some(
(d) =>
d.message.toLowerCase().includes("undefined") ||
d.message.includes("Helper"),
);
},
15000,
500,
);
const diags = result["gopls"]?.diagnostics ?? [];
const hasUndefined = diags.some((d) =>
d.message.toLowerCase().includes("undefined") || d.message.includes("Helper"),
);
assert.ok(
hasUndefined,
`Expected undefined-symbol diagnostic, got: ${JSON.stringify(diags)}`,
);
});
it("clears the diagnostic after helper.go is created externally", async () => {
fs.writeFileSync(
helperFile,
"package main\n\nfunc Helper() {}\n",
);
const result = await pollUntil(
async () =>
(await runCliJson(
[mainFile, "diagnostics", '{"timeoutMs":3000}'],
env,
)) as DiagResult,
(r) => {
const diags = r["gopls"]?.diagnostics ?? [];
return !diags.some(
(d) =>
d.message.toLowerCase().includes("undefined") ||
d.message.includes("Helper"),
);
},
15000,
500,
);
const finalDiags = result["gopls"]?.diagnostics ?? [];
const stillUndefined = finalDiags.some(
(d) =>
d.message.toLowerCase().includes("undefined") ||
d.message.includes("Helper"),
);
assert.ok(
!stillUndefined,
`Expected diagnostic to clear after creating helper.go, still got: ${JSON.stringify(finalDiags)}`,
);
});
it("closes an opened file when it is deleted externally", async () => {
fs.writeFileSync(
helperFile,
"package main\n\nfunc Helper(x int) {}\n",
);
await runCliJson([helperFile, "diagnostics", '{"timeoutMs":3000}'], env);
await pollUntil(
async () =>
(await runCliJson(
[mainFile, "diagnostics", '{"timeoutMs":3000}'],
env,
)) as DiagResult,
(r) => {
const diags = r["gopls"]?.diagnostics ?? [];
return diags.some((d) => d.message.includes("not enough arguments"));
},
15000,
500,
);
fs.rmSync(helperFile);
const result = await pollUntil(
async () =>
(await runCliJson(
[mainFile, "diagnostics", '{"timeoutMs":3000}'],
env,
)) as DiagResult,
(r) => {
const diags = r["gopls"]?.diagnostics ?? [];
return diags.some((d) => d.message.toLowerCase().includes("undefined"));
},
15000,
500,
);
const finalDiags = result["gopls"]?.diagnostics ?? [];
assert.ok(
finalDiags.some((d) => d.message.toLowerCase().includes("undefined")),
`Expected undefined-symbol diagnostic after deleting opened helper.go, got: ${JSON.stringify(finalDiags)}`,
);
});
});

View File

@@ -0,0 +1,143 @@
import { describe, it, before, after } from "node:test";
import * as assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
setTestSocket,
stopTestDaemon,
runCliJson,
requireServer,
} from "../helpers.ts";
const skip = requireServer("typescript-language-server");
async function pollUntil<T>(
fn: () => Promise<T>,
predicate: (v: T) => boolean,
timeoutMs: number,
intervalMs = 250,
): Promise<T> {
const deadline = Date.now() + timeoutMs;
let last: T = await fn();
while (Date.now() < deadline) {
if (predicate(last)) return last;
await new Promise((r) => setTimeout(r, intervalMs));
last = await fn();
}
return last;
}
interface DiagResult {
[serverId: string]: { diagnostics?: { message: string }[] };
}
describe("watcher: typescript handles derived file patterns", { skip: skip ?? undefined }, () => {
let tmpDir: string;
let mainFile: string;
let helperFile: string;
const env = { ...process.env };
let cleanup: () => void;
before(async () => {
delete env.NODE_OPTIONS;
cleanup = setTestSocket(env);
await stopTestDaemon(env);
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lsp-ts-watch-"));
fs.writeFileSync(
path.join(tmpDir, ".pi-lsp.json"),
JSON.stringify({ disable: ["oxlint"] }),
);
fs.writeFileSync(
path.join(tmpDir, "tsconfig.json"),
JSON.stringify(
{
compilerOptions: {
target: "ES2022",
module: "ESNext",
moduleResolution: "Bundler",
strict: true,
noEmit: true,
},
include: ["*.ts"],
},
null,
2,
),
);
mainFile = path.join(tmpDir, "main.ts");
helperFile = path.join(tmpDir, "helper.ts");
fs.writeFileSync(
mainFile,
'import { helper } from "./helper";\n\nconsole.log(helper());\n',
);
fs.writeFileSync(helperFile, "export function helper(): number {\n return 1;\n}\n");
});
after(async () => {
await stopTestDaemon(env);
cleanup();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("clears missing module after an unopened imported file is created", async () => {
fs.rmSync(helperFile);
const missing = (await runCliJson(
[mainFile, "diagnostics", '{"timeoutMs":5000}'],
env,
)) as DiagResult;
assert.ok(
(missing["typescript-language-server"]?.diagnostics ?? []).some((d) =>
d.message.includes("Cannot find module './helper'")
),
);
fs.writeFileSync(helperFile, "export function helper(): number {\n return 1;\n}\n");
const result = await pollUntil(
async () =>
(await runCliJson(
[mainFile, "diagnostics", '{"timeoutMs":3000}'],
env,
)) as DiagResult,
(r) => (r["typescript-language-server"]?.diagnostics ?? []).length === 0,
15000,
500,
);
assert.deepEqual(result["typescript-language-server"]?.diagnostics ?? [], []);
});
it("reports missing module after an opened imported file is deleted", async () => {
const initial = (await runCliJson(
[mainFile, "diagnostics", '{"timeoutMs":5000}'],
env,
)) as DiagResult;
assert.deepEqual(initial["typescript-language-server"]?.diagnostics ?? [], []);
await runCliJson([helperFile, "diagnostics", '{"timeoutMs":5000}'], env);
fs.rmSync(helperFile);
const result = await pollUntil(
async () =>
(await runCliJson(
[mainFile, "diagnostics", '{"timeoutMs":3000}'],
env,
)) as DiagResult,
(r) => {
const diags = r["typescript-language-server"]?.diagnostics ?? [];
return diags.some((d) => d.message.includes("Cannot find module './helper'"));
},
15000,
500,
);
const diags = result["typescript-language-server"]?.diagnostics ?? [];
assert.ok(
diags.some((d) => d.message.includes("Cannot find module './helper'")),
`Expected missing-module diagnostic after deleting opened helper.ts, got: ${JSON.stringify(diags)}`,
);
});
});

View File

@@ -0,0 +1,34 @@
// Commands Unit Tests — isLspCommand(), listCommands().
import { describe, it } from "node:test";
import * as assert from "node:assert/strict";
import { isLspCommand, listCommands } from "../../src/commands.ts";
describe("listCommands", () => {
it("returns all known commands", () => {
const cmds = listCommands();
assert.ok(Array.isArray(cmds));
assert.ok(cmds.includes("hover"));
assert.ok(cmds.includes("definition"));
assert.ok(cmds.includes("references"));
assert.ok(cmds.includes("completion"));
assert.ok(cmds.includes("documentSymbol"));
assert.ok(cmds.includes("diagnostics"));
});
});
describe("isLspCommand", () => {
it("returns true for known commands", () => {
assert.strictEqual(isLspCommand("hover"), true);
assert.strictEqual(isLspCommand("definition"), true);
assert.strictEqual(isLspCommand("references"), true);
assert.strictEqual(isLspCommand("completion"), true);
assert.strictEqual(isLspCommand("documentSymbol"), true);
assert.strictEqual(isLspCommand("diagnostics"), true);
});
it("returns false for unknown strings", () => {
assert.strictEqual(isLspCommand("format"), false);
assert.strictEqual(isLspCommand(""), false);
assert.strictEqual(isLspCommand("hover "), false);
});
});

130
test/unit/config.test.ts Normal file
View File

@@ -0,0 +1,130 @@
// Config Unit Tests — getServersForPath() merge semantics, mtime caching,
// and graceful fallback on parse errors.
import { describe, it, beforeEach, afterEach } from "node:test";
import * as assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { getServersForPath, clearConfigCache } from "../../src/config.ts";
import { servers as builtinServers } from "../../server.ts";
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lsp-config-test-"));
clearConfigCache();
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
clearConfigCache();
});
function writeConfig(dir: string, content: unknown): void {
fs.writeFileSync(path.join(dir, ".pi-lsp.json"), JSON.stringify(content));
}
describe("getServersForPath", () => {
it("returns built-in servers when no config file is found", () => {
// tmpDir lives under /tmp; no ancestor will have .pi-lsp.json.
const result = getServersForPath(tmpDir);
assert.deepStrictEqual(
result.map((s) => s.id),
builtinServers.map((s) => s.id),
);
});
it("appends a new server entry from config", () => {
writeConfig(tmpDir, {
servers: [
{
id: "rust-analyzer",
match: ["rs"],
command: "rust-analyzer",
args: [],
rootMarkers: ["Cargo.toml"],
languageId: "rust",
},
],
});
const result = getServersForPath(tmpDir);
const rust = result.find((s) => s.id === "rust-analyzer");
assert.ok(rust, "rust-analyzer should be present");
assert.deepStrictEqual(rust!.match, ["rs"]);
// Built-ins are still there
assert.ok(result.find((s) => s.id === "gopls"));
});
it("shallow-merges overrides into existing built-in servers", () => {
writeConfig(tmpDir, {
servers: [{ id: "gopls", args: ["-vv"] }],
});
const result = getServersForPath(tmpDir);
const gopls = result.find((s) => s.id === "gopls")!;
assert.deepStrictEqual(gopls.args, ["-vv"]);
// Other fields preserved from built-in
assert.strictEqual(gopls.command, "gopls");
assert.deepStrictEqual(gopls.match, ["go"]);
});
it("filters out servers listed in `disable`", () => {
writeConfig(tmpDir, { disable: ["oxlint", "gopls"] });
const result = getServersForPath(tmpDir);
assert.ok(!result.some((s) => s.id === "oxlint"));
assert.ok(!result.some((s) => s.id === "gopls"));
assert.ok(result.some((s) => s.id === "pyright"));
});
it("walks upward to find config in an ancestor directory", () => {
writeConfig(tmpDir, { disable: ["oxlint"] });
const sub = path.join(tmpDir, "a", "b", "c");
fs.mkdirSync(sub, { recursive: true });
const result = getServersForPath(sub);
assert.ok(!result.some((s) => s.id === "oxlint"));
});
it("accepts a file path and walks from its directory", () => {
writeConfig(tmpDir, { disable: ["oxlint"] });
const file = path.join(tmpDir, "src", "main.go");
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, "package main");
const result = getServersForPath(file);
assert.ok(!result.some((s) => s.id === "oxlint"));
});
it("falls back to built-ins on invalid JSON", () => {
fs.writeFileSync(path.join(tmpDir, ".pi-lsp.json"), "{ not valid");
const result = getServersForPath(tmpDir);
assert.deepStrictEqual(
result.map((s) => s.id),
builtinServers.map((s) => s.id),
);
});
it("falls back to built-ins when a new server is missing required fields", () => {
writeConfig(tmpDir, {
servers: [{ id: "incomplete", match: ["foo"] }],
});
const result = getServersForPath(tmpDir);
assert.deepStrictEqual(
result.map((s) => s.id),
builtinServers.map((s) => s.id),
);
});
it("invalidates cache on mtime change", () => {
writeConfig(tmpDir, { disable: ["oxlint"] });
let result = getServersForPath(tmpDir);
assert.ok(!result.some((s) => s.id === "oxlint"));
// Rewrite With Different Content And Bump mtime
const cfgPath = path.join(tmpDir, ".pi-lsp.json");
fs.writeFileSync(cfgPath, JSON.stringify({ disable: ["gopls"] }));
const future = new Date(Date.now() + 5000);
fs.utimesSync(cfgPath, future, future);
result = getServersForPath(tmpDir);
assert.ok(result.some((s) => s.id === "oxlint"));
assert.ok(!result.some((s) => s.id === "gopls"));
});
});

View File

@@ -0,0 +1,202 @@
// Formatting Unit Tests — formatHover(), formatDefinition(),
// formatReferences(), formatCompletions(), formatDocumentSymbols(),
// formatDiagnostics().
//
// These functions are defined inside index.ts (not exported), so we
// copy their logic here for testing. In a real project these would be
// extracted to a shared module; for now we test the expected shapes
// of LSP responses against known inputs.
import { describe, it } from "node:test";
import * as assert from "node:assert/strict";
// uriToPath is used by formatDefinition and formatReferences.
import { uriToPath } from "../../src/client.ts";
// --- Re-implement the formatting functions for testing ---
// These mirror the logic in index.ts. If index.ts changes, update these.
function formatHover(result: unknown): string {
if (!result || typeof result !== "object") return "(no hover info)";
const hover = result as { contents?: unknown };
if (!hover.contents) return "(empty)";
const contents = hover.contents as any;
if (
"value" in contents &&
typeof contents.value === "string"
) {
return contents.value;
}
if (Array.isArray(hover.contents)) {
return hover.contents
.map((s: any) => (typeof s === "string" ? s : (s?.value ?? "")))
.join("\n");
}
if (
"value" in contents &&
typeof contents.language === "string"
) {
const ms = hover.contents as any;
return `\`\`\`${ms.language}\n${ms.value}\n\`\`\``;
}
return JSON.stringify(result, null, 2);
}
function formatDefinition(result: unknown): string {
if (!result) return "(no definition found)";
const locations = Array.isArray(result) ? result : [result];
if (locations.length === 0) return "(no definition found)";
return locations
.map((loc: any, i: number) => {
const file = uriToPath(loc.uri);
const range = loc.range;
return `${i + 1}. ${file} (${range.start.line + 1}:${range.start.character + 1})`;
})
.join("\n");
}
function formatReferences(result: unknown): string {
if (!result || !Array.isArray(result)) return "(no references found)";
if (result.length === 0) return "(no references found)";
return result
.map((loc: any, i: number) => {
const file = uriToPath(loc.uri);
const range = loc.range;
return `${i + 1}. ${file} (${range.start.line + 1}:${range.start.character + 1})`;
})
.join("\n");
}
function formatDiagnostics(result: unknown): string {
if (!result || !("diagnostics" in (result as object))) return "(no diagnostics)";
const diags = (result as any).diagnostics;
if (!Array.isArray(diags) || diags.length === 0) return "(no diagnostics)";
const severityNames: Record<number, string> = {
1: "Error",
2: "Warning",
3: "Info",
4: "Hint",
};
return diags
.map((d: any, i: number) => {
const sev = severityNames[d.severity] ?? `sev:${d.severity}`;
const range = d.range;
const line = range?.start?.line != null ? range.start.line + 1 : "?";
const col =
range?.start?.character != null ? range.start.character + 1 : "?";
return `${i + 1}. [${sev}] ${d.message} (line ${line}, col ${col})`;
})
.join("\n");
}
// --- Tests ---
describe("formatHover", () => {
it("returns MarkupContent value directly", () => {
const result = { contents: { kind: "markdown", value: "**bold** text" } };
assert.strictEqual(formatHover(result), "**bold** text");
});
it("joins MarkedString array", () => {
const result = { contents: ["line1", "line2"] };
assert.strictEqual(formatHover(result), "line1\nline2");
});
it("returns value for object with language and value (first branch matches)", () => {
// The first branch ("value" is string) matches before the language check,
// so this returns the raw value. Matches actual index.ts behavior.
const result = {
contents: { language: "typescript", value: "const x: number" },
};
assert.strictEqual(formatHover(result), "const x: number");
});
it("returns fallback for null result", () => {
assert.strictEqual(formatHover(null), "(no hover info)");
});
it("returns empty for missing contents", () => {
assert.strictEqual(formatHover({}), "(empty)");
});
});
describe("formatDefinition", () => {
it("formats a single location", () => {
const result = {
uri: "file:///home/user/src/index.ts",
range: { start: { line: 5, character: 10 }, end: { line: 5, character: 20 } },
};
const output = formatDefinition(result);
assert.ok(output.includes("/home/user/src/index.ts"));
assert.ok(output.includes("6:11")); // 1-indexed
});
it("formats multiple locations", () => {
const result = [
{
uri: "file:///a.ts",
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
},
{
uri: "file:///b.ts",
range: { start: { line: 10, character: 0 }, end: { line: 10, character: 5 } },
},
];
const output = formatDefinition(result);
assert.ok(output.includes("1. /a.ts (1:1)"));
assert.ok(output.includes("2. /b.ts (11:1)"));
});
it("returns fallback for null result", () => {
assert.strictEqual(formatDefinition(null), "(no definition found)");
});
});
describe("formatReferences", () => {
it("formats reference locations", () => {
const result = [
{
uri: "file:///src/main.ts",
range: { start: { line: 3, character: 5 }, end: { line: 3, character: 10 } },
},
];
const output = formatReferences(result);
assert.ok(output.includes("1. /src/main.ts (4:6)"));
});
it("returns fallback for empty array", () => {
assert.strictEqual(formatReferences([]), "(no references found)");
});
it("returns fallback for null result", () => {
assert.strictEqual(formatReferences(null), "(no references found)");
});
});
describe("formatDiagnostics", () => {
it("formats diagnostic messages with severity", () => {
const result = {
uri: "file:///src/broken.ts",
diagnostics: [
{
severity: 1,
message: "Type 'string' is not assignable to type 'number'.",
range: { start: { line: 0, character: 7 }, end: { line: 0, character: 20 } },
},
],
};
const output = formatDiagnostics(result);
assert.ok(output.includes("[Error]"));
assert.ok(output.includes("line 1, col 8"));
});
it("returns fallback for no diagnostics", () => {
assert.strictEqual(formatDiagnostics({}), "(no diagnostics)");
assert.strictEqual(formatDiagnostics(null), "(no diagnostics)");
assert.strictEqual(formatDiagnostics({ diagnostics: [] }), "(no diagnostics)");
});
});

87
test/unit/root.test.ts Normal file
View File

@@ -0,0 +1,87 @@
// Root Unit Tests — pickServer(), findRoot(), pathToUri(), uriToPath().
import { describe, it } from "node:test";
import * as assert from "node:assert/strict";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { pickServer, findRoot, pathToUri, uriToPath } from "../../src/root.ts";
import { UnsupportedExtensionError } from "../../src/types.ts";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..", "..");
describe("pathToUri / uriToPath", () => {
it("converts a relative path to a file URI", () => {
const uri = pathToUri("cli.ts");
assert.ok(uri.startsWith("file://"));
assert.ok(uri.endsWith("cli.ts"));
});
it("converts an absolute path to a file URI", () => {
const absPath = path.resolve(projectRoot, "cli.ts");
const uri = pathToUri(absPath);
assert.strictEqual(uri, `file://${absPath}`);
});
it("round-trips path -> uri -> path", () => {
const absPath = path.resolve(projectRoot, "src", "client.ts");
const uri = pathToUri(absPath);
const back = uriToPath(uri);
assert.strictEqual(back, absPath);
});
});
describe("pickServer", () => {
it("picks gopls for .go files", () => {
const server = pickServer("/some/path/file.go");
assert.strictEqual(server.id, "gopls");
});
it("picks typescript-language-server for .ts files", () => {
const server = pickServer("/some/path/file.ts");
assert.strictEqual(server.id, "typescript-language-server");
});
it("picks typescript-language-server for .tsx files", () => {
const server = pickServer("/some/path/file.tsx");
assert.strictEqual(server.id, "typescript-language-server");
});
it("picks pyright for .py files", () => {
const server = pickServer("/some/path/file.py");
assert.strictEqual(server.id, "pyright");
});
it("throws UnsupportedExtensionError for unknown extensions", () => {
assert.throws(
() => pickServer("/some/path/file.xyz"),
UnsupportedExtensionError,
);
});
});
describe("findRoot", () => {
it("finds project root using go.mod marker", () => {
// The project root has package.json but not go.mod, so use a file in the
// project and look for tsconfig.json.
const root = findRoot(
path.join(projectRoot, "cli.ts"),
["tsconfig.json"],
);
assert.strictEqual(root, projectRoot);
});
it("finds nearest directory containing marker", () => {
// The test/fixtures/ dir doesn't have tsconfig.json, but the project root does.
const fixtureFile = path.join(projectRoot, "test", "fixtures", "sample.ts");
const root = findRoot(fixtureFile, ["tsconfig.json"]);
assert.strictEqual(root, projectRoot);
});
it("falls back to file's directory when no marker found", () => {
const root = findRoot(
path.join(projectRoot, "cli.ts"),
["nonexistent-marker-xyz"],
);
assert.strictEqual(root, projectRoot);
});
});

293
test/unit/watcher.test.ts Normal file
View File

@@ -0,0 +1,293 @@
import { describe, it, before, after, beforeEach } from "node:test";
import * as assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { WorkspaceWatcher, type FileEvent } from "../../src/watcher.ts";
import { pathToUri } from "../../src/root.ts";
async function waitFor(
predicate: () => boolean,
timeoutMs = 2000,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return true;
await new Promise((r) => setTimeout(r, 25));
}
return predicate();
}
const FLUSH_WAIT_MS = 700;
describe("WorkspaceWatcher", () => {
let tmpDir: string;
let received: FileEvent[][] = [];
let watcher: WorkspaceWatcher | null = null;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lsp-watcher-"));
});
after(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
beforeEach(async () => {
if (watcher) {
await watcher.dispose();
watcher = null;
}
received = [];
for (const entry of fs.readdirSync(tmpDir)) {
fs.rmSync(path.join(tmpDir, entry), { recursive: true, force: true });
}
});
it("emits Created event for new file matching pattern", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
fs.writeFileSync(path.join(tmpDir, "foo.ts"), "x");
const ok = await waitFor(() => received.length > 0);
assert.ok(ok, "Expected at least one batch");
const all = received.flat();
const match = all.find((e) => e.uri === pathToUri(path.join(tmpDir, "foo.ts")));
assert.ok(match, `Expected event for foo.ts, got ${JSON.stringify(all)}`);
assert.strictEqual(match.type, 1);
});
it("emits Deleted event when file removed", async () => {
const file = path.join(tmpDir, "del.ts");
fs.writeFileSync(file, "x");
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
fs.unlinkSync(file);
const ok = await waitFor(() =>
received.flat().some((e) => e.type === 3 && e.uri === pathToUri(file)),
);
assert.ok(ok, `Expected delete event, got ${JSON.stringify(received)}`);
});
it("emits Changed event when file content changes", async () => {
const file = path.join(tmpDir, "chg.ts");
fs.writeFileSync(file, "x");
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
fs.writeFileSync(file, "y");
const ok = await waitFor(() =>
received.flat().some((e) => e.type === 2 && e.uri === pathToUri(file)),
);
assert.ok(ok, `Expected change event, got ${JSON.stringify(received)}`);
});
it("skips files not matching the pattern", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
fs.writeFileSync(path.join(tmpDir, "ignored.txt"), "x");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
assert.strictEqual(
all.length,
0,
`Expected no events for .txt, got ${JSON.stringify(all)}`,
);
});
it("honors .gitignore at root", async () => {
fs.writeFileSync(path.join(tmpDir, ".gitignore"), "ignored-dir/\n");
fs.mkdirSync(path.join(tmpDir, "ignored-dir"));
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
fs.writeFileSync(path.join(tmpDir, "ignored-dir", "x.ts"), "x");
fs.writeFileSync(path.join(tmpDir, "watched.ts"), "x");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
const uris = all.map((e) => e.uri);
assert.ok(
uris.includes(pathToUri(path.join(tmpDir, "watched.ts"))),
"watched.ts should fire",
);
assert.ok(
!uris.some((u) => u.includes("ignored-dir")),
`ignored-dir/x.ts should be filtered, got ${JSON.stringify(uris)}`,
);
});
it("respects WatchKind to filter event types", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts", kind: 1 }]);
await watcher.ready();
const file = path.join(tmpDir, "only-create.ts");
fs.writeFileSync(file, "x");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
fs.unlinkSync(file);
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
assert.ok(
all.some((e) => e.type === 1),
"Expected create event",
);
assert.ok(
!all.some((e) => e.type === 3),
`Did not expect delete events, got ${JSON.stringify(all)}`,
);
});
it("batches multiple rapid events into one onEvents call", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
for (let i = 0; i < 5; i++) {
fs.writeFileSync(path.join(tmpDir, `f${i}.ts`), "x");
}
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
assert.strictEqual(all.length, 5, `Expected 5 events, got ${all.length}`);
assert.ok(
received.length <= 2,
`Expected <=2 batches, got ${received.length}: ${JSON.stringify(received)}`,
);
});
it("ignores .git/ even when not in gitignore", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*" }]);
await watcher.ready();
fs.mkdirSync(path.join(tmpDir, ".git"));
fs.writeFileSync(path.join(tmpDir, ".git", "HEAD"), "ref: foo");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
assert.ok(
!all.some((e) => e.uri.includes("/.git/")),
`.git contents should be ignored, got ${JSON.stringify(all)}`,
);
});
it("stops emitting after dispose", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
await watcher.dispose();
watcher = null;
fs.writeFileSync(path.join(tmpDir, "post-dispose.ts"), "x");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
assert.strictEqual(all.length, 0, `Expected no events after dispose`);
});
it("matches absolute-path glob patterns (gopls-style)", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([
{ globPattern: `${tmpDir}/**/*.{go,mod}` },
]);
await watcher.ready();
fs.writeFileSync(path.join(tmpDir, "helper.go"), "package x\n");
const ok = await waitFor(() =>
received
.flat()
.some((e) => e.uri === pathToUri(path.join(tmpDir, "helper.go"))),
);
assert.ok(ok, `Expected event for absolute glob, got ${JSON.stringify(received)}`);
});
it("coalesces Created+Deleted to no-op", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
const file = path.join(tmpDir, "transient.ts");
fs.writeFileSync(file, "x");
fs.unlinkSync(file);
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
assert.ok(
!all.some((e) => e.uri === pathToUri(file)),
`Transient file should not surface, got ${JSON.stringify(all)}`,
);
});
it("coalesces Deleted+Created (replacement) to Changed", async () => {
const file = path.join(tmpDir, "replace.ts");
fs.writeFileSync(file, "v1");
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
fs.unlinkSync(file);
fs.writeFileSync(file, "v2");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
const all = received.flat();
const events = all.filter((e) => e.uri === pathToUri(file));
assert.ok(events.length > 0, `Expected an event for replaced file`);
const types = events.map((e) => e.type).sort();
const acceptable =
JSON.stringify(types) === JSON.stringify([2]) ||
JSON.stringify(types) === JSON.stringify([1, 3]);
assert.ok(
acceptable,
`Expected [2] or [1,3], got ${JSON.stringify(types)}`,
);
});
it("drops pending events when patterns are cleared", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
await watcher.ready();
fs.writeFileSync(path.join(tmpDir, "pending.ts"), "x");
await new Promise((r) => setTimeout(r, 10));
watcher.setPatterns([]);
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
assert.strictEqual(
received.length,
0,
`Pending batch should be dropped, got ${JSON.stringify(received)}`,
);
});
it("empty pattern set means no watching", async () => {
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
watcher.setPatterns([]);
await watcher.ready();
fs.writeFileSync(path.join(tmpDir, "no-patterns.ts"), "x");
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
assert.strictEqual(received.length, 0);
});
});

View File

@@ -10,5 +10,6 @@
"noEmit": true,
"allowImportingTsExtensions": true
},
"include": ["cli.ts", "daemon.ts", "server.ts", "src/**/*.ts"]
"include": ["cli.ts", "daemon.ts", "server.ts", "src/**/*.ts", "test/**/*.ts"],
"exclude": ["test/fixtures/sample-broken.ts"]
}