Browse Source

feat(lsp): align with OpenCode core - 37 servers, root detection, merged config (#195)

* feat: use opencode.json lsp config instead of static builtins

LSP tools now respect user configuration from opencode.json lsp section
instead of using hardcoded BUILTIN_SERVERS. User config takes precedence;
built-in servers serve as fallback when no user config exists.

Changes:
- Add config-store module to hold user LSP config at runtime
- Update config hook to pass opencode.json lsp config to store
- Modify findServerForExtension to check user config first
- Mark BUILTIN_SERVERS as deprecated fallback

Users can now override LSP servers in opencode.json:
{
  "lsp": {
    "pyright": { "disabled": true },
    "ty": { "command": ["uvx", "ty", "server"], "extensions": [".py"] }
  }
}

* feat(lsp): mirror all 37 servers from OpenCode core with root detection

- Expand BUILTIN_SERVERS from 12 to 37 servers (matches OpenCode core)
- Add rootPatterns and excludePatterns for server-specific project root detection
- Add findServerProjectRoot() function for walking up directories
- Rename EXT_TO_LANG to LANGUAGE_EXTENSIONS (matches core naming)
- Expand LANGUAGE_EXTENSIONS to 100+ file extensions
- Update tests to match new server order (deno first for .ts, ty first for .py)
- Add comprehensive logging throughout LSP lifecycle
- All install hints included for all 37 servers

* feat(lsp): add NearestRoot generator matching OpenCode core

- Add walkUpDirectories generator mirroring Filesystem.up() utility
- Refactor findServerProjectRoot to match core's NearestRoot behavior
- Fix bug: remove unused 'excluded' variable in exclusion loop
- Change fallback from file directory to cwd (matches Instance.directory)
- Add tests for empty rootPatterns and exclusion pattern handling
- All 54 LSP tests pass

feat(lsp): use which.sync for server installation check

- Replace manual PATH walking with which.sync (mirrors core approach)
- Keep ~/.config/opencode/bin in search path for user-installed servers
- Add which@6.0.0 and @types/which dependencies
- Update tests to mock which.sync

* docs: add ping all agents section to README (#193)

* refactor(lsp): use root function instead of rootPatterns array

Convert all LSP servers from rootPatterns/excludePatterns arrays to
root: NearestRoot([...]) function pattern matching OpenCode core.

Changes:
- Add RootFunction type (file => string | undefined)
- Export NearestRoot helper for use in tests
- Update BUILTIN_SERVERS to use root: NearestRoot([...]) syntax
- Update MergedServerConfig to preserve root function on merge
- Simplify findServerProjectRoot to call server.root() directly
- Update tests to use root function API

fix(lsp): address greptile P1 and P2 review issues

- Fix glob patterns in NearestRoot (readdirSync + regex matching)
- Guard against undefined PATH in isServerInstalled
- Add clarifying comment for ruby_lsp (rubocop vs ruby-lsp)
- Fix incorrect ty install URL (astral-sh not jeansantefior)

---------

Co-authored-by: alvinreal <claw@boringdystopia.ai>
Adithya Kozham Burath Bijoy 3 weeks ago
parent
commit
ce40614a30

+ 10 - 2
bun.lock

@@ -11,10 +11,12 @@
         "@opencode-ai/sdk": "^1.2.6",
         "vscode-jsonrpc": "^8.2.0",
         "vscode-languageserver-protocol": "^3.17.5",
+        "which": "^6.0.0",
         "zod": "^4.3.6",
       },
       "devDependencies": {
         "@biomejs/biome": "2.4.2",
+        "@types/which": "^3.0.4",
         "bun-types": "1.3.9",
         "typescript": "^5.9.3",
       },
@@ -68,6 +70,8 @@
 
     "@types/node": ["@types/node@25.0.8", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg=="],
 
+    "@types/which": ["@types/which@3.0.4", "", {}, "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w=="],
+
     "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
 
     "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
@@ -162,7 +166,7 @@
 
     "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
 
-    "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+    "isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
 
     "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
 
@@ -252,7 +256,7 @@
 
     "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="],
 
-    "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+    "which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
 
     "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
 
@@ -262,6 +266,10 @@
 
     "@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
 
+    "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+
     "vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
+
+    "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
   }
 }

+ 9 - 47
oh-my-opencode-slim.schema.json

@@ -10,11 +10,7 @@
     },
     "scoringEngineVersion": {
       "type": "string",
-      "enum": [
-        "v1",
-        "v2-shadow",
-        "v2"
-      ]
+      "enum": ["v1", "v2-shadow", "v2"]
     },
     "balanceProviderUsage": {
       "type": "boolean"
@@ -42,12 +38,7 @@
               "pattern": "^[^/\\s]+\\/[^\\s]+$"
             }
           },
-          "required": [
-            "primary",
-            "fallback1",
-            "fallback2",
-            "fallback3"
-          ]
+          "required": ["primary", "fallback1", "fallback2", "fallback3"]
         },
         "oracle": {
           "type": "object",
@@ -69,12 +60,7 @@
               "pattern": "^[^/\\s]+\\/[^\\s]+$"
             }
           },
-          "required": [
-            "primary",
-            "fallback1",
-            "fallback2",
-            "fallback3"
-          ]
+          "required": ["primary", "fallback1", "fallback2", "fallback3"]
         },
         "designer": {
           "type": "object",
@@ -96,12 +82,7 @@
               "pattern": "^[^/\\s]+\\/[^\\s]+$"
             }
           },
-          "required": [
-            "primary",
-            "fallback1",
-            "fallback2",
-            "fallback3"
-          ]
+          "required": ["primary", "fallback1", "fallback2", "fallback3"]
         },
         "explorer": {
           "type": "object",
@@ -123,12 +104,7 @@
               "pattern": "^[^/\\s]+\\/[^\\s]+$"
             }
           },
-          "required": [
-            "primary",
-            "fallback1",
-            "fallback2",
-            "fallback3"
-          ]
+          "required": ["primary", "fallback1", "fallback2", "fallback3"]
         },
         "librarian": {
           "type": "object",
@@ -150,12 +126,7 @@
               "pattern": "^[^/\\s]+\\/[^\\s]+$"
             }
           },
-          "required": [
-            "primary",
-            "fallback1",
-            "fallback2",
-            "fallback3"
-          ]
+          "required": ["primary", "fallback1", "fallback2", "fallback3"]
         },
         "fixer": {
           "type": "object",
@@ -177,12 +148,7 @@
               "pattern": "^[^/\\s]+\\/[^\\s]+$"
             }
           },
-          "required": [
-            "primary",
-            "fallback1",
-            "fallback2",
-            "fallback3"
-          ]
+          "required": ["primary", "fallback1", "fallback2", "fallback3"]
         }
       },
       "required": [
@@ -230,9 +196,7 @@
                             "type": "string"
                           }
                         },
-                        "required": [
-                          "id"
-                        ]
+                        "required": ["id"]
                       }
                     ]
                   }
@@ -293,9 +257,7 @@
                           "type": "string"
                         }
                       },
-                      "required": [
-                        "id"
-                      ]
+                      "required": ["id"]
                     }
                   ]
                 }

+ 2 - 0
package.json

@@ -57,10 +57,12 @@
     "@opencode-ai/sdk": "^1.2.6",
     "vscode-jsonrpc": "^8.2.0",
     "vscode-languageserver-protocol": "^3.17.5",
+    "which": "^6.0.0",
     "zod": "^4.3.6"
   },
   "devDependencies": {
     "@biomejs/biome": "2.4.2",
+    "@types/which": "^3.0.4",
     "bun-types": "1.3.9",
     "typescript": "^5.9.3"
   },

+ 1 - 1
scripts/generate-schema.ts

@@ -30,6 +30,6 @@ const jsonSchema = {
 };
 
 const json = JSON.stringify(jsonSchema, null, 2);
-writeFileSync(outputPath, json + '\n');
+writeFileSync(outputPath, `${json}\n`);
 
 console.log(`✅ Schema written to ${outputPath}`);

+ 29 - 25
src/cli/install.ts

@@ -78,9 +78,9 @@ async function checkOpenCodeInstalled(): Promise<{
   }
   const version = await getOpenCodeVersion();
   const path = getOpenCodePath();
-  printSuccess(
-    `OpenCode ${version ?? ''} detected${path ? ` (${DIM}${path}${RESET})` : ''}`,
-  );
+  const detectedVersion = version ?? '';
+  const pathInfo = path ? ` (${DIM}${path}${RESET})` : '';
+  printSuccess(`OpenCode ${detectedVersion} detected${pathInfo}`);
   return { ok: true, version: version ?? undefined, path: path ?? undefined };
 }
 
@@ -104,13 +104,10 @@ function formatConfigSummary(): string {
   lines.push('');
   lines.push(`  ${BOLD}Preset:${RESET} ${BLUE}openai${RESET}`);
   lines.push(`  ${SYMBOLS.check} OpenAI (default)`);
-  lines.push(`  ${DIM}○ Kimi — see docs/provider-configurations.md${RESET}`);
-  lines.push(
-    `  ${DIM}○ GitHub Copilot — see docs/provider-configurations.md${RESET}`,
-  );
-  lines.push(
-    `  ${DIM}○ ZAI Coding Plan — see docs/provider-configurations.md${RESET}`,
-  );
+  const seeDocs = 'see docs/provider-configurations.md';
+  lines.push(`  ${DIM}○ Kimi — ${seeDocs}${RESET}`);
+  lines.push(`  ${DIM}○ GitHub Copilot — ${seeDocs}${RESET}`);
+  lines.push(`  ${DIM}○ ZAI Coding Plan — ${seeDocs}${RESET}`);
   return lines.join('\n');
 }
 
@@ -159,10 +156,14 @@ async function runInstall(config: InstallConfig): Promise<number> {
 
     if (configExists && !config.reset) {
       printInfo(
-        `Configuration already exists at ${configPath}. Use --reset to overwrite.`,
+        `Configuration already exists at ${configPath}. ` +
+          'Use --reset to overwrite.',
       );
     } else {
-      const liteResult = writeLiteConfig(config, configExists ? configPath : undefined);
+      const liteResult = writeLiteConfig(
+        config,
+        configExists ? configPath : undefined,
+      );
       if (
         !handleStepResult(
           liteResult,
@@ -217,8 +218,9 @@ async function runInstall(config: InstallConfig): Promise<number> {
           printInfo(`Skipped: ${skill.name} (already installed)`);
         }
       }
+      const totalCustom = CUSTOM_SKILLS.length;
       printSuccess(
-        `${customSkillsInstalled}/${CUSTOM_SKILLS.length} custom skills processed`,
+        `${customSkillsInstalled}/${totalCustom} custom skills processed`,
       );
     }
   }
@@ -228,9 +230,10 @@ async function runInstall(config: InstallConfig): Promise<number> {
   console.log(formatConfigSummary());
   console.log();
 
-  console.log(
-    `${SYMBOLS.star} ${BOLD}${GREEN}${isUpdate ? 'Configuration updated!' : 'Installation complete!'}${RESET}`,
-  );
+  const statusMsg = isUpdate
+    ? 'Configuration updated!'
+    : 'Installation complete!';
+  console.log(`${SYMBOLS.star} ${BOLD}${GREEN}${statusMsg}${RESET}`);
   console.log();
   console.log(`${BOLD}Next steps:${RESET}`);
   console.log();
@@ -238,15 +241,16 @@ async function runInstall(config: InstallConfig): Promise<number> {
   console.log(`  1. Start OpenCode:`);
   console.log(`     ${BLUE}$ opencode${RESET}`);
   console.log();
-  console.log(
-    `${BOLD}Default configuration uses OpenAI models (gpt-5.4 / gpt-5.4-mini).${RESET}`,
-  );
-  console.log(
-    `${BOLD}For alternative providers (Kimi, GitHub Copilot, ZAI Coding Plan), see:${RESET}`,
-  );
-  console.log(
-    `  ${BLUE}https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/docs/provider-configurations.md${RESET}`,
-  );
+  const modelsInfo =
+    'Default configuration uses OpenAI models (gpt-5.4 / gpt-5.4-mini).';
+  console.log(`${BOLD}${modelsInfo}${RESET}`);
+  const altProviders =
+    'For alternative providers (Kimi, GitHub Copilot, ZAI Coding Plan)';
+  console.log(`${BOLD}${altProviders}, see:${RESET}`);
+  const docsUrl =
+    'https://github.com/alvinunreal/oh-my-opencode-slim/' +
+    'blob/master/docs/provider-configurations.md';
+  console.log(`  ${BLUE}${docsUrl}${RESET}`);
   console.log();
 
   return 0;

+ 0 - 11
src/cli/providers.ts

@@ -2,17 +2,6 @@ import { DEFAULT_AGENT_MCPS } from '../config/agent-mcps';
 import { RECOMMENDED_SKILLS } from './skills';
 import type { InstallConfig } from './types';
 
-const AGENT_NAMES = [
-  'orchestrator',
-  'oracle',
-  'designer',
-  'explorer',
-  'librarian',
-  'fixer',
-] as const;
-
-type AgentName = (typeof AGENT_NAMES)[number];
-
 // Model mappings by provider - only 4 supported providers
 export const MODEL_MAPPINGS = {
   openai: {

+ 7 - 0
src/index.ts

@@ -21,6 +21,7 @@ import {
   lsp_find_references,
   lsp_goto_definition,
   lsp_rename,
+  setUserLspConfig,
 } from './tools';
 import { startTmuxCheck } from './utils';
 import { log } from './utils/logger';
@@ -110,6 +111,12 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     mcp: mcps,
 
     config: async (opencodeConfig: Record<string, unknown>) => {
+      // Set user's lsp config from opencode.json for LSP tools
+      const lspConfig = opencodeConfig.lsp as
+        | Record<string, unknown>
+        | undefined;
+      setUserLspConfig(lspConfig);
+
       // Only set default_agent if not already configured by the user
       // and the plugin config doesn't explicitly disable this behavior
       if (

+ 1 - 0
src/tools/index.ts

@@ -9,4 +9,5 @@ export {
   lsp_goto_definition,
   lsp_rename,
   lspManager,
+  setUserLspConfig,
 } from './lsp';

+ 64 - 2
src/tools/lsp/client.ts

@@ -11,6 +11,7 @@ import {
   StreamMessageReader,
   StreamMessageWriter,
 } from 'vscode-jsonrpc/node';
+import { log } from '../../utils/logger';
 import { getLanguageId } from './config';
 import type { Diagnostic, ResolvedServer } from './types';
 
@@ -29,6 +30,7 @@ class LSPServerManager {
   private readonly IDLE_TIMEOUT = 5 * 60 * 1000;
 
   private constructor() {
+    log('[lsp] manager initialized');
     this.startCleanupTimer();
     this.registerProcessCleanup();
   }
@@ -95,17 +97,32 @@ class LSPServerManager {
     const managed = this.clients.get(key);
     if (managed) {
       if (managed.initPromise) {
+        log('[lsp] getClient: waiting for init', { key, server: server.id });
         await managed.initPromise;
       }
       if (managed.client.isAlive()) {
         managed.refCount++;
         managed.lastUsedAt = Date.now();
+        log('[lsp] getClient: reuse pooled client', {
+          key,
+          server: server.id,
+          refCount: managed.refCount,
+        });
         return managed.client;
       }
+      log('[lsp] getClient: client dead, recreating', {
+        key,
+        server: server.id,
+      });
       await managed.client.stop();
       this.clients.delete(key);
     }
 
+    log('[lsp] getClient: creating new client', {
+      key,
+      server: server.id,
+      root,
+    });
     const client = new LSPClient(root, server);
     const initPromise = (async () => {
       await client.start();
@@ -127,7 +144,13 @@ class LSPServerManager {
         m.initPromise = undefined;
         m.isInitializing = false;
       }
+      log('[lsp] getClient: client ready', { key, server: server.id });
     } catch (err) {
+      log('[lsp] getClient: init failed', {
+        key,
+        server: server.id,
+        error: String(err),
+      });
       this.clients.delete(key);
       throw err;
     }
@@ -141,6 +164,11 @@ class LSPServerManager {
     if (managed && managed.refCount > 0) {
       managed.refCount--;
       managed.lastUsedAt = Date.now();
+      log('[lsp] releaseClient', {
+        key,
+        server: serverId,
+        refCount: managed.refCount,
+      });
     }
   }
 
@@ -151,14 +179,19 @@ class LSPServerManager {
   }
 
   async stopAll(): Promise<void> {
-    for (const [, managed] of this.clients) {
+    log('[lsp] stopAll: shutting down all clients', {
+      count: this.clients.size,
+    });
+    for (const [key, managed] of this.clients) {
       await managed.client.stop();
+      log('[lsp] stopAll: client stopped', { key });
     }
     this.clients.clear();
     if (this.cleanupInterval) {
       clearInterval(this.cleanupInterval);
       this.cleanupInterval = null;
     }
+    log('[lsp] stopAll: complete');
   }
 }
 
@@ -178,6 +211,12 @@ export class LSPClient {
   ) {}
 
   async start(): Promise<void> {
+    log('[lsp] LSPClient.start: spawning server', {
+      server: this.server.id,
+      command: this.server.command.join(' '),
+      root: this.root,
+    });
+
     this.proc = spawn(this.server.command, {
       stdin: 'pipe',
       stdout: 'pipe',
@@ -274,11 +313,17 @@ export class LSPClient {
 
     if (this.proc.exitCode !== null) {
       const stderr = this.stderrBuffer.join('\n');
+      log('[lsp] LSPClient.start: server exited immediately', {
+        server: this.server.id,
+        exitCode: this.proc.exitCode,
+        stderr: stderr.slice(0, 500),
+      });
       throw new Error(
         `LSP server exited immediately with code ${this.proc.exitCode}` +
           (stderr ? `\nstderr: ${stderr}` : ''),
       );
     }
+    log('[lsp] LSPClient.start: server spawned', { server: this.server.id });
   }
 
   private startStderrReading(): void {
@@ -305,6 +350,11 @@ export class LSPClient {
   async initialize(): Promise<void> {
     if (!this.connection) throw new Error('LSP connection not established');
 
+    log('[lsp] LSPClient.initialize: sending initialize request', {
+      server: this.server.id,
+      root: this.root,
+    });
+
     const rootUri = pathToFileURL(this.root).href;
     await this.connection.sendRequest('initialize', {
       processId: process.pid,
@@ -336,16 +386,26 @@ export class LSPClient {
     });
     this.connection.sendNotification('initialized');
     await new Promise((r) => setTimeout(r, 300));
+    log('[lsp] LSPClient.initialize: complete', { server: this.server.id });
   }
 
   async openFile(filePath: string): Promise<void> {
     const absPath = resolve(filePath);
-    if (this.openedFiles.has(absPath)) return;
+    if (this.openedFiles.has(absPath)) {
+      log('[lsp] openFile: already open, skipping', { filePath: absPath });
+      return;
+    }
 
     const text = readFileSync(absPath, 'utf-8');
     const ext = extname(absPath);
     const languageId = getLanguageId(ext);
 
+    log('[lsp] openFile: opening document', {
+      filePath: absPath,
+      languageId,
+      size: text.length,
+    });
+
     this.connection?.sendNotification('textDocument/didOpen', {
       textDocument: {
         uri: pathToFileURL(absPath).href,
@@ -430,6 +490,7 @@ export class LSPClient {
   }
 
   async stop(): Promise<void> {
+    log('[lsp] LSPClient.stop: stopping', { server: this.server.id });
     try {
       if (this.connection) {
         await this.connection.sendRequest('shutdown');
@@ -442,5 +503,6 @@ export class LSPClient {
     this.connection = null;
     this.processExited = true;
     this.diagnosticsStore.clear();
+    log('[lsp] LSPClient.stop: complete', { server: this.server.id });
   }
 }

+ 416 - 0
src/tools/lsp/config-store.test.ts

@@ -0,0 +1,416 @@
+import { beforeEach, describe, expect, it } from 'bun:test';
+
+describe('LSP Config Store', () => {
+  let setUserLspConfig: (config: Record<string, unknown> | undefined) => void;
+  let getUserLspConfig: (
+    serverId: string,
+  ) => import('./config-store').UserLspConfig | undefined;
+  let getAllUserLspConfigs: () => Map<
+    string,
+    import('./config-store').UserLspConfig
+  >;
+  let hasUserLspConfig: () => boolean;
+
+  beforeEach(async () => {
+    // Import fresh module and clear state
+    const module = await import('./config-store');
+    setUserLspConfig = module.setUserLspConfig;
+    getUserLspConfig = module.getUserLspConfig;
+    getAllUserLspConfigs = module.getAllUserLspConfigs;
+    hasUserLspConfig = module.hasUserLspConfig;
+    setUserLspConfig(undefined);
+  });
+
+  describe('setUserLspConfig', () => {
+    it('clears the store when called with undefined', () => {
+      // First set some config
+      setUserLspConfig({
+        'typescript-language-server': {
+          command: ['typescript-language-server', '--stdio'],
+        },
+      });
+
+      expect(hasUserLspConfig()).toBe(true);
+
+      // Clear with undefined
+      setUserLspConfig(undefined);
+
+      expect(hasUserLspConfig()).toBe(false);
+    });
+
+    it('clears the store when called with empty object', () => {
+      // First set some config
+      setUserLspConfig({
+        'typescript-language-server': {
+          command: ['typescript-language-server', '--stdio'],
+        },
+      });
+
+      expect(hasUserLspConfig()).toBe(true);
+
+      // Clear with empty object
+      setUserLspConfig({});
+
+      expect(hasUserLspConfig()).toBe(false);
+    });
+
+    it('sets multiple servers correctly', () => {
+      const config = {
+        'typescript-language-server': {
+          command: ['typescript-language-server', '--stdio'],
+          extensions: ['ts', 'tsx'],
+        },
+        'eslint-language-server': {
+          command: ['vscode-eslint-language-server', '--stdio'],
+          extensions: ['js', 'jsx'],
+        },
+      };
+
+      setUserLspConfig(config);
+
+      const tsConfig = getUserLspConfig('typescript-language-server');
+      const eslintConfig = getUserLspConfig('eslint-language-server');
+
+      expect(tsConfig).toBeDefined();
+      expect(tsConfig?.id).toBe('typescript-language-server');
+      expect(tsConfig?.command).toEqual([
+        'typescript-language-server',
+        '--stdio',
+      ]);
+      expect(tsConfig?.extensions).toEqual(['ts', 'tsx']);
+
+      expect(eslintConfig).toBeDefined();
+      expect(eslintConfig?.id).toBe('eslint-language-server');
+      expect(eslintConfig?.command).toEqual([
+        'vscode-eslint-language-server',
+        '--stdio',
+      ]);
+      expect(eslintConfig?.extensions).toEqual(['js', 'jsx']);
+    });
+
+    it('handles all UserLspConfig fields', () => {
+      const config = {
+        'test-server': {
+          command: ['test-server', '--stdio'],
+          extensions: ['ts', 'js'],
+          disabled: true,
+          env: { NODE_ENV: 'test', DEBUG: 'true' },
+          initialization: { maxNumberOfProblems: 100 },
+        },
+      };
+
+      setUserLspConfig(config);
+
+      const serverConfig = getUserLspConfig('test-server');
+
+      expect(serverConfig).toBeDefined();
+      expect(serverConfig?.id).toBe('test-server');
+      expect(serverConfig?.command).toEqual(['test-server', '--stdio']);
+      expect(serverConfig?.extensions).toEqual(['ts', 'js']);
+      expect(serverConfig?.disabled).toBe(true);
+      expect(serverConfig?.env).toEqual({ NODE_ENV: 'test', DEBUG: 'true' });
+      expect(serverConfig?.initialization).toEqual({
+        maxNumberOfProblems: 100,
+      });
+    });
+
+    it('handles optional fields correctly', () => {
+      const config = {
+        'minimal-server': {
+          command: ['minimal-server'],
+        },
+      };
+
+      setUserLspConfig(config);
+
+      const serverConfig = getUserLspConfig('minimal-server');
+
+      expect(serverConfig).toBeDefined();
+      expect(serverConfig?.id).toBe('minimal-server');
+      expect(serverConfig?.command).toEqual(['minimal-server']);
+      expect(serverConfig?.extensions).toBeUndefined();
+      expect(serverConfig?.disabled).toBeUndefined();
+      expect(serverConfig?.env).toBeUndefined();
+      expect(serverConfig?.initialization).toBeUndefined();
+    });
+
+    it('ignores non-object entries', () => {
+      const config = {
+        'valid-server': {
+          command: ['valid-server'],
+        },
+        'invalid-null': null,
+        'invalid-string': 'not-an-object',
+        'invalid-number': 123,
+      };
+
+      setUserLspConfig(config);
+
+      expect(hasUserLspConfig()).toBe(true);
+      expect(getUserLspConfig('valid-server')).toBeDefined();
+      expect(getUserLspConfig('invalid-null')).toBeUndefined();
+      expect(getUserLspConfig('invalid-string')).toBeUndefined();
+      expect(getUserLspConfig('invalid-number')).toBeUndefined();
+    });
+
+    it('replaces existing config when called again', () => {
+      // Set initial config
+      setUserLspConfig({
+        'old-server': {
+          command: ['old-server'],
+        },
+      });
+
+      expect(getAllUserLspConfigs().size).toBe(1);
+
+      // Replace with new config
+      setUserLspConfig({
+        'new-server': {
+          command: ['new-server'],
+        },
+      });
+
+      expect(getAllUserLspConfigs().size).toBe(1);
+      expect(getUserLspConfig('old-server')).toBeUndefined();
+      expect(getUserLspConfig('new-server')).toBeDefined();
+    });
+  });
+
+  describe('getUserLspConfig', () => {
+    it('returns undefined for non-existent server', () => {
+      const config = getUserLspConfig('non-existent-server');
+
+      expect(config).toBeUndefined();
+    });
+
+    it('returns correct config for existing server', () => {
+      const config = {
+        'typescript-language-server': {
+          command: ['typescript-language-server', '--stdio'],
+          extensions: ['ts', 'tsx'],
+          disabled: false,
+          env: { NODE_ENV: 'production' },
+          initialization: { maxNumberOfProblems: 100 },
+        },
+      };
+
+      setUserLspConfig(config);
+
+      const serverConfig = getUserLspConfig('typescript-language-server');
+
+      expect(serverConfig).toBeDefined();
+      expect(serverConfig?.id).toBe('typescript-language-server');
+      expect(serverConfig?.command).toEqual([
+        'typescript-language-server',
+        '--stdio',
+      ]);
+      expect(serverConfig?.extensions).toEqual(['ts', 'tsx']);
+      expect(serverConfig?.disabled).toBe(false);
+      expect(serverConfig?.env).toEqual({ NODE_ENV: 'production' });
+      expect(serverConfig?.initialization).toEqual({
+        maxNumberOfProblems: 100,
+      });
+    });
+
+    it('returns reference - mutation affects store', () => {
+      const config = {
+        'test-server': {
+          command: ['test-server'],
+          extensions: ['ts'],
+          env: { KEY: 'value' },
+          initialization: { setting: 'original' },
+        },
+      };
+
+      setUserLspConfig(config);
+
+      const serverConfig = getUserLspConfig('test-server');
+
+      expect(serverConfig).toBeDefined();
+
+      // Mutate the returned config
+      if (serverConfig) {
+        serverConfig.command = ['modified-command'];
+        serverConfig.extensions = ['js'];
+        if (serverConfig.env) {
+          serverConfig.env.KEY = 'modified';
+        }
+        if (serverConfig.initialization) {
+          serverConfig.initialization.setting = 'modified';
+        }
+      }
+
+      // Get fresh copy - should be affected (same reference)
+      const freshConfig = getUserLspConfig('test-server');
+
+      expect(freshConfig?.command).toEqual(['modified-command']);
+      expect(freshConfig?.extensions).toEqual(['js']);
+      expect(freshConfig?.env).toEqual({ KEY: 'modified' });
+      expect(freshConfig?.initialization).toEqual({ setting: 'modified' });
+    });
+  });
+
+  describe('getAllUserLspConfigs', () => {
+    it('returns empty map when no config set', () => {
+      const allConfigs = getAllUserLspConfigs();
+
+      expect(allConfigs).toBeInstanceOf(Map);
+      expect(allConfigs.size).toBe(0);
+    });
+
+    it('returns all configured servers', () => {
+      const config = {
+        'server-1': {
+          command: ['server-1'],
+          extensions: ['ts'],
+        },
+        'server-2': {
+          command: ['server-2'],
+          extensions: ['js'],
+        },
+        'server-3': {
+          command: ['server-3'],
+          extensions: ['py'],
+        },
+      };
+
+      setUserLspConfig(config);
+
+      const allConfigs = getAllUserLspConfigs();
+
+      expect(allConfigs.size).toBe(3);
+      expect(allConfigs.get('server-1')?.id).toBe('server-1');
+      expect(allConfigs.get('server-2')?.id).toBe('server-2');
+      expect(allConfigs.get('server-3')?.id).toBe('server-3');
+    });
+
+    it('returns new Map with same value references - mutation affects store', () => {
+      const config = {
+        'test-server': {
+          command: ['test-server'],
+          extensions: ['ts'],
+          env: { KEY: 'value' },
+          initialization: { setting: 'original' },
+        },
+      };
+
+      setUserLspConfig(config);
+
+      const allConfigs = getAllUserLspConfigs();
+
+      // Mutate the returned map's value
+      const serverConfig = allConfigs.get('test-server');
+      if (serverConfig) {
+        serverConfig.command = ['modified-command'];
+        if (serverConfig.env) {
+          serverConfig.env.KEY = 'modified';
+        }
+      }
+
+      // Get fresh copy - should be affected (same reference)
+      const freshConfig = getUserLspConfig('test-server');
+
+      expect(freshConfig?.command).toEqual(['modified-command']);
+      expect(freshConfig?.env).toEqual({ KEY: 'modified' });
+    });
+
+    it('returns a new Map instance each time', () => {
+      setUserLspConfig({
+        'test-server': {
+          command: ['test-server'],
+        },
+      });
+
+      const map1 = getAllUserLspConfigs();
+      const map2 = getAllUserLspConfigs();
+
+      expect(map1).not.toBe(map2);
+      expect(map1.size).toBe(map2.size);
+    });
+  });
+
+  describe('hasUserLspConfig', () => {
+    it('returns false when store is empty', () => {
+      expect(hasUserLspConfig()).toBe(false);
+    });
+
+    it('returns true when config exists', () => {
+      setUserLspConfig({
+        'test-server': {
+          command: ['test-server'],
+        },
+      });
+
+      expect(hasUserLspConfig()).toBe(true);
+    });
+
+    it('returns false after clearing config', () => {
+      setUserLspConfig({
+        'test-server': {
+          command: ['test-server'],
+        },
+      });
+
+      expect(hasUserLspConfig()).toBe(true);
+
+      setUserLspConfig(undefined);
+
+      expect(hasUserLspConfig()).toBe(false);
+    });
+
+    it('returns true for multiple servers', () => {
+      setUserLspConfig({
+        'server-1': { command: ['server-1'] },
+        'server-2': { command: ['server-2'] },
+        'server-3': { command: ['server-3'] },
+      });
+
+      expect(hasUserLspConfig()).toBe(true);
+    });
+  });
+
+  describe('integration tests', () => {
+    it('handles complete workflow: set, get, check, clear', () => {
+      // Initial state
+      expect(hasUserLspConfig()).toBe(false);
+      expect(getAllUserLspConfigs().size).toBe(0);
+
+      // Set config
+      const config = {
+        'typescript-language-server': {
+          command: ['typescript-language-server', '--stdio'],
+          extensions: ['ts', 'tsx'],
+          disabled: false,
+          env: { NODE_ENV: 'development' },
+          initialization: { maxNumberOfProblems: 100 },
+        },
+        'eslint-language-server': {
+          command: ['vscode-eslint-language-server', '--stdio'],
+          extensions: ['js', 'jsx'],
+        },
+      };
+
+      setUserLspConfig(config);
+
+      // Verify config is set
+      expect(hasUserLspConfig()).toBe(true);
+      expect(getAllUserLspConfigs().size).toBe(2);
+
+      // Get specific server
+      const tsConfig = getUserLspConfig('typescript-language-server');
+      expect(tsConfig?.id).toBe('typescript-language-server');
+      expect(tsConfig?.command).toEqual([
+        'typescript-language-server',
+        '--stdio',
+      ]);
+
+      // Clear config
+      setUserLspConfig(undefined);
+
+      // Verify config is cleared
+      expect(hasUserLspConfig()).toBe(false);
+      expect(getAllUserLspConfigs().size).toBe(0);
+      expect(getUserLspConfig('typescript-language-server')).toBeUndefined();
+    });
+  });
+});

+ 70 - 0
src/tools/lsp/config-store.ts

@@ -0,0 +1,70 @@
+// LSP Config Store - Holds OpenCode's lsp config for runtime access
+// This allows the config hook to set the lsp config once,
+// and the LSP tools to read it at execution time.
+
+/**
+ * User-provided LSP server config (from opencode.json lsp section).
+ * Fields are optional because user config may not include all properties.
+ */
+export interface UserLspConfig {
+  id: string;
+  command?: string[];
+  extensions?: string[];
+  disabled?: boolean;
+  env?: Record<string, string>;
+  initialization?: Record<string, unknown>;
+}
+
+/**
+ * Module-level store for OpenCode's lsp configuration.
+ * Set during plugin initialization via the config hook.
+ */
+const userConfig = new Map<string, UserLspConfig>();
+
+/**
+ * Set the user's lsp config from opencode.json.
+ * Called during plugin initialization.
+ */
+export function setUserLspConfig(
+  config: Record<string, unknown> | undefined,
+): void {
+  userConfig.clear();
+  if (config) {
+    for (const [id, server] of Object.entries(config)) {
+      if (server && typeof server === 'object') {
+        const s = server as Record<string, unknown>;
+        userConfig.set(id, {
+          id,
+          command: s.command as string[] | undefined,
+          extensions: s.extensions as string[] | undefined,
+          disabled: s.disabled as boolean | undefined,
+          env: s.env as Record<string, string> | undefined,
+          initialization: s.initialization as
+            | Record<string, unknown>
+            | undefined,
+        });
+      }
+    }
+  }
+}
+
+/**
+ * Get the user's lsp config for a specific server ID.
+ */
+export function getUserLspConfig(serverId: string): UserLspConfig | undefined {
+  return userConfig.get(serverId);
+}
+
+/**
+ * Get all user-configured lsp servers.
+ */
+export function getAllUserLspConfigs(): Map<string, UserLspConfig> {
+  return new Map(userConfig);
+}
+
+/**
+ * Check if user has configured any lsp servers.
+ */
+export function hasUserLspConfig(): boolean {
+  return userConfig.size > 0;
+}

+ 19 - 13
src/tools/lsp/config.test.ts

@@ -10,6 +10,13 @@ mock.module('os', () => ({
   homedir: () => '/home/user',
 }));
 
+// Create a mock for which.sync
+const whichSyncMock = mock(() => null);
+mock.module('which', () => ({
+  sync: whichSyncMock,
+  default: { sync: whichSyncMock },
+}));
+
 import { existsSync } from 'node:fs';
 // Now import the code to test
 import { findServerForExtension, isServerInstalled } from './config';
@@ -18,6 +25,8 @@ describe('config', () => {
   beforeEach(() => {
     (existsSync as any).mockClear();
     (existsSync as any).mockImplementation(() => false);
+    whichSyncMock.mockClear();
+    whichSyncMock.mockReturnValue(null);
   });
 
   describe('isServerInstalled', () => {
@@ -37,9 +46,9 @@ describe('config', () => {
       const originalPath = process.env.PATH;
       process.env.PATH = '/usr/local/bin:/usr/bin';
 
-      (existsSync as any).mockImplementation(
-        (path: string) =>
-          path === join('/usr/bin', 'typescript-language-server'),
+      // Mock whichSync to return a path (simulating the command is found)
+      whichSyncMock.mockReturnValue(
+        join('/usr/bin', 'typescript-language-server'),
       );
 
       expect(isServerInstalled(['typescript-language-server'])).toBe(true);
@@ -72,9 +81,8 @@ describe('config', () => {
         'typescript-language-server',
       );
 
-      (existsSync as any).mockImplementation(
-        (path: string) => path === globalBin,
-      );
+      // Mock whichSync to return the global bin path
+      whichSyncMock.mockReturnValue(globalBin);
 
       expect(isServerInstalled(['typescript-language-server'])).toBe(true);
     });
@@ -86,16 +94,16 @@ describe('config', () => {
       const result = findServerForExtension('.ts');
       expect(result.status).toBe('found');
       if (result.status === 'found') {
-        expect(result.server.id).toBe('typescript');
+        expect(result.server.id).toBe('deno');
       }
     });
 
-    test('should return found for .py extension if installed (prefers basedpyright)', () => {
+    test('should return found for .py extension if installed (prefers ty)', () => {
       (existsSync as any).mockReturnValue(true);
       const result = findServerForExtension('.py');
       expect(result.status).toBe('found');
       if (result.status === 'found') {
-        expect(result.server.id).toBe('basedpyright');
+        expect(result.server.id).toBe('ty');
       }
     });
 
@@ -109,10 +117,8 @@ describe('config', () => {
       const result = findServerForExtension('.ts');
       expect(result.status).toBe('not_installed');
       if (result.status === 'not_installed') {
-        expect(result.server.id).toBe('typescript');
-        expect(result.installHint).toContain(
-          'npm install -g typescript-language-server',
-        );
+        expect(result.server.id).toBe('deno');
+        expect(result.installHint).toContain('Install Deno');
       }
     });
   });

+ 104 - 22
src/tools/lsp/config.ts

@@ -1,19 +1,102 @@
-// Simplified LSP config - just PATH lookup, no multi-tier config merging
+// Simplified LSP config - uses OpenCode's lsp config from opencode.json
+// Falls back to BUILTIN_SERVERS if no user config exists
 
 import { existsSync } from 'node:fs';
 import { homedir } from 'node:os';
 import { join } from 'node:path';
-import { BUILTIN_SERVERS, EXT_TO_LANG, LSP_INSTALL_HINTS } from './constants';
+import whichSync from 'which';
+import { getAllUserLspConfigs, hasUserLspConfig } from './config-store';
+import {
+  BUILTIN_SERVERS,
+  LANGUAGE_EXTENSIONS,
+  LSP_INSTALL_HINTS,
+} from './constants';
 import type { ResolvedServer, ServerLookupResult } from './types';
 
-export function findServerForExtension(ext: string): ServerLookupResult {
-  // Find matching server
+/**
+ * Merged server config that combines built-in and user config.
+ */
+interface MergedServerConfig {
+  id: string;
+  command: string[];
+  extensions: string[];
+  root?: (file: string) => string | undefined;
+  env?: Record<string, string>;
+  initialization?: Record<string, unknown>;
+}
+
+/**
+ * Build the merged server list by combining built-in servers with user config.
+ * This mirrors OpenCode core's pattern: start with built-in, then merge user config.
+ */
+function buildMergedServers(): Map<string, MergedServerConfig> {
+  const servers = new Map<string, MergedServerConfig>();
+
+  // Start with built-in servers
   for (const [id, config] of Object.entries(BUILTIN_SERVERS)) {
+    servers.set(id, {
+      id,
+      command: config.command,
+      extensions: config.extensions,
+      root: config.root,
+      env: config.env,
+      initialization: config.initialization,
+    });
+  }
+
+  // Apply user config (merge with existing or add new)
+  if (hasUserLspConfig()) {
+    for (const [id, userConfig] of getAllUserLspConfigs()) {
+      // Handle disabled: remove built-in from consideration
+      if (userConfig.disabled === true) {
+        servers.delete(id);
+        continue;
+      }
+
+      const existing = servers.get(id);
+
+      if (existing) {
+        // Merge user config with built-in, preserving root function from built-in
+        servers.set(id, {
+          ...existing,
+          id,
+          // User config overrides command if provided
+          command: userConfig.command ?? existing.command,
+          // User config overrides extensions if provided
+          extensions: userConfig.extensions ?? existing.extensions,
+          // Preserve root function from built-in (not overrideable)
+          root: existing.root,
+          // User config overrides env/initialization
+          env: userConfig.env ?? existing.env,
+          initialization: userConfig.initialization ?? existing.initialization,
+        });
+      } else {
+        // New server defined by user config
+        servers.set(id, {
+          id,
+          command: userConfig.command ?? [],
+          extensions: userConfig.extensions ?? [],
+          root: undefined,
+          env: userConfig.env,
+          initialization: userConfig.initialization,
+        });
+      }
+    }
+  }
+
+  return servers;
+}
+
+export function findServerForExtension(ext: string): ServerLookupResult {
+  const servers = buildMergedServers();
+
+  for (const [, config] of servers) {
     if (config.extensions.includes(ext)) {
       const server: ResolvedServer = {
-        id,
+        id: config.id,
         command: config.command,
         extensions: config.extensions,
+        root: config.root,
         env: config.env,
         initialization: config.initialization,
       };
@@ -26,7 +109,7 @@ export function findServerForExtension(ext: string): ServerLookupResult {
         status: 'not_installed',
         server,
         installHint:
-          LSP_INSTALL_HINTS[id] ||
+          LSP_INSTALL_HINTS[config.id] ||
           `Install '${config.command[0]}' and add to PATH`,
       };
     }
@@ -36,7 +119,7 @@ export function findServerForExtension(ext: string): ServerLookupResult {
 }
 
 export function getLanguageId(ext: string): string {
-  return EXT_TO_LANG[ext] || 'plaintext';
+  return LANGUAGE_EXTENSIONS[ext] || 'plaintext';
 }
 
 export function isServerInstalled(command: string[]): boolean {
@@ -52,29 +135,28 @@ export function isServerInstalled(command: string[]): boolean {
   const isWindows = process.platform === 'win32';
   const ext = isWindows ? '.exe' : '';
 
-  // Check PATH
-  const pathEnv = process.env.PATH || '';
-  const pathSeparator = isWindows ? ';' : ':';
-  const paths = pathEnv.split(pathSeparator);
+  // Check PATH using which (mirrors core's approach)
+  // Include ~/.config/opencode/bin in the search path
+  const opencodeBin = join(homedir(), '.config', 'opencode', 'bin');
+  const searchPath =
+    (process.env.PATH ?? '') + (isWindows ? ';' : ':') + opencodeBin;
 
-  for (const p of paths) {
-    if (existsSync(join(p, cmd)) || existsSync(join(p, cmd + ext))) {
-      return true;
-    }
+  const result = whichSync.sync(cmd, {
+    path: searchPath,
+    pathExt: isWindows ? process.env.PATHEXT : undefined,
+    nothrow: true,
+  });
+
+  if (result !== null) {
+    return true;
   }
 
-  // Check local node_modules
+  // Check local node_modules (where npm/yarn/pnpm install binaries)
   const cwd = process.cwd();
   const localBin = join(cwd, 'node_modules', '.bin', cmd);
   if (existsSync(localBin) || existsSync(localBin + ext)) {
     return true;
   }
 
-  // Check global opencode bin
-  const globalBin = join(homedir(), '.config', 'opencode', 'bin', cmd);
-  if (existsSync(globalBin) || existsSync(globalBin + ext)) {
-    return true;
-  }
-
   return false;
 }

+ 103 - 0
src/tools/lsp/constants.test.ts

@@ -0,0 +1,103 @@
+import { describe, expect, test } from 'bun:test';
+import {
+  DEFAULT_MAX_DIAGNOSTICS,
+  DEFAULT_MAX_REFERENCES,
+  SEVERITY_MAP,
+  SYMBOL_KIND_MAP,
+} from './constants';
+
+describe('constants', () => {
+  describe('SYMBOL_KIND_MAP', () => {
+    test('should have correct number of symbol kinds', () => {
+      expect(Object.keys(SYMBOL_KIND_MAP).length).toBe(26);
+    });
+
+    test('should map File to 1', () => {
+      expect(SYMBOL_KIND_MAP[1]).toBe('File');
+    });
+
+    test('should map Module to 2', () => {
+      expect(SYMBOL_KIND_MAP[2]).toBe('Module');
+    });
+
+    test('should map Class to 5', () => {
+      expect(SYMBOL_KIND_MAP[5]).toBe('Class');
+    });
+
+    test('should map Method to 6', () => {
+      expect(SYMBOL_KIND_MAP[6]).toBe('Method');
+    });
+
+    test('should map Function to 12', () => {
+      expect(SYMBOL_KIND_MAP[12]).toBe('Function');
+    });
+
+    test('should map Variable to 13', () => {
+      expect(SYMBOL_KIND_MAP[13]).toBe('Variable');
+    });
+
+    test('should map Constant to 14', () => {
+      expect(SYMBOL_KIND_MAP[14]).toBe('Constant');
+    });
+
+    test('should map TypeParameter to 26', () => {
+      expect(SYMBOL_KIND_MAP[26]).toBe('TypeParameter');
+    });
+  });
+
+  describe('SEVERITY_MAP', () => {
+    test('should have correct number of severity levels', () => {
+      expect(Object.keys(SEVERITY_MAP).length).toBe(4);
+    });
+
+    test('should map 1 to error', () => {
+      expect(SEVERITY_MAP[1]).toBe('error');
+    });
+
+    test('should map 2 to warning', () => {
+      expect(SEVERITY_MAP[2]).toBe('warning');
+    });
+
+    test('should map 3 to information', () => {
+      expect(SEVERITY_MAP[3]).toBe('information');
+    });
+
+    test('should map 4 to hint', () => {
+      expect(SEVERITY_MAP[4]).toBe('hint');
+    });
+  });
+
+  describe('DEFAULT_MAX_REFERENCES', () => {
+    test('should be 200', () => {
+      expect(DEFAULT_MAX_REFERENCES).toBe(200);
+    });
+  });
+
+  describe('DEFAULT_MAX_DIAGNOSTICS', () => {
+    test('should be 200', () => {
+      expect(DEFAULT_MAX_DIAGNOSTICS).toBe(200);
+    });
+  });
+});
+
+// Note: NearestRoot tests require complex file system mocking
+// and are better suited for integration tests with real directory structures.
+// The constants themselves (maps and default values) are tested above.
+describe('NearestRoot', () => {
+  test('should be exported as a function', () => {
+    const { NearestRoot } = require('./constants');
+    expect(typeof NearestRoot).toBe('function');
+  });
+
+  test('should return a function when called', () => {
+    const { NearestRoot } = require('./constants');
+    const rootFn = NearestRoot(['package.json']);
+    expect(typeof rootFn).toBe('function');
+  });
+
+  test('should accept optional exclude patterns', () => {
+    const { NearestRoot } = require('./constants');
+    const rootFn = NearestRoot(['package.json'], ['deno.json']);
+    expect(typeof rootFn).toBe('function');
+  });
+});

+ 663 - 38
src/tools/lsp/constants.ts

@@ -1,6 +1,9 @@
-// Slim LSP constants - only essential languages
+// LSP constants - mirrors OpenCode core servers (no auto-download)
+// All server definitions from OpenCode's LSPServer namespace
 
-import type { LSPServerConfig } from './types';
+import { existsSync, readdirSync, statSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import type { LSPServerConfig, RootFunction } from './types';
 
 export const SYMBOL_KIND_MAP: Record<number, string> = {
   1: 'File',
@@ -41,25 +44,121 @@ export const SEVERITY_MAP: Record<number, string> = {
 export const DEFAULT_MAX_REFERENCES = 200;
 export const DEFAULT_MAX_DIAGNOSTICS = 200;
 
-// Slim server list - common languages + popular frontend
+// Common root patterns shared by multiple servers
+const LOCK_FILE_PATTERNS = [
+  'package-lock.json',
+  'bun.lockb',
+  'bun.lock',
+  'pnpm-lock.yaml',
+  'yarn.lock',
+];
+
+/**
+ * Generator that walks up the directory tree yielding each directory.
+ * Mirrors OpenCode core's Filesystem.up() utility.
+ */
+function* walkUpDirectories(
+  start: string,
+  stop: string,
+): Generator<string, void, unknown> {
+  let dir = resolve(start);
+
+  // If start is a file (not a directory), start from its parent
+  try {
+    if (!statSync(dir).isDirectory()) {
+      dir = dirname(dir);
+    }
+  } catch {
+    dir = dirname(dir);
+  }
+
+  let prevDir = '';
+  while (dir !== prevDir && dir !== '/') {
+    yield dir;
+    prevDir = dir;
+    if (dir === stop) break;
+    dir = dirname(dir);
+  }
+}
+
+/**
+ * NearestRoot helper - mirrors OpenCode core's NearestRoot function.
+ * Creates a RootFunction that walks up directories looking for root markers.
+ */
+export function NearestRoot(
+  includePatterns: string[],
+  excludePatterns?: string[],
+): RootFunction {
+  return (file: string): string | undefined => {
+    const cwd = process.cwd();
+
+    // Check for exclusion patterns first
+    if (excludePatterns) {
+      for (const dir of walkUpDirectories(file, cwd)) {
+        for (const pattern of excludePatterns) {
+          if (existsSync(`${dir}/${pattern}`)) {
+            // Found an exclusion marker - this server should not activate
+            return undefined;
+          }
+        }
+      }
+    }
+
+    // Find nearest root pattern
+    for (const dir of walkUpDirectories(file, cwd)) {
+      for (const pattern of includePatterns) {
+        if (pattern.includes('*')) {
+          // Handle glob patterns (e.g., *.xcodeproj, *.tf)
+          try {
+            const entries = readdirSync(dir);
+            const regex = new RegExp(
+              `^${pattern.replace(/\./g, '\\.').replace(/\*/g, '.*')}$`,
+            );
+            if (entries.some((entry) => regex.test(entry))) {
+              return dir;
+            }
+          } catch {
+            // Skip directories that can't be read
+          }
+        } else if (existsSync(`${dir}/${pattern}`)) {
+          return dir;
+        }
+      }
+    }
+
+    // No root found - return undefined (let caller decide fallback)
+    return undefined;
+  };
+}
+
+/**
+ * Built-in LSP servers - mirrors OpenCode core LSPServer namespace.
+ * User configuration from opencode.json lsp section takes precedence and is
+ * merged on top of these: user settings override command/extensions/env, while
+ * root patterns are always preserved from built-in. Servers can be removed by
+ * setting `"disabled": true` in the user config.
+ */
 export const BUILTIN_SERVERS: Record<string, Omit<LSPServerConfig, 'id'>> = {
-  // JavaScript/TypeScript ecosystem
+  // ============ JavaScript/TypeScript Ecosystem ============
+
+  deno: {
+    command: ['deno', 'lsp'],
+    extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs'],
+    root: NearestRoot(['deno.json', 'deno.jsonc']),
+  },
+
   typescript: {
     command: ['typescript-language-server', '--stdio'],
     extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'],
+    root: NearestRoot(LOCK_FILE_PATTERNS, ['deno.json', 'deno.jsonc']),
   },
+
   vue: {
     command: ['vue-language-server', '--stdio'],
     extensions: ['.vue'],
+    root: NearestRoot(LOCK_FILE_PATTERNS),
   },
-  svelte: {
-    command: ['svelteserver', '--stdio'],
-    extensions: ['.svelte'],
-  },
-  astro: {
-    command: ['astro-ls', '--stdio'],
-    extensions: ['.astro'],
-  },
+
   eslint: {
     command: ['vscode-eslint-language-server', '--stdio'],
     extensions: [
@@ -69,59 +168,414 @@ export const BUILTIN_SERVERS: Record<string, Omit<LSPServerConfig, 'id'>> = {
       '.jsx',
       '.mjs',
       '.cjs',
+      '.mts',
+      '.cts',
+      '.vue',
+    ],
+    root: NearestRoot(LOCK_FILE_PATTERNS),
+  },
+
+  oxlint: {
+    command: ['oxlint', '--lsp'],
+    extensions: [
+      '.ts',
+      '.tsx',
+      '.js',
+      '.jsx',
+      '.mjs',
+      '.cjs',
+      '.mts',
+      '.cts',
       '.vue',
+      '.astro',
       '.svelte',
     ],
+    root: NearestRoot([
+      '.oxlintrc.json',
+      ...LOCK_FILE_PATTERNS,
+      'package.json',
+    ]),
   },
-  tailwindcss: {
-    command: ['tailwindcss-language-server', '--stdio'],
-    extensions: ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'],
+
+  biome: {
+    command: ['biome', 'lsp-proxy', '--stdio'],
+    extensions: [
+      '.ts',
+      '.tsx',
+      '.js',
+      '.jsx',
+      '.mjs',
+      '.cjs',
+      '.mts',
+      '.cts',
+      '.json',
+      '.jsonc',
+      '.vue',
+      '.astro',
+      '.svelte',
+      '.css',
+      '.graphql',
+      '.gql',
+      '.html',
+    ],
+    root: NearestRoot(['biome.json', 'biome.jsonc', ...LOCK_FILE_PATTERNS]),
   },
-  // Backend languages
+
+  // ============ Backend Languages ============
+
   gopls: {
     command: ['gopls'],
     extensions: ['.go'],
+    root: NearestRoot(['go.work', 'go.mod', 'go.sum']),
   },
-  rust: {
-    command: ['rust-analyzer'],
-    extensions: ['.rs'],
+
+  ruby_lsp: {
+    // Note: uses rubocop --lsp (RuboCop's built-in LSP linting mode),
+    // not the separate ruby-lsp gem. Users wanting full ruby-lsp features
+    // should configure: { command: ["ruby-lsp"] }
+    command: ['rubocop', '--lsp'],
+    extensions: ['.rb', '.rake', '.gemspec', '.ru'],
+    root: NearestRoot(['Gemfile']),
   },
-  basedpyright: {
-    command: ['basedpyright-langserver', '--stdio'],
+
+  ty: {
+    command: ['ty', 'server'],
     extensions: ['.py', '.pyi'],
+    root: NearestRoot([
+      'pyproject.toml',
+      'ty.toml',
+      'setup.py',
+      'setup.cfg',
+      'requirements.txt',
+      'Pipfile',
+      'pyrightconfig.json',
+    ]),
   },
+
   pyright: {
     command: ['pyright-langserver', '--stdio'],
     extensions: ['.py', '.pyi'],
+    root: NearestRoot([
+      'pyproject.toml',
+      'setup.py',
+      'setup.cfg',
+      'requirements.txt',
+      'Pipfile',
+      'pyrightconfig.json',
+    ]),
   },
-  clangd: {
-    command: ['clangd', '--background-index'],
-    extensions: ['.c', '.cpp', '.cc', '.cxx', '.h', '.hpp'],
+
+  elixir_ls: {
+    command: ['elixir-ls'],
+    extensions: ['.ex', '.exs'],
+    root: NearestRoot(['mix.exs', 'mix.lock']),
   },
+
   zls: {
     command: ['zls'],
-    extensions: ['.zig'],
+    extensions: ['.zig', '.zon'],
+    root: NearestRoot(['build.zig']),
+  },
+
+  // ============ .NET Languages ============
+
+  csharp: {
+    command: ['csharp-ls'],
+    extensions: ['.cs'],
+    root: NearestRoot(['.slnx', '.sln', '.csproj', 'global.json']),
+  },
+
+  fsharp: {
+    command: ['fsautocomplete'],
+    extensions: ['.fs', '.fsi', '.fsx', '.fsscript'],
+    root: NearestRoot(['.slnx', '.sln', '.fsproj', 'global.json']),
+  },
+
+  // ============ Apple Languages ============
+
+  sourcekit_lsp: {
+    command: ['sourcekit-lsp'],
+    extensions: ['.swift', '.objc', '.objcpp'],
+    root: NearestRoot(['Package.swift', '*.xcodeproj', '*.xcworkspace']),
+  },
+
+  // ============ Rust ============
+
+  rust: {
+    command: ['rust-analyzer'],
+    extensions: ['.rs'],
+    root: NearestRoot(['Cargo.toml', 'Cargo.lock']),
+  },
+
+  // ============ C/C++ ============
+
+  clangd: {
+    command: ['clangd', '--background-index', '--clang-tidy'],
+    extensions: [
+      '.c',
+      '.cpp',
+      '.cc',
+      '.cxx',
+      '.c++',
+      '.h',
+      '.hpp',
+      '.hh',
+      '.hxx',
+      '.h++',
+    ],
+    root: NearestRoot([
+      'compile_commands.json',
+      'compile_flags.txt',
+      '.clangd',
+      'CMakeLists.txt',
+      'Makefile',
+    ]),
+  },
+
+  // ============ Frontend Frameworks ============
+
+  svelte: {
+    command: ['svelteserver', '--stdio'],
+    extensions: ['.svelte'],
+    root: NearestRoot(LOCK_FILE_PATTERNS),
+  },
+
+  astro: {
+    command: ['astro-ls', '--stdio'],
+    extensions: ['.astro'],
+    root: NearestRoot(LOCK_FILE_PATTERNS),
+  },
+
+  // ============ Java/JVM Languages ============
+
+  jdtls: {
+    // Complex java -jar invocation - requires special setup
+    // Users should configure their own command for JDTLS
+    command: ['jdtls'],
+    extensions: ['.java'],
+    root: NearestRoot([
+      'pom.xml',
+      'build.gradle',
+      'build.gradle.kts',
+      '.project',
+      '.classpath',
+    ]),
+  },
+
+  kotlin_ls: {
+    command: ['kotlin-lsp', '--stdio'],
+    extensions: ['.kt', '.kts'],
+    root: NearestRoot([
+      'settings.gradle.kts',
+      'settings.gradle',
+      'gradlew',
+      'build.gradle.kts',
+      'build.gradle',
+      'pom.xml',
+    ]),
+  },
+
+  // ============ Config/ Markup Languages ============
+
+  yaml_ls: {
+    command: ['yaml-language-server', '--stdio'],
+    extensions: ['.yaml', '.yml'],
+    root: NearestRoot(LOCK_FILE_PATTERNS),
+  },
+
+  lua_ls: {
+    command: ['lua-language-server'],
+    extensions: ['.lua'],
+    root: NearestRoot([
+      '.luarc.json',
+      '.luarc.jsonc',
+      '.luacheckrc',
+      'stylua.toml',
+      'selene.toml',
+      'selene.yml',
+    ]),
+  },
+
+  php_intelephense: {
+    command: ['intelephense', '--stdio'],
+    extensions: ['.php'],
+    root: NearestRoot(['composer.json', 'composer.lock', '.php-version']),
+  },
+
+  prisma: {
+    command: ['prisma', 'language-server'],
+    extensions: ['.prisma'],
+    root: NearestRoot(['schema.prisma', 'prisma/schema.prisma', 'prisma']),
+  },
+
+  dart: {
+    command: ['dart', 'language-server', '--lsp'],
+    extensions: ['.dart'],
+    root: NearestRoot(['pubspec.yaml', 'analysis_options.yaml']),
+  },
+
+  ocaml_lsp: {
+    command: ['ocamllsp'],
+    extensions: ['.ml', '.mli'],
+    root: NearestRoot(['dune-project', 'dune-workspace', '.merlin', 'opam']),
+  },
+
+  // ============ Shell/Scripts ============
+
+  bash: {
+    command: ['bash-language-server', 'start'],
+    extensions: ['.sh', '.bash', '.zsh', '.ksh'],
+    root: undefined, // No root detection needed - uses file directory
+  },
+
+  // ============ Infrastructure/ DevOps ============
+
+  terraform_ls: {
+    command: ['terraform-ls', 'serve'],
+    extensions: ['.tf', '.tfvars'],
+    root: NearestRoot(['.terraform.lock.hcl', 'terraform.tfstate', '*.tf']),
+  },
+
+  // ============ Document/ Publishing ============
+
+  texlab: {
+    command: ['texlab'],
+    extensions: ['.tex', '.bib'],
+    root: NearestRoot(['.latexmkrc', 'latexmkrc', '.texlabroot', 'texlabroot']),
+  },
+
+  dockerfile: {
+    command: ['docker-langserver', '--stdio'],
+    extensions: ['.dockerfile', 'Dockerfile'],
+    root: undefined, // No root detection needed - uses file directory
+  },
+
+  // ============ Functional Languages ============
+
+  gleam: {
+    command: ['gleam', 'lsp'],
+    extensions: ['.gleam'],
+    root: NearestRoot(['gleam.toml']),
+  },
+
+  clojure_lsp: {
+    command: ['clojure-lsp', 'listen'],
+    extensions: ['.clj', '.cljs', '.cljc', '.edn'],
+    root: NearestRoot([
+      'deps.edn',
+      'project.clj',
+      'shadow-cljs.edn',
+      'bb.edn',
+      'build.boot',
+    ]),
+  },
+
+  nixd: {
+    command: ['nixd'],
+    extensions: ['.nix'],
+    root: NearestRoot(['flake.nix']),
+  },
+
+  tinymist: {
+    command: ['tinymist'],
+    extensions: ['.typ', '.typc'],
+    root: NearestRoot(['typst.toml']),
+  },
+
+  haskell_language_server: {
+    command: ['haskell-language-server-wrapper', '--lsp'],
+    extensions: ['.hs', '.lhs'],
+    root: NearestRoot(['stack.yaml', 'cabal.project', 'hie.yaml', '*.cabal']),
+  },
+
+  julials: {
+    command: [
+      'julia',
+      '--startup-file=no',
+      '--history-file=no',
+      '-e',
+      'using LanguageServer; runserver()',
+    ],
+    extensions: ['.jl'],
+    root: NearestRoot(['Project.toml', 'Manifest.toml', '*.jl']),
   },
 };
 
 export const LSP_INSTALL_HINTS: Record<string, string> = {
+  // JavaScript/TypeScript Ecosystem
+  deno: 'Install Deno: https://deno.land/#installation',
   typescript: 'npm install -g typescript-language-server typescript',
   vue: 'npm install -g @vue/language-server',
-  svelte: 'npm install -g svelte-language-server',
-  astro: 'npm install -g @astrojs/language-server',
   eslint: 'npm install -g vscode-langservers-extracted',
-  tailwindcss: 'npm install -g @tailwindcss/language-server',
+  oxlint: 'npm install -g oxlint or install via package manager',
+  biome: 'npm install -g @biomejs/biome',
+
+  // Backend Languages
   gopls: 'go install golang.org/x/tools/gopls@latest',
-  rust: 'rustup component add rust-analyzer',
-  basedpyright: 'pip install basedpyright',
+  ruby_lsp: 'gem install rubocop (Ruby LSP runs via rubocop --lsp)',
+  ty: 'pip install ty or see https://github.com/astral-sh/ty',
   pyright: 'pip install pyright',
-  clangd: 'See https://clangd.llvm.org/installation',
-  zls: 'See https://github.com/zigtools/zls',
+  elixir_ls:
+    'Download from https://github.com/elixir-lsp/elixir-ls/releases or build from source',
+  zls: 'Install via your package manager or build from source: https://github.com/zigtools/zls',
+
+  // .NET Languages
+  csharp: 'dotnet tool install --global csharp-ls',
+  fsharp: 'dotnet tool install --global fsautocomplete',
+
+  // Apple Languages
+  sourcekit_lsp: 'Install via Xcode or Swift toolchain (included with Xcode)',
+
+  // Rust
+  rust: 'rustup component add rust-analyzer',
+
+  // C/C++
+  clangd: 'Install clangd via your system package manager or LLVM',
+
+  // Frontend Frameworks
+  svelte: 'npm install -g svelte-language-server',
+  astro: 'npm install -g @astrojs/language-server',
+
+  // Java/JVM Languages
+  jdtls: 'See https://github.com/eclipse-jdtls/eclipse.jdt.ls for installation',
+  kotlin_ls: 'Download from https://github.com/Kotlin/kotlin-lsp/releases',
+
+  // Config/ Markup Languages
+  yaml_ls: 'npm install -g yaml-language-server',
+  lua_ls: 'Download from https://github.com/LuaLS/lua-language-server/releases',
+  php_intelephense: 'npm install -g intelephense',
+  prisma: 'npm install -g @prisma/language-server or use npx',
+  dart: 'dart pub global activate language_server',
+  ocaml_lsp: 'opam install ocaml-lsp-server',
+
+  // Shell/Scripts
+  bash: 'npm install -g bash-language-server',
+
+  // Infrastructure/ DevOps
+  terraform_ls:
+    'Download from https://github.com/hashicorp/terraform-ls/releases or install via tfenv',
+
+  // Document/ Publishing
+  texlab: 'Download from https://github.com/latex-lsp/texlab/releases',
+  dockerfile: 'npm install -g dockerfile-language-server-nodejs',
+
+  // Functional Languages
+  gleam: 'Install Gleam: https://gleam.run/getting-started/',
+  clojure_lsp:
+    'Install via deps.edn, project.clj, or: clj -M -m clojure-lsp.main',
+  nixd: 'Install via nix-env or your system package manager',
+  tinymist: 'cargo install tinymist or download from releases',
+  haskell_language_server:
+    'Install Haskell Tool Stack or Cabal, then language-server',
+  julials: 'Install Julia: https://julialang.org/downloads/',
 };
 
-// Extension to language ID mapping
-export const EXT_TO_LANG: Record<string, string> = {
-  // TypeScript/JavaScript
+/**
+ * Maps file extensions to LSP language IDs.
+ * Mirrors OpenCode core's LANGUAGE_EXTENSIONS constant.
+ */
+export const LANGUAGE_EXTENSIONS: Record<string, string> = {
+  // JavaScript/TypeScript
   '.ts': 'typescript',
   '.tsx': 'typescriptreact',
   '.mts': 'typescript',
@@ -130,26 +584,197 @@ export const EXT_TO_LANG: Record<string, string> = {
   '.jsx': 'javascriptreact',
   '.mjs': 'javascript',
   '.cjs': 'javascript',
-  // Frontend frameworks
+  '.ets': 'typescript',
+
+  // Vue
   '.vue': 'vue',
+
+  // Svelte
   '.svelte': 'svelte',
+
+  // Astro
   '.astro': 'astro',
-  // Web
+
+  // HTML/XML
   '.html': 'html',
+  '.htm': 'html',
+  '.xml': 'xml',
+  '.xsl': 'xsl',
+
+  // CSS/SCSS/Less
   '.css': 'css',
   '.scss': 'scss',
+  '.sass': 'sass',
   '.less': 'less',
+
+  // JSON
   '.json': 'json',
-  // Backend
+  '.jsonc': 'json',
+
+  // GraphQL
+  '.graphql': 'graphql',
+  '.gql': 'graphql',
+
+  // Web/Build
+  '.dockerfile': 'dockerfile',
+  '.sh': 'shellscript',
+  '.bash': 'shellscript',
+  '.zsh': 'shellscript',
+  '.ksh': 'shellscript',
+
+  // Go
   '.go': 'go',
+
+  // Rust
   '.rs': 'rust',
+
+  // Python
   '.py': 'python',
   '.pyi': 'python',
+
+  // Ruby
+  '.rb': 'ruby',
+  '.rake': 'ruby',
+  '.gemspec': 'ruby',
+  '.ru': 'ruby',
+
+  // C/C++
   '.c': 'c',
   '.cpp': 'cpp',
   '.cc': 'cpp',
   '.cxx': 'cpp',
+  '.c++': 'cpp',
   '.h': 'c',
   '.hpp': 'cpp',
+  '.hh': 'cpp',
+  '.hxx': 'cpp',
+  '.h++': 'cpp',
+
+  // Java
+  '.java': 'java',
+
+  // Kotlin
+  '.kt': 'kotlin',
+  '.kts': 'kotlin',
+
+  // C#
+  '.cs': 'csharp',
+
+  // F#
+  '.fs': 'fsharp',
+  '.fsi': 'fsharp',
+  '.fsx': 'fsharp',
+  '.fsscript': 'fsharp',
+
+  // Swift/Objective-C
+  '.swift': 'swift',
+  '.m': 'objective-c',
+  '.mm': 'objective-cpp',
+
+  // Zig
   '.zig': 'zig',
+  '.zon': 'zig',
+
+  // Elixir
+  '.ex': 'elixir',
+  '.exs': 'elixir',
+
+  // Clojure
+  '.clj': 'clojure',
+  '.cljs': 'clojure',
+  '.cljc': 'clojure',
+  '.edn': 'clojure',
+
+  // Haskell
+  '.hs': 'haskell',
+  '.lhs': 'haskell',
+
+  // OCaml
+  '.ml': 'ocaml',
+  '.mli': 'ocaml',
+
+  // Scala
+  '.scala': 'scala',
+
+  // PHP
+  '.php': 'php',
+
+  // Lua
+  '.lua': 'lua',
+
+  // Dart
+  '.dart': 'dart',
+
+  // YAML
+  '.yaml': 'yaml',
+  '.yml': 'yaml',
+
+  // Terraform
+  '.tf': 'terraform',
+  '.tfvars': 'terraform-vars',
+  '.hcl': 'hcl',
+
+  // Nix
+  '.nix': 'nix',
+
+  // Typst
+  '.typ': 'typst',
+  '.typc': 'typst',
+
+  // LaTeX
+  '.tex': 'latex',
+  '.latex': 'latex',
+  '.bib': 'bibtex',
+  '.bibtex': 'bibtex',
+
+  // Prisma
+  '.prisma': 'prisma',
+
+  // Julia
+  '.jl': 'julia',
+
+  // Gleam
+  '.gleam': 'gleam',
+
+  // Markdown
+  '.md': 'markdown',
+  '.markdown': 'markdown',
+
+  // Other
+  '.d': 'd',
+  '.pas': 'pascal',
+  '.pascal': 'pascal',
+  '.diff': 'diff',
+  '.patch': 'diff',
+  '.erl': 'erlang',
+  '.hrl': 'erlang',
+  '.groovy': 'groovy',
+  '.handlebars': 'handlebars',
+  '.hbs': 'handlebars',
+  '.ini': 'ini',
+  '.makefile': 'makefile',
+  makefile: 'makefile',
+  '.pug': 'jade',
+  '.jade': 'jade',
+  '.r': 'r',
+  '.cshtml': 'razor',
+  '.razor': 'razor',
+  '.erb': 'erb',
+  '.html.erb': 'erb',
+  '.js.erb': 'erb',
+  '.css.erb': 'erb',
+  '.json.erb': 'erb',
+  '.shader': 'shaderlab',
+  '.sql': 'sql',
+  '.perl': 'perl',
+  '.pl': 'perl',
+  '.pm': 'perl',
+  '.pm6': 'perl6',
+  '.ps1': 'powershell',
+  '.psm1': 'powershell',
+  '.coffee': 'coffeescript',
+  '.bat': 'bat',
+  '.abap': 'abap',
+  '.gitcommit': 'git-commit',
+  '.gitrebase': 'git-rebase',
 };

+ 1 - 0
src/tools/lsp/index.ts

@@ -1,6 +1,7 @@
 // LSP Module - Explicit exports
 
 export { lspManager } from './client';
+export { getUserLspConfig, setUserLspConfig } from './config-store';
 export {
   lsp_diagnostics,
   lsp_find_references,

+ 8 - 0
src/tools/lsp/types.ts

@@ -16,10 +16,17 @@ import type {
   WorkspaceEdit,
 } from 'vscode-languageserver-protocol';
 
+/**
+ * Root function type - mirrors OpenCode core's RootFunction.
+ * Returns the project root directory for a given file, or undefined if not applicable.
+ */
+export type RootFunction = (file: string) => string | undefined;
+
 export interface LSPServerConfig {
   id: string;
   command: string[];
   extensions: string[];
+  root?: RootFunction;
   disabled?: boolean;
   env?: Record<string, string>;
   initialization?: Record<string, unknown>;
@@ -29,6 +36,7 @@ export interface ResolvedServer {
   id: string;
   command: string[];
   extensions: string[];
+  root?: RootFunction;
   env?: Record<string, string>;
   initialization?: Record<string, unknown>;
 }

+ 62 - 2
src/tools/lsp/utils.ts

@@ -9,6 +9,7 @@ import {
 } from 'node:fs';
 import { dirname, extname, join, resolve } from 'node:path';
 import { fileURLToPath } from 'node:url';
+import { log } from '../../utils/logger';
 import type { LSPClient } from './client';
 import { lspManager } from './client';
 import { findServerForExtension } from './config';
@@ -17,11 +18,35 @@ import type {
   Diagnostic,
   Location,
   LocationLink,
+  ResolvedServer,
   ServerLookupResult,
   TextEdit,
   WorkspaceEdit,
 } from './types';
 
+/**
+ * Find the project root for a specific LSP server using its root function.
+ * Mirrors OpenCode core's RootFunction approach.
+ *
+ * @param filePath - The file to find the root for
+ * @param server - The LSP server config with root function
+ * @returns The project root directory, or file's directory if no root function
+ */
+export function findServerProjectRoot(
+  filePath: string,
+  server: ResolvedServer,
+): string {
+  // Use the server's root function if available, otherwise use file's directory
+  if (server.root) {
+    return server.root(filePath) ?? dirname(resolve(filePath));
+  }
+  return dirname(resolve(filePath));
+}
+
+/**
+ * Legacy function for backward compatibility.
+ * @deprecated Use findServerProjectRoot with server-specific patterns instead.
+ */
 export function findWorkspaceRoot(filePath: string): string {
   let dir = resolve(filePath);
 
@@ -84,24 +109,46 @@ export async function withLspClient<T>(
   const result = findServerForExtension(ext);
 
   if (result.status !== 'found') {
+    log('[lsp] withLspClient: server not found', {
+      filePath: absPath,
+      extension: ext,
+    });
     throw new Error(formatServerLookupError(result));
   }
 
   const server = result.server;
-  const root = findWorkspaceRoot(absPath);
+  // Use server-specific root detection instead of generic workspace root
+  // Fall back to file's directory if no root patterns match
+  const root = findServerProjectRoot(absPath, server) ?? dirname(absPath);
+
+  log('[lsp] withLspClient: acquiring client', {
+    filePath: absPath,
+    server: server.id,
+    root,
+  });
+
   const client = await lspManager.getClient(root, server);
 
   try {
-    return await fn(client);
+    const result = await fn(client);
+    log('[lsp] withLspClient: operation complete', { server: server.id });
+    return result;
   } catch (e) {
     if (e instanceof Error && e.message.includes('timeout')) {
       const isInitializing = lspManager.isServerInitializing(root, server.id);
       if (isInitializing) {
+        log('[lsp] withLspClient: timeout during init', {
+          server: server.id,
+        });
         throw new Error(
           `LSP server is still initializing. Please retry in a few seconds.`,
         );
       }
     }
+    log('[lsp] withLspClient: operation failed', {
+      server: server.id,
+      error: String(e),
+    });
     throw e;
   } finally {
     lspManager.releaseClient(root, server.id);
@@ -221,6 +268,7 @@ export interface ApplyResult {
 
 export function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult {
   if (!edit) {
+    log('[lsp] applyWorkspaceEdit: no edit provided');
     return {
       success: false,
       filesModified: [],
@@ -229,6 +277,11 @@ export function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult {
     };
   }
 
+  const changeCount =
+    (edit.changes ? Object.keys(edit.changes).length : 0) +
+    (edit.documentChanges ? edit.documentChanges.length : 0);
+  log('[lsp] applyWorkspaceEdit: applying', { changeCount });
+
   const result: ApplyResult = {
     success: true,
     filesModified: [],
@@ -300,6 +353,13 @@ export function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult {
     }
   }
 
+  log('[lsp] applyWorkspaceEdit: complete', {
+    success: result.success,
+    filesModified: result.filesModified.length,
+    totalEdits: result.totalEdits,
+    errors: result.errors.length,
+  });
+
   return result;
 }