From 17b4a5f43398beadf08525bfe3330c6ccb6083d0 Mon Sep 17 00:00:00 2001 From: Agent Date: Sat, 10 Oct 2026 19:15:23 +0000 Subject: [PATCH] Initial commit: omp-lsp DSH plugin (oh-my-pi port) with docs and tests --- .gitignore | 4 + README.md | 104 ++++++++++++++ cordis.patch.yml | 11 ++ index.js | 338 ++++++++++++++++++++++++++++++++++++++++++++++ lib/client.js | 239 ++++++++++++++++++++++++++++++++ lib/format.js | 108 +++++++++++++++ lib/servers.js | 148 ++++++++++++++++++++ locale/en.json | 1 + locale/zh.json | 1 + package.json | 16 +++ test/lsp.test.mjs | 121 +++++++++++++++++ 11 files changed, 1091 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cordis.patch.yml create mode 100644 index.js create mode 100644 lib/client.js create mode 100644 lib/format.js create mode 100644 lib/servers.js create mode 100644 locale/en.json create mode 100644 locale/zh.json create mode 100644 package.json create mode 100644 test/lsp.test.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb38758 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.bak +*.orig +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae4220e --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# omp-lsp + +[oh-my-pi](https://github.com/can1357/oh-my-pi)-style language-server integration for the DeepSeek Harness (DSH). + +- **Diagnostics writethrough:** after every successful `edit` or `write` (DSH's built-ins or [omp-edit](https://git.rizzy.dev/DSH-Plugins/omp-edit)), the touched files are synced to their language server. Fresh errors and warnings are appended to the tool result, so the agent sees what it just broke: + ``` + The file src/a.ts has been updated successfully. + + LSP diagnostics after this edit (1 error, 0 warnings): + src/a.ts:1:7 error [2322]: Type 'string' is not assignable to type 'number'. (typescript) + ``` +- **`lsp` agent tool:** + +| Action | Inputs | Result | +| --- | --- | --- | +| `diagnostics` | `file` (path or glob) | All diagnostics with counts | +| `definition` / `type_definition` / `implementation` / `references` | `file`, `line`, `symbol` | `path:line:col: source line` list | +| `hover` | `file`, `line`, `symbol` | Type/doc text | +| `symbols` | `file`, or `file="*"` + `query` | Document outline / workspace symbol search | +| `rename` | `file`, `line`, `symbol`, `new_name`, `apply` | Applies the rename across the workspace (`apply=false` previews) | +| `rename_file` | `file`, `new_name`, `apply` | Moves the file and updates imports when the server supports it | +| `code_actions` | `file`, `line`, `query`, `apply` | Lists actions; `apply=true` applies the first match | +| `status` / `reload` / `capabilities` | — | Installed/running servers, restart, server capabilities | +| `request` | `file`, `query` (method), `payload` (JSON) | Raw LSP request | + +`symbol` locates the column on the line: bare identifiers match on word boundaries, other text as a substring, and `name#2` picks the 2nd occurrence. + +## Language servers + +They're auto-detected on `PATH`, `~/.local/bin` and the project's `node_modules/.bin`. There's one client per (server, project root), found from the nearest root marker, and idle servers stop after `idleMinutes`. + +| id | Command | Files | Install | +| --- | --- | --- | --- | +| typescript | `typescript-language-server` | ts tsx js jsx mjs cjs | `npm i -g typescript-language-server typescript@5` | +| pyright | `pyright-langserver` | py pyi | `npm i -g pyright` | +| gopls | `gopls` | go | `go install golang.org/x/tools/gopls@latest` | +| rust-analyzer | `rust-analyzer` | rs | `rustup component add rust-analyzer` | +| clangd | `clangd` | c h cc cpp hpp | distro package `clangd` | +| bash | `bash-language-server` | sh bash zsh | `npm i -g bash-language-server` | +| json / css / html | `vscode-*-language-server` | json, css/scss/less, html | `npm i -g vscode-langservers-extracted` | +| yaml | `yaml-language-server` | yaml yml | `npm i -g yaml-language-server` | + +Without root access, install the npm ones into `~/.local`: + +```bash +npm i -g --prefix ~/.local typescript-language-server typescript@5 pyright bash-language-server yaml-language-server vscode-langservers-extracted +``` + +TypeScript 7 (the Go port) ships no `tsserver.js`, so keep `typescript@5` for the language server. If a project has no local `typescript` dependency, the plugin points the server at the global install. + +## Installation + +```bash +dsh plugin --profile web add git+https://git.rizzy.dev/DSH-Plugins/omp-lsp.git +``` + +Or go to **Settings → Plugins → Install** with the same URL, or ask the agent to use `plugin_manager` → `install_bundle`. + +For development: + +```bash +git clone https://git.rizzy.dev/DSH-Plugins/omp-lsp.git ~/dsh-plugins/omp-lsp +dsh plugin --profile web add link:$HOME/dsh-plugins/omp-lsp +``` + +Then restart DSH (`systemctl --user restart dsh` or re-run `dsh web`), and check with `lsp action=status` in a new session. + +## Configuration + +Set options in `config` of the `omp-lsp` row in `~/.dsh/profiles/web/cordis.patch.yml`: + +```yaml +- id: omp-lsp + config: + writethrough: true # append diagnostics to edit/write results + writethroughTools: [edit, write] + minSeverity: 2 # 1 error, 2 +warnings, 3 +info, 4 +hints + budgetMs: 4000 # max wait for fresh diagnostics per write + maxDiagnostics: 25 + idleMinutes: 10 + disabled: [html] # server ids to turn off + servers: # override or add servers + lua: + command: lua-language-server + args: [] + extensions: [.lua] + roots: [.luarc.json] + languageId: lua +``` + +## Tests + +```bash +npm test # unit tests + live tests against typescript-language-server and pyright when installed +``` + +## Files + +| File | Role | +| --- | --- | +| `index.js` | DSH plugin: `lsp` tool, `tools/post-execute` diagnostics writethrough, workspace-edit application through `ctx.fs` | +| `lib/client.js` | JSON-RPC/stdio LSP client: document sync, push + pull diagnostics with freshness tracking | +| `lib/servers.js` | Server catalog, PATH detection, root markers, per-root pool, idle reaping | +| `lib/format.js` | Diagnostics/locations/symbols/hover formatting, TextEdit application, column lookup | diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..bcd3e5b --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,11 @@ +- insert: + - id: omp-lsp + name: '@local/omp-lsp' + config: + writethrough: true + # 1 error, 2 warning, 3 info, 4 hint + minSeverity: 2 + budgetMs: 4000 + idleMinutes: 10 + disabled: [] + servers: {} diff --git a/index.js b/index.js new file mode 100644 index 0000000..0a1604b --- /dev/null +++ b/index.js @@ -0,0 +1,338 @@ +/** + * @local/omp-lsp — oh-my-pi style language-server integration for DSH. + * + * - Diagnostics writethrough: after every successful `edit` / `write` (DSH built-ins or + * @local/omp-edit), the touched files are synced to their language server and fresh + * errors/warnings are appended to the tool result the model sees. + * - `lsp` tool: diagnostics, definition, type_definition, implementation, references, hover, + * symbols, rename (applied through DSH's fs), code_actions, status, reload, capabilities, + * raw request. + * + * Servers (typescript-language-server, pyright, gopls, rust-analyzer, clangd, bash, json, yaml, + * css, html) are auto-detected on PATH, ~/.local/bin and the project's node_modules/.bin; one + * client per (server, project root), idle clients are stopped after `idleMinutes`. + */ +import fs from 'node:fs' +import path from 'node:path' +import { ServerPool } from './lib/servers.js' +import { fromUri, toUri } from './lib/client.js' +import { + applyTextEdits, columnOf, formatDiagnostics, formatHover, formatLocations, formatSymbols, normalizeWorkspaceEdit, rel, +} from './lib/format.js' + +export const name = 'omp-lsp' +export const inject = ['tools', 'fs', 'systemPrompt'] + +const ACTIONS = ['diagnostics', 'definition', 'type_definition', 'implementation', 'references', 'hover', 'symbols', 'rename', 'rename_file', 'code_actions', 'status', 'reload', 'capabilities', 'request'] +const WRITE_TOOLS = new Set(['edit', 'write']) + +export function apply(ctx, config = {}) { + const cfg = { + writethrough: true, + writethroughTools: ['edit', 'write'], + minSeverity: 2, // 1 error, 2 warning, 3 info, 4 hint + budgetMs: 4000, + maxDiagnostics: 25, + idleMinutes: 10, + servers: {}, + disabled: [], + ...config, + } + const writeTools = new Set(cfg.writethroughTools ?? [...WRITE_TOOLS]) + const pool = new ServerPool({ + servers: cfg.servers, + disabled: cfg.disabled, + idleMs: cfg.idleMinutes * 60_000, + log: (m) => console.warn(`[omp-lsp] ${m}`), + }) + ctx.effect(() => () => { pool.stopAll() }, 'omp-lsp: language servers') + + const cwdOf = (exec) => exec?.agent?.session?.header?.cwd + const absPath = async (p, exec) => { + const t = await ctx.fs.resolve(String(p).replace(/^\[(.+?)#[0-9A-Fa-f]{4}\]$/, '$1'), { ...(cwdOf(exec) ? { cwd: cwdOf(exec) } : {}), signal: exec?.signal }) + return { target: t, file: ctx.fs.processPath(t) } + } + + // ---------------------------------------------------------------- writethrough diagnostics + + /** Files a write/edit call touched, from its arguments and (omp-edit) result value. */ + function touchedFiles(exec, result) { + const a = isObj(exec.arguments) ? exec.arguments : {} + const files = new Set() + if (typeof a.file_path === 'string') files.add(a.file_path) + if (typeof a.path === 'string') files.add(a.path) + const v = result.value + if (isObj(v)) { + if (typeof v.path === 'string') files.add(v.path) + for (const r of Array.isArray(v.results) ? v.results : []) { + if (r?.operation === 'delete') continue + if (typeof r?.movedTo === 'string') files.add(r.movedTo) + else if (typeof r?.path === 'string') files.add(r.path) + } + } + // omp-edit text payloads: [PATH#TAG] headers / *** Edit File: / *** Update File: + if (typeof a.input === 'string' && !(isObj(v) && Array.isArray(v.results))) { + for (const m of a.input.matchAll(/^\[(.+?)#[0-9A-Fa-f]{4}\]\s*$/gm)) files.add(m[1]) + for (const m of a.input.matchAll(/^\*\*\* (?:Edit|Update|Add) File:\s*(.+?)(?:\s+all)?\s*$/gm)) files.add(m[1]) + } + return [...files] + } + + async function diagnose(files, exec) { + const sections = [] + const totals = { error: 0, warning: 0 } + await Promise.all(files.slice(0, 8).map(async (f) => { + try { + const { file } = await absPath(f, exec) + if (!fs.existsSync(file)) return + const client = await pool.clientFor(file, { cwd: cwdOf(exec) }) + if (!client) return + const { items, fresh } = await client.diagnosticsFor(file, { budgetMs: cfg.budgetMs }) + const fmt = formatDiagnostics(file, items, { cwd: cwdOf(exec), minSeverity: cfg.minSeverity, max: cfg.maxDiagnostics }) + totals.error += fmt.counts.error; totals.warning += fmt.counts.warning + if (fmt.text) sections.push(fmt.text) + else if (!fresh) sections.push(`${rel(file, cwdOf(exec))}: ${client.id} did not report diagnostics within ${cfg.budgetMs}ms.`) + } catch (e) { + sections.push(`${f}: diagnostics unavailable (${e?.message ?? e})`) + } + })) + return { sections, totals } + } + + if (cfg.writethrough) { + ctx.on('tools/post-execute', async (exec, result, next) => { + const decision = await next() + if (decision.kind !== 'accept' || result.isError || !writeTools.has(exec.name) || exec.parent !== undefined) return decision + const files = touchedFiles(exec, result) + if (!files.length) return decision + const { sections, totals } = await diagnose(files, exec) + if (!sections.length) return decision + const head = totals.error || totals.warning + ? `LSP diagnostics after this ${exec.name} (${totals.error} error${totals.error === 1 ? '' : 's'}, ${totals.warning} warning${totals.warning === 1 ? '' : 's'}):` + : `LSP diagnostics after this ${exec.name}:` + const content = [...(decision.content ?? result.content ?? []), { type: 'text', text: `\n${head}\n${sections.join('\n')}` }] + return { ...decision, content } + }) + } + + // ---------------------------------------------------------------- lsp tool + + async function positionArgs(args, exec) { + if (!args.file) throw new Error('file is required for this action') + const { file } = await absPath(args.file, exec) + if (!fs.existsSync(file)) throw new Error(`${args.file}: not found`) + const client = await pool.clientFor(file, { cwd: cwdOf(exec) }) + if (!client) throw new Error(`No language server available for ${path.extname(file) || args.file}. Run lsp action=status to see installed servers.`) + const { uri } = client.sync(file) + const line = Math.max(1, Math.floor(args.line ?? 1)) - 1 + const lineStr = fs.readFileSync(file, 'utf8').split(/\r?\n/)[line] ?? '' + const character = columnOf(lineStr, args.symbol) + if (character < 0) throw new Error(`symbol ${JSON.stringify(args.symbol)} not found on line ${line + 1}: ${lineStr.trim()}`) + return { client, file, uri, position: { line, character } } + } + + async function applyWorkspaceEdit(we, exec) { + const { files, ops } = normalizeWorkspaceEdit(we) + const changed = [] + for (const [file, edits] of files) { + const { target } = await absPath(file, exec) + const before = await ctx.fs.readText(target, exec?.signal) + const after = applyTextEdits(before, edits) + if (after === before) continue + const outcome = await ctx.fs.writeText(target, after, undefined, exec?.signal, sandboxPolicyFor(exec)) + ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec) + changed.push(`${rel(file, cwdOf(exec))} (${edits.length} edit${edits.length === 1 ? '' : 's'})`) + } + for (const op of ops) { + if (op.kind === 'rename') { + const from = fromUri(op.oldUri); const to = fromUri(op.newUri) + await fs.promises.mkdir(path.dirname(to), { recursive: true }) + await fs.promises.rename(from, to) + changed.push(`renamed ${rel(from, cwdOf(exec))} → ${rel(to, cwdOf(exec))}`) + } else if (op.kind === 'create') { + await fs.promises.writeFile(fromUri(op.uri), '', { flag: 'wx' }).catch(() => {}) + changed.push(`created ${rel(fromUri(op.uri), cwdOf(exec))}`) + } else if (op.kind === 'delete') { + await fs.promises.rm(fromUri(op.uri), { recursive: !!op.options?.recursive, force: true }) + changed.push(`deleted ${rel(fromUri(op.uri), cwdOf(exec))}`) + } + } + return changed + } + + const sandboxPolicyFor = (exec) => { + try { return ctx.get('sandboxPolicy')?.resolve({ ...(exec?.agent ? { session: exec.agent.session } : {}) }) } catch { return undefined } + } + + function describeEdit(we, exec) { + const { files, ops } = normalizeWorkspaceEdit(we) + const out = [] + for (const [file, edits] of files) out.push(`${rel(file, cwdOf(exec))}: ${edits.length} edit${edits.length === 1 ? '' : 's'}`) + for (const op of ops) out.push(`${op.kind} ${fromUri(op.oldUri ?? op.uri)}${op.newUri ? ` → ${fromUri(op.newUri)}` : ''}`) + return out.join('\n') || '(no changes)' + } + + async function runLsp(args, exec) { + const action = args.action + const timeout = Math.max(5, Math.min(300, args.timeout ?? 20)) * 1000 + const cwd = cwdOf(exec) + switch (action) { + case 'status': { + const s = pool.status() + return [ + 'Servers:', + ...s.servers.map((x) => ` ${x.installed ? '✓' : '✗'} ${x.id} (${x.command}) ${x.extensions.join(' ')}`), + s.running.length ? 'Running:' : 'Running: none', + ...s.running.map((r) => ` ${r.id} ${r.state} root=${r.root} open=${r.openDocuments}`), + ...(s.broken.length ? ['Failed:', ...s.broken.map((b) => ` ${b.server}: ${b.error}`)] : []), + ].join('\n') + } + case 'reload': { + await pool.stopAll() + pool.reaper = setInterval(() => pool.reap(), 60_000); pool.reaper.unref?.() + pool.broken.clear() + return 'All language servers stopped; they restart on next use.' + } + case 'diagnostics': { + if (!args.file) throw new Error('file is required (path or glob)') + const files = await expand(args.file, cwd) + if (!files.length) return `No files match ${args.file}.` + const out = []; let errors = 0; let warnings = 0 + for (const f of files.slice(0, 50)) { + const client = await pool.clientFor(f, { cwd }) + if (!client) continue + const { items } = await client.diagnosticsFor(f, { budgetMs: Math.min(timeout, 15000) }) + const fmt = formatDiagnostics(f, items, { cwd, minSeverity: 4, max: 100 }) + errors += fmt.counts.error; warnings += fmt.counts.warning + if (fmt.text) out.push(fmt.text) + } + return out.length ? `${errors} error(s), ${warnings} warning(s)\n${out.join('\n')}` : `No diagnostics (${files.length} file${files.length === 1 ? '' : 's'} checked).` + } + case 'definition': case 'type_definition': case 'implementation': case 'references': { + const { client, uri, position } = await positionArgs(args, exec) + const method = { definition: 'textDocument/definition', type_definition: 'textDocument/typeDefinition', implementation: 'textDocument/implementation', references: 'textDocument/references' }[action] + const params = { textDocument: { uri }, position, ...(action === 'references' ? { context: { includeDeclaration: true } } : {}) } + return formatLocations(await client.request(method, params, timeout), { cwd }) + } + case 'hover': { + const { client, uri, position } = await positionArgs(args, exec) + return formatHover(await client.request('textDocument/hover', { textDocument: { uri }, position }, timeout)) + } + case 'symbols': { + if (args.file && args.file !== '*') { + const { file } = await absPath(args.file, exec) + const client = await pool.clientFor(file, { cwd }) + if (!client) throw new Error(`No language server for ${args.file}`) + const { uri } = client.sync(file) + return formatSymbols(await client.request('textDocument/documentSymbol', { textDocument: { uri } }, timeout), { cwd }) + } + if (!args.query) throw new Error('symbols needs file, or file="*" with query for a workspace search') + const clients = [...pool.clients.values()].filter((c) => c.state === 'ready') + if (!clients.length) throw new Error('No running language server; open a file first (e.g. lsp action=symbols file=).') + const res = (await Promise.all(clients.map((c) => c.request('workspace/symbol', { query: args.query }, timeout).catch(() => [])))).flat() + return formatSymbols(res, { cwd }) + } + case 'rename': { + if (!args.new_name) throw new Error('new_name is required') + const { client, uri, position } = await positionArgs(args, exec) + const we = await client.request('textDocument/rename', { textDocument: { uri }, position, newName: args.new_name }, timeout) + if (!we) return 'Rename not possible at this position.' + if (args.apply === false) return `Preview (not applied):\n${describeEdit(we, exec)}` + const changed = await applyWorkspaceEdit(we, exec) + for (const c of pool.clients.values()) for (const u of c.docs.keys()) c.sync(fromUri(u)) + return `Renamed to ${args.new_name}:\n${changed.join('\n') || '(no changes)'}` + } + case 'rename_file': { + if (!args.file || !args.new_name) throw new Error('file and new_name are required') + const { file } = await absPath(args.file, exec) + const dest = path.resolve(path.dirname(file), args.new_name) + const client = await pool.clientFor(file, { cwd }) + let we = null + if (client?.capabilities?.workspace?.fileOperations?.willRename) { + we = await client.request('workspace/willRenameFiles', { files: [{ oldUri: toUri(file), newUri: toUri(dest) }] }, timeout).catch(() => null) + } + if (args.apply === false) return `Preview: ${rel(file, cwd)} → ${rel(dest, cwd)}${we ? `\n${describeEdit(we, exec)}` : ''}` + const changed = we ? await applyWorkspaceEdit(we, exec) : [] + await fs.promises.mkdir(path.dirname(dest), { recursive: true }) + await fs.promises.rename(file, dest) + client?.notify('workspace/didRenameFiles', { files: [{ oldUri: toUri(file), newUri: toUri(dest) }] }) + return `Moved ${rel(file, cwd)} → ${rel(dest, cwd)}${changed.length ? `\nUpdated references:\n${changed.join('\n')}` : ''}` + } + case 'code_actions': { + const { client, uri, position } = await positionArgs(args, exec) + const diags = (client.diagnostics.get(uri)?.items || []).filter((d) => d.range.start.line <= position.line && d.range.end.line >= position.line) + const actions = (await client.request('textDocument/codeAction', { textDocument: { uri }, range: { start: { line: position.line, character: 0 }, end: { line: position.line + 1, character: 0 } }, context: { diagnostics: diags } }, timeout)) || [] + const filtered = args.query ? actions.filter((a) => (a.title || '').toLowerCase().includes(args.query.toLowerCase()) || (a.kind || '').startsWith(args.query)) : actions + if (args.apply !== true) return filtered.length ? filtered.map((a, i) => `${i + 1}. ${a.title}${a.kind ? ` [${a.kind}]` : ''}`).join('\n') : 'No code actions here.' + const pickAction = filtered[0] + if (!pickAction) return 'No matching code action to apply.' + let act = pickAction + if (!act.edit && act.data !== undefined && client.capabilities?.codeActionProvider?.resolveProvider) act = await client.request('codeAction/resolve', act, timeout) + if (act.edit) { + const changed = await applyWorkspaceEdit(act.edit, exec) + return `Applied "${act.title}":\n${changed.join('\n')}` + } + if (act.command) { await client.request('workspace/executeCommand', act.command.command ? act.command : act, timeout); return `Executed "${act.title}".` } + return `"${act.title}" has no edit to apply.` + } + case 'capabilities': { + if (!args.file) throw new Error('file is required') + const { file } = await absPath(args.file, exec) + const client = await pool.clientFor(file, { cwd }) + if (!client) throw new Error(`No language server for ${args.file}`) + return JSON.stringify(client.capabilities, null, 1).slice(0, 8000) + } + case 'request': { + if (!args.file || !args.query) throw new Error('request needs file (to pick the server) and query (LSP method)') + const { client, uri, position } = await positionArgs(args, exec) + const params = args.payload ? JSON.parse(args.payload) : { textDocument: { uri }, position } + return JSON.stringify(await client.request(args.query, params, timeout), null, 1).slice(0, 20000) + } + default: + throw new Error(`unknown action ${action}`) + } + } + + async function expand(spec, cwd) { + const base = cwd || process.cwd() + if (!/[*?[{]/.test(spec)) return [path.resolve(base, spec)].filter((f) => fs.existsSync(f)) + const out = [] + const glob = fs.promises.glob ? fs.promises.glob(spec, { cwd: base, exclude: (p) => /(^|\/)(node_modules|\.git|dist|build)(\/|$)/.test(p) }) : null + if (glob) for await (const f of glob) { out.push(path.resolve(base, f)); if (out.length >= 200) break } + return out + } + + ctx.tools.register({ + name: 'lsp', + description: 'Query language servers (auto-detected per file type): diagnostics (file path or glob), definition, type_definition, implementation, references, hover (file + 1-based line + symbol text to locate the column; `name#N` picks the Nth occurrence), symbols (file, or file="*" + query for workspace search), rename (new_name; applies through the workspace unless apply=false), rename_file (moves a file and updates imports when supported), code_actions (lists; apply=true applies the first match of query), status, reload, capabilities, request (raw LSP method in query, JSON params in payload). Errors and warnings are also appended automatically after edit/write.', + parameters: { + type: 'object', + additionalProperties: false, + required: ['action'], + properties: { + action: { type: 'string', enum: ACTIONS }, + file: { type: 'string', description: 'File path; a glob for diagnostics; "*" for workspace symbols.' }, + line: { type: 'integer', description: '1-based line for position-based actions (default 1).' }, + symbol: { type: 'string', description: 'Text on that line to locate the column; `name#N` selects the Nth occurrence.' }, + query: { type: 'string', description: 'Workspace-symbol query, code-action filter, or LSP method for action=request.' }, + new_name: { type: 'string', description: 'New name for rename / new path for rename_file.' }, + apply: { type: 'boolean', description: 'rename/rename_file: apply unless false. code_actions: apply only when true.' }, + timeout: { type: 'integer', description: 'Seconds (5-300, default 20).' }, + payload: { type: 'string', description: 'JSON params for action=request.' }, + }, + }, + output: { schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } } }, render: (_a, v) => [{ type: 'text', text: v.text }] }, + timeoutMs: 320_000, + async execute(args, exec) { + return { text: await runLsp(args ?? {}, exec) } + }, + }) + + ctx.systemPrompt.section({ + name: 'omp-lsp', + order: 1650, + text: 'Language-server diagnostics are appended to edit/write results; fix reported errors you introduced. Use the `lsp` tool for definitions, references, hover, renames and workspace diagnostics instead of grepping when a server is available.', + }) +} + +const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) diff --git a/lib/client.js b/lib/client.js new file mode 100644 index 0000000..bd40793 --- /dev/null +++ b/lib/client.js @@ -0,0 +1,239 @@ +// Minimal LSP client over stdio (JSON-RPC 2.0 with Content-Length framing). +import { spawn } from 'node:child_process' +import { EventEmitter } from 'node:events' +import fs from 'node:fs' +import path from 'node:path' +import { pathToFileURL, fileURLToPath } from 'node:url' + +export const toUri = (p) => pathToFileURL(p).href +export const fromUri = (u) => { try { return fileURLToPath(u) } catch { return u } } + +export class LspClient extends EventEmitter { + constructor({ id, command, args = [], root, env, initializationOptions, settings, languageIdFor }) { + super() + this.id = id + this.command = command + this.args = args + this.root = root + this.env = env + this.initializationOptions = initializationOptions + this.settings = settings + this.languageIdFor = languageIdFor + this.seq = 0 + this.pending = new Map() + this.buffer = Buffer.alloc(0) + this.docs = new Map() // uri -> { version, text } + this.diagnostics = new Map() // uri -> { version, items, at } + this.capabilities = null + this.state = 'new' + this.stderr = '' + this.lastUsed = Date.now() + } + + async start(timeoutMs = 30000) { + if (this.state !== 'new') return this.ready + this.state = 'starting' + this.ready = (async () => { + this.proc = spawn(this.command, this.args, { cwd: this.root, env: { ...process.env, ...this.env }, stdio: ['pipe', 'pipe', 'pipe'] }) + this.proc.on('error', (e) => this.fail(e)) + this.proc.on('exit', (code, sig) => this.fail(new Error(`${this.id} exited (${sig ?? code})${this.stderr ? `: ${this.stderr.slice(-400)}` : ''}`))) + this.proc.stdout.on('data', (d) => this.onData(d)) + this.proc.stderr.on('data', (d) => { this.stderr = (this.stderr + d).slice(-4000) }) + this.proc.stdin.on('error', () => {}) + const res = await this.request('initialize', { + processId: process.pid, + rootUri: toUri(this.root), + rootPath: this.root, + workspaceFolders: [{ uri: toUri(this.root), name: path.basename(this.root) }], + initializationOptions: this.initializationOptions, + capabilities: { + general: { positionEncodings: ['utf-16'] }, + workspace: { workspaceFolders: true, configuration: true, applyEdit: true, workspaceEdit: { documentChanges: true }, symbol: {}, didChangeWatchedFiles: { dynamicRegistration: false } }, + textDocument: { + synchronization: { didSave: true, dynamicRegistration: false }, + publishDiagnostics: { relatedInformation: true, versionSupport: true, codeDescriptionSupport: true }, + diagnostic: { dynamicRegistration: false, relatedDocumentSupport: false }, + hover: { contentFormat: ['markdown', 'plaintext'] }, + definition: { linkSupport: true }, typeDefinition: { linkSupport: true }, implementation: { linkSupport: true }, + references: {}, documentSymbol: { hierarchicalDocumentSymbolSupport: true }, + rename: { prepareSupport: true }, + codeAction: { codeActionLiteralSupport: { codeActionKind: { valueSet: ['', 'quickfix', 'refactor', 'refactor.extract', 'refactor.inline', 'refactor.rewrite', 'source', 'source.organizeImports', 'source.fixAll'] } }, resolveSupport: { properties: ['edit'] } }, + }, + }, + }, timeoutMs) + this.capabilities = res?.capabilities ?? {} + this.notify('initialized', {}) + if (this.settings) this.notify('workspace/didChangeConfiguration', { settings: this.settings }) + this.state = 'ready' + return this + })() + try { return await this.ready } catch (e) { this.fail(e); throw e } + } + + fail(err) { + if (this.state === 'dead') return + this.state = 'dead' + this.error = err + for (const { reject, timer } of this.pending.values()) { clearTimeout(timer); reject(err) } + this.pending.clear() + try { this.proc?.kill() } catch {} + this.emit('dead', err) + } + + onData(chunk) { + this.buffer = Buffer.concat([this.buffer, chunk]) + for (;;) { + const sep = this.buffer.indexOf('\r\n\r\n') + if (sep < 0) return + const header = this.buffer.slice(0, sep).toString('ascii') + const m = /Content-Length:\s*(\d+)/i.exec(header) + if (!m) { this.buffer = this.buffer.slice(sep + 4); continue } + const len = Number(m[1]) + if (this.buffer.length < sep + 4 + len) return + const body = this.buffer.slice(sep + 4, sep + 4 + len).toString('utf8') + this.buffer = this.buffer.slice(sep + 4 + len) + let msg + try { msg = JSON.parse(body) } catch { continue } + this.onMessage(msg) + } + } + + onMessage(msg) { + if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined) && !msg.method) { + const p = this.pending.get(msg.id) + if (!p) return + this.pending.delete(msg.id) + clearTimeout(p.timer) + if (msg.error) p.reject(Object.assign(new Error(msg.error.message || 'LSP error'), { code: msg.error.code })) + else p.resolve(msg.result) + return + } + if (msg.method && msg.id !== undefined) return this.onServerRequest(msg) + if (msg.method === 'textDocument/publishDiagnostics') { + const { uri, diagnostics = [], version } = msg.params || {} + this.diagnostics.set(uri, { version, items: diagnostics, at: Date.now() }) + this.emit('diagnostics', uri) + } + } + + onServerRequest(msg) { + const reply = (result) => this.send({ jsonrpc: '2.0', id: msg.id, result }) + switch (msg.method) { + case 'workspace/configuration': + return reply((msg.params?.items || []).map((it) => pick(this.settings, it.section))) + case 'workspace/workspaceFolders': + return reply([{ uri: toUri(this.root), name: path.basename(this.root) }]) + case 'client/registerCapability': case 'client/unregisterCapability': case 'window/workDoneProgress/create': + return reply(null) + case 'workspace/applyEdit': + // Edits the server asks for are applied by the tool layer, which owns fs policy. + this.emit('applyEdit', msg.params) + return reply({ applied: false, failureReason: 'apply edits through the lsp tool' }) + default: + return reply(null) + } + } + + send(obj) { + if (!this.proc?.stdin?.writable) return + const s = JSON.stringify(obj) + this.proc.stdin.write(`Content-Length: ${Buffer.byteLength(s, 'utf8')}\r\n\r\n${s}`) + } + + request(method, params, timeoutMs = 20000) { + if (this.state === 'dead') return Promise.reject(this.error || new Error(`${this.id} is not running`)) + this.lastUsed = Date.now() + const id = ++this.seq + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id) + this.send({ jsonrpc: '2.0', method: '$/cancelRequest', params: { id } }) + reject(new Error(`${this.id}: ${method} timed out after ${timeoutMs}ms`)) + }, timeoutMs) + this.pending.set(id, { resolve, reject, timer }) + this.send({ jsonrpc: '2.0', id, method, params }) + }) + } + + notify(method, params) { + this.lastUsed = Date.now() + this.send({ jsonrpc: '2.0', method, params }) + } + + /** Open or update a document from disk (full sync). Returns the uri and version. */ + sync(file) { + const uri = toUri(file) + let text + try { text = fs.readFileSync(file, 'utf8') } catch { text = null } + const doc = this.docs.get(uri) + if (text === null) { + if (doc) { this.notify('textDocument/didClose', { textDocument: { uri } }); this.docs.delete(uri) } + return { uri, version: null, missing: true } + } + if (!doc) { + const d = { version: 1, text } + this.docs.set(uri, d) + this.notify('textDocument/didOpen', { textDocument: { uri, languageId: this.languageIdFor(file), version: 1, text } }) + return { uri, version: 1, changed: true } + } + if (doc.text === text) return { uri, version: doc.version, changed: false } + doc.version++ + doc.text = text + this.notify('textDocument/didChange', { textDocument: { uri, version: doc.version }, contentChanges: [{ text }] }) + this.notify('textDocument/didSave', { textDocument: { uri }, text }) + return { uri, version: doc.version, changed: true } + } + + /** + * Fresh diagnostics for a synced document: pull when supported, otherwise wait for a + * publish newer than the sync (matching version when the server reports one), then let + * it settle briefly because many servers publish twice (syntax, then semantic). + */ + async diagnosticsFor(file, { budgetMs = 4000, settleMs = 350 } = {}) { + const startedAt = Date.now() + const { uri, version, missing } = this.sync(file) + if (missing) return { uri, items: [], fresh: true } + if (this.capabilities?.diagnosticProvider) { + try { + const r = await this.request('textDocument/diagnostic', { textDocument: { uri } }, budgetMs) + if (r?.kind === 'full') return { uri, items: r.items || [], fresh: true } + if (r?.kind === 'unchanged') return { uri, items: this.diagnostics.get(uri)?.items ?? [], fresh: true } + } catch { /* fall back to push */ } + } + const deadline = startedAt + budgetMs + const isFresh = () => { + const d = this.diagnostics.get(uri) + if (!d) return false + if (d.version != null && version != null) return d.version >= version + return d.at >= startedAt + } + await new Promise((resolve) => { + let settle = null + const done = () => { clearTimeout(timer); clearTimeout(settle); this.off('diagnostics', on); resolve() } + const on = (u) => { + if (u !== uri || !isFresh()) return + clearTimeout(settle) + settle = setTimeout(done, settleMs) + } + const timer = setTimeout(done, Math.max(0, deadline - Date.now())) + this.on('diagnostics', on) + if (isFresh()) on(uri) + }) + return { uri, items: this.diagnostics.get(uri)?.items ?? [], fresh: isFresh() } + } + + async stop() { + if (this.state === 'dead') return + try { await this.request('shutdown', null, 3000) } catch {} + this.notify('exit', null) + setTimeout(() => { try { this.proc?.kill() } catch {} }, 500) + this.state = 'dead' + } +} + +function pick(obj, section) { + if (!section || !obj) return obj ?? null + let cur = obj + for (const k of String(section).split('.')) { if (cur == null) return null; cur = cur[k] } + return cur ?? null +} diff --git a/lib/format.js b/lib/format.js new file mode 100644 index 0000000..e9336c4 --- /dev/null +++ b/lib/format.js @@ -0,0 +1,108 @@ +// Formatting helpers for diagnostics, locations, symbols, hovers and workspace edits. +import fs from 'node:fs' +import path from 'node:path' +import { fromUri } from './client.js' + +const SEV = { 1: 'error', 2: 'warning', 3: 'info', 4: 'hint' } + +export function rel(file, cwd) { + if (!cwd) return file + const r = path.relative(cwd, file) + return r && !r.startsWith('..') && !path.isAbsolute(r) ? r : file +} + +/** Diagnostics → compact lines; returns {text, counts}. */ +export function formatDiagnostics(file, items, { cwd, minSeverity = 2, max = 40 } = {}) { + const filtered = items.filter((d) => (d.severity ?? 1) <= minSeverity) + const counts = { error: 0, warning: 0, info: 0, hint: 0 } + for (const d of items) counts[SEV[d.severity ?? 1]]++ + const sorted = [...filtered].sort((a, b) => (a.severity ?? 1) - (b.severity ?? 1) || a.range.start.line - b.range.start.line) + const lines = sorted.slice(0, max).map((d) => { + const l = d.range.start.line + 1; const c = d.range.start.character + 1 + const code = d.code != null ? ` [${typeof d.code === 'object' ? d.code.value : d.code}]` : '' + const src = d.source ? ` (${d.source})` : '' + return `${rel(file, cwd)}:${l}:${c} ${SEV[d.severity ?? 1]}${code}: ${String(d.message).split('\n')[0]}${src}` + }) + if (sorted.length > max) lines.push(`… ${sorted.length - max} more`) + return { text: lines.join('\n'), counts, shown: filtered.length } +} + +export function lineText(file, line) { + try { return fs.readFileSync(file, 'utf8').split(/\r?\n/)[line] ?? '' } catch { return '' } +} + +export function formatLocations(locs, { cwd, max = 50 } = {}) { + const arr = (Array.isArray(locs) ? locs : locs ? [locs] : []).map((l) => ({ uri: l.uri ?? l.targetUri, range: l.range ?? l.targetSelectionRange ?? l.targetRange })) + if (!arr.length) return 'No results.' + const out = arr.slice(0, max).map((l) => { + const f = fromUri(l.uri); const ln = l.range.start.line + return `${rel(f, cwd)}:${ln + 1}:${l.range.start.character + 1}: ${lineText(f, ln).trim()}` + }) + if (arr.length > max) out.push(`… ${arr.length - max} more`) + return out.join('\n') +} + +const KIND = ['', 'file', 'module', 'namespace', 'package', 'class', 'method', 'property', 'field', 'constructor', 'enum', 'interface', 'function', 'variable', 'constant', 'string', 'number', 'boolean', 'array', 'object', 'key', 'null', 'enum-member', 'struct', 'event', 'operator', 'type-param'] +export function formatSymbols(syms, { cwd, depth = 0, max = 200 } = {}) { + const out = [] + const walk = (list, d) => { + for (const s of list || []) { + if (out.length >= max) return + const range = s.selectionRange ?? s.range ?? s.location?.range + const where = s.location ? `${rel(fromUri(s.location.uri), cwd)}:` : '' + out.push(`${' '.repeat(d)}${KIND[s.kind] ?? 'symbol'} ${s.name}${s.detail ? ` ${s.detail}` : ''} — ${where}${range ? range.start.line + 1 : '?'}`) + if (s.children) walk(s.children, d + 1) + } + } + walk(syms, depth) + return out.length ? out.join('\n') : 'No symbols.' +} + +export function formatHover(h) { + if (!h?.contents) return 'No hover information.' + const c = h.contents + const part = (x) => (typeof x === 'string' ? x : x.value ?? '') + return (Array.isArray(c) ? c.map(part) : [part(c)]).filter(Boolean).join('\n\n') || 'No hover information.' +} + +/** WorkspaceEdit → [{file, edits:[{range,newText}]}] plus file ops. */ +export function normalizeWorkspaceEdit(we) { + const files = new Map(); const ops = [] + const add = (uri, edits) => { const f = fromUri(uri); files.set(f, [...(files.get(f) || []), ...edits]) } + for (const [uri, edits] of Object.entries(we?.changes || {})) add(uri, edits) + for (const dc of we?.documentChanges || []) { + if (dc.textDocument) add(dc.textDocument.uri, dc.edits) + else if (dc.kind) ops.push(dc) + } + return { files, ops } +} + +/** Apply LSP TextEdits (utf-16 positions) to text. */ +export function applyTextEdits(text, edits) { + const lineStarts = [0] + for (let i = 0; i < text.length; i++) if (text[i] === '\n') lineStarts.push(i + 1) + const off = (p) => Math.min(text.length, (lineStarts[p.line] ?? text.length) + p.character) + const sorted = [...edits].sort((a, b) => off(b.range.start) - off(a.range.start)) + let out = text + for (const e of sorted) out = out.slice(0, off(e.range.start)) + e.newText + out.slice(off(e.range.end)) + return out +} + +/** Column (0-based utf-16) of `symbol` on a line; supports `name#N` occurrence selectors. */ +export function columnOf(lineStr, symbol) { + if (!symbol) return lineStr.search(/\S/) >= 0 ? lineStr.search(/\S/) : 0 + let name = symbol; let nth = 1 + const m = /^(.*)#(\d+)$/.exec(symbol) + if (m) { name = m[1]; nth = Number(m[2]) } + const ident = /^[A-Za-z_$][\w$]*$/.test(name) + const find = (hay, needle) => { + const re = ident ? new RegExp(`(? ({ '.ts': 'typescript', '.mts': 'typescript', '.cts': 'typescript', '.tsx': 'typescriptreact', '.jsx': 'javascriptreact' }[path.extname(f)] ?? 'javascript'), + }, + pyright: { + command: 'pyright-langserver', args: ['--stdio'], + extensions: ['.py', '.pyi'], + roots: ['pyrightconfig.json', 'pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt'], + languageId: () => 'python', + settings: { python: { analysis: { autoSearchPaths: true, useLibraryCodeForTypes: true, diagnosticMode: 'openFilesOnly' } } }, + }, + gopls: { command: 'gopls', args: [], extensions: ['.go'], roots: ['go.mod', 'go.work'], languageId: () => 'go' }, + 'rust-analyzer': { command: 'rust-analyzer', args: [], extensions: ['.rs'], roots: ['Cargo.toml'], languageId: () => 'rust' }, + clangd: { command: 'clangd', args: ['--background-index=false'], extensions: ['.c', '.h', '.cc', '.cpp', '.cxx', '.hpp', '.hh'], roots: ['compile_commands.json', 'CMakeLists.txt', '.clangd'], languageId: (f) => (['.c', '.h'].includes(path.extname(f)) ? 'c' : 'cpp') }, + bash: { command: 'bash-language-server', args: ['start'], extensions: ['.sh', '.bash', '.zsh'], roots: [], languageId: () => 'shellscript' }, + json: { command: 'vscode-json-language-server', args: ['--stdio'], extensions: ['.json', '.jsonc'], roots: [], languageId: (f) => (path.extname(f) === '.jsonc' ? 'jsonc' : 'json') }, + yaml: { command: 'yaml-language-server', args: ['--stdio'], extensions: ['.yaml', '.yml'], roots: [], languageId: () => 'yaml', settings: { yaml: { validate: true, schemaStore: { enable: false } } } }, + css: { command: 'vscode-css-language-server', args: ['--stdio'], extensions: ['.css', '.scss', '.less'], roots: [], languageId: (f) => path.extname(f).slice(1) }, + html: { command: 'vscode-html-language-server', args: ['--stdio'], extensions: ['.html', '.htm'], roots: [], languageId: () => 'html' }, +} + +const whichCache = new Map() +export function which(cmd, extraDirs = []) { + if (path.isAbsolute(cmd)) return fs.existsSync(cmd) ? cmd : null + const key = `${cmd}|${extraDirs.join(':')}` + if (whichCache.has(key)) return whichCache.get(key) + const dirs = [...extraDirs, ...(process.env.PATH || '').split(path.delimiter), path.join(process.env.HOME || '', '.local/bin')] + let found = null + for (const d of dirs) { + if (!d) continue + const p = path.join(d, cmd) + try { fs.accessSync(p, fs.constants.X_OK); found = p; break } catch {} + } + whichCache.set(key, found) + return found +} + +/** Nearest ancestor of `file` (up to `ceiling`) containing one of `markers`. */ +export function findRoot(file, markers, ceiling) { + let dir = path.dirname(file) + const stop = ceiling ? path.resolve(ceiling) : path.parse(dir).root + for (;;) { + for (const m of markers) if (fs.existsSync(path.join(dir, m))) return dir + if (dir === stop || dir === path.dirname(dir)) break + dir = path.dirname(dir) + } + return null +} + +export class ServerPool { + /** + * @param {object} opts { servers: overrides/extra defs, disabled: string[], idleMs } + */ + constructor({ servers = {}, disabled = [], idleMs = 10 * 60_000, log = () => {} } = {}) { + this.defs = {} + for (const [id, def] of Object.entries({ ...BUILTIN_SERVERS, ...servers })) { + if (disabled.includes(id) || def === false || def?.enabled === false) continue + const base = BUILTIN_SERVERS[id] ?? {} + this.defs[id] = { ...base, ...def } + if (typeof this.defs[id].languageId === 'string') { const l = this.defs[id].languageId; this.defs[id].languageId = () => l } + if (typeof this.defs[id].languageId !== 'function') this.defs[id].languageId = () => id + } + this.clients = new Map() // `${id}|${root}` -> LspClient + this.broken = new Map() // key -> { error, at } + this.idleMs = idleMs + this.log = log + this.reaper = setInterval(() => this.reap(), 60_000) + this.reaper.unref?.() + } + + /** Server definitions able to handle this file (installed only). */ + defsFor(file) { + const ext = path.extname(file).toLowerCase() + return Object.entries(this.defs).filter(([, d]) => (d.extensions || []).includes(ext)) + } + + resolveCommand(def, root) { + return which(def.command, root ? [path.join(root, 'node_modules/.bin')] : []) + } + + /** Get (starting if needed) the client for a file. Returns null when no server applies. */ + async clientFor(file, { cwd } = {}) { + for (const [id, def] of this.defsFor(file)) { + const root = (def.roots?.length ? findRoot(file, def.roots, null) : null) ?? cwd ?? path.dirname(file) + const command = this.resolveCommand(def, root) + if (!command) continue + const key = `${id}|${root}` + const broken = this.broken.get(key) + if (broken && Date.now() - broken.at < 60_000) continue + let c = this.clients.get(key) + if (c && c.state === 'dead') { this.clients.delete(key); c = null } + if (!c) { + c = new LspClient({ id, command, args: def.args || [], root, env: def.env, initializationOptions: def.initializationOptions, settings: def.settings, languageIdFor: def.languageId }) + this.clients.set(key, c) + c.on('dead', (e) => { this.log(`${id} (${root}) stopped: ${e?.message ?? e}`); this.clients.delete(key) }) + } + try { + await c.start(def.startupTimeoutMs ?? 45_000) + return c + } catch (e) { + this.broken.set(key, { error: String(e?.message ?? e), at: Date.now() }) + this.clients.delete(key) + this.log(`${id} failed to start: ${e?.message ?? e}`) + } + } + return null + } + + status() { + const servers = Object.entries(this.defs).map(([id, d]) => ({ + id, + command: d.command, + installed: !!this.resolveCommand(d), + extensions: d.extensions, + })) + const running = [...this.clients.values()].map((c) => ({ id: c.id, root: c.root, state: c.state, openDocuments: c.docs.size })) + const broken = [...this.broken.entries()].map(([k, v]) => ({ server: k, error: v.error })) + return { servers, running, broken } + } + + reap() { + for (const [k, c] of this.clients) if (Date.now() - c.lastUsed > this.idleMs) { c.stop(); this.clients.delete(k) } + } + + async stopAll() { + clearInterval(this.reaper) + await Promise.all([...this.clients.values()].map((c) => c.stop().catch(() => {}))) + this.clients.clear() + } +} + +export function gitRootOf(dir) { + try { return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() } catch { return null } +} diff --git a/locale/en.json b/locale/en.json new file mode 100644 index 0000000..0d11db0 --- /dev/null +++ b/locale/en.json @@ -0,0 +1 @@ +{ "meta": { "title": "OMP LSP", "description": "Language-server diagnostics after every edit/write, and an lsp tool for definitions, references, hover, symbols, renames and code actions (auto-detects typescript, pyright, gopls, rust-analyzer, clangd, bash, json, yaml, css, html)." } } diff --git a/locale/zh.json b/locale/zh.json new file mode 100644 index 0000000..2a481d0 --- /dev/null +++ b/locale/zh.json @@ -0,0 +1 @@ +{ "meta": { "title": "OMP LSP", "description": "每次 edit/write 后附加语言服务器诊断,并提供 lsp 工具:定义、引用、悬停、符号、重命名和代码操作(自动检测 typescript、pyright、gopls、rust-analyzer、clangd、bash、json、yaml、css、html)。" } } diff --git a/package.json b/package.json new file mode 100644 index 0000000..c94bba1 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "@local/omp-lsp", + "version": "1.0.0", + "description": "oh-my-pi style language-server integration for DSH: diagnostics appended to every edit/write result, plus an lsp tool (diagnostics, definition, references, hover, symbols, rename, code actions, raw requests).", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": "./index.js", + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" + }, + "files": ["index.js", "lib/*.js", "cordis.patch.yml", "locale/*.json"], + "scripts": { "test": "node --test test/*.test.mjs" }, + "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } +} diff --git a/test/lsp.test.mjs b/test/lsp.test.mjs new file mode 100644 index 0000000..4fc5f54 --- /dev/null +++ b/test/lsp.test.mjs @@ -0,0 +1,121 @@ +// Integration tests against real language servers (skipped when not installed). +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { ServerPool, which } from '../lib/servers.js' +import { columnOf, applyTextEdits, formatDiagnostics } from '../lib/format.js' +import * as plugin from '../index.js' + +const DSH = '/usr/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai' + +test('columnOf supports identifiers, substrings and #N', () => { + assert.equal(columnOf('const foo = foo + 1', 'foo'), 6) + assert.equal(columnOf('const foo = foo + 1', 'foo#2'), 12) + assert.equal(columnOf('a.b(c)', 'b(c'), 2) + assert.equal(columnOf('x', 'nope'), -1) +}) + +test('applyTextEdits applies utf-16 ranges bottom-up', () => { + const t = 'let a = 1\nlet b = a\n' + const out = applyTextEdits(t, [ + { range: { start: { line: 0, character: 4 }, end: { line: 0, character: 5 } }, newText: 'x' }, + { range: { start: { line: 1, character: 8 }, end: { line: 1, character: 9 } }, newText: 'x' }, + ]) + assert.equal(out, 'let x = 1\nlet b = x\n') +}) + +async function harness() { + const { default: LocalFileSystem } = await import(`${DSH}/dsh-fs-local/lib/index.js`) + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'omp-lsp-')) + const fsImpl = Object.create(LocalFileSystem.prototype) + Object.assign(fsImpl, { config: { cwd: dir, diffBasisMaxBytes: 1 << 24 }, internals: {}, locks: new Map() }) + const listeners = new Map() + const tools = new Map() + const ctx = { + fs: fsImpl, + tools: { register(d) { tools.set(d.name, d); return () => tools.delete(d.name) }, get: (n) => tools.get(n) }, + on(n, f) { listeners.set(n, [...(listeners.get(n) ?? []), f]); return () => {} }, + emit() {}, + effect(fn) { return fn() }, + get() {}, + systemPrompt: { section: () => () => {} }, + } + const agent = { session: { header: { cwd: dir } } } + const post = async (name, args, value) => { + const exec = { name, arguments: args, agent, signal: new AbortController().signal } + const result = { isError: false, value, content: [{ type: 'text', text: 'ok' }] } + const cbs = [...(listeners.get('tools/post-execute') ?? [])] + const next = () => (cbs.shift() ?? (async () => ({ kind: 'accept' })))(exec, result, next) + return next() + } + const lsp = (args) => tools.get('lsp').execute(args, { agent, signal: new AbortController().signal }) + return { ctx, dir, post, lsp, tools } +} + +const hasTs = !!which('typescript-language-server') +const hasPy = !!which('pyright-langserver') + +test('writethrough appends TypeScript diagnostics after edit', { skip: !hasTs, timeout: 90_000 }, async () => { + const h = await harness() + fs.writeFileSync(path.join(h.dir, 'package.json'), '{"name":"t"}') + fs.writeFileSync(path.join(h.dir, 'tsconfig.json'), '{"compilerOptions":{"strict":true,"noEmit":true}}') + fs.writeFileSync(path.join(h.dir, 'a.ts'), 'const n: number = "x"\nexport {}\n') + plugin.apply(h.ctx, { budgetMs: 30000 }) + const d = await h.post('edit', { input: `[a.ts#ABCD]\nPUT 1.=1:\n+const n: number = "x"` }, { results: [{ path: 'a.ts', operation: 'update' }] }) + const text = d.content.map((c) => c.text).join('\n') + assert.match(text, /LSP diagnostics after this edit/) + assert.match(text, /a\.ts:1:\d+ error.*not assignable/) + // lsp tool: hover + definition + rename + fs.writeFileSync(path.join(h.dir, 'b.ts'), 'export function greet(name: string) { return name }\nexport const r = greet("x")\n') + const hover = await h.lsp({ action: 'hover', file: 'b.ts', line: 2, symbol: 'greet' }) + assert.match(hover.text, /greet/) + const def = await h.lsp({ action: 'definition', file: 'b.ts', line: 2, symbol: 'greet' }) + assert.match(def.text, /b\.ts:1:/) + const ren = await h.lsp({ action: 'rename', file: 'b.ts', line: 1, symbol: 'greet', new_name: 'welcome' }) + assert.match(ren.text, /Renamed to welcome/) + assert.match(fs.readFileSync(path.join(h.dir, 'b.ts'), 'utf8'), /welcome\("x"\)/) + const st = await h.lsp({ action: 'status' }) + assert.match(st.text, /typescript .*ready/) + await h.lsp({ action: 'reload' }) + fs.rmSync(h.dir, { recursive: true, force: true }) +}) + +test('writethrough for python via pyright', { skip: !hasPy, timeout: 90_000 }, async () => { + const h = await harness() + fs.writeFileSync(path.join(h.dir, 'pyproject.toml'), '[project]\nname="t"\n') + fs.writeFileSync(path.join(h.dir, 'm.py'), 'def f() -> int:\n return "x"\n') + plugin.apply(h.ctx, { budgetMs: 30000 }) + const d = await h.post('write', { file_path: 'm.py', content: '' }, { path: path.join(h.dir, 'm.py'), operation: 'update' }) + const text = d.content.map((c) => c.text).join('\n') + assert.match(text, /m\.py:2:\d+ error/) + await h.lsp({ action: 'reload' }) + fs.rmSync(h.dir, { recursive: true, force: true }) +}) + +test('no server for unknown extension leaves result untouched', async () => { + const h = await harness() + fs.writeFileSync(path.join(h.dir, 'x.unknownext'), 'hi') + plugin.apply(h.ctx, {}) + const d = await h.post('write', { file_path: 'x.unknownext', content: 'hi' }, { path: 'x.unknownext' }) + assert.equal(d.content, undefined) + fs.rmSync(h.dir, { recursive: true, force: true }) +}) + +test('formatDiagnostics filters by severity', () => { + const items = [ + { severity: 1, message: 'bad', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }, + { severity: 4, message: 'hint', range: { start: { line: 1, character: 0 }, end: { line: 1, character: 1 } } }, + ] + const f = formatDiagnostics('/x/a.ts', items, { cwd: '/x', minSeverity: 2 }) + assert.equal(f.text, 'a.ts:1:1 error: bad') + assert.equal(f.counts.hint, 1) +}) + +test('pool status lists installed servers', () => { + const p = new ServerPool({}) + const s = p.status() + assert.ok(s.servers.find((x) => x.id === 'typescript')) + p.stopAll() +})