Alvin Unreal 2 months ago
parent
commit
74c67daf69

+ 14 - 2
src/index.ts

@@ -1,7 +1,13 @@
 import type { Plugin } from "@opencode-ai/plugin";
 import { getAgentConfigs } from "./agents";
 import { BackgroundTaskManager } from "./features";
-import { createBackgroundTools } from "./tools";
+import {
+  createBackgroundTools,
+  lsp_goto_definition,
+  lsp_find_references,
+  lsp_diagnostics,
+  lsp_rename,
+} from "./tools";
 import { loadPluginConfig } from "./config";
 import { createBuiltinMcps } from "./mcp";
 
@@ -17,7 +23,13 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
     agent: agents,
 
-    tool: backgroundTools,
+    tool: {
+      ...backgroundTools,
+      lsp_goto_definition,
+      lsp_find_references,
+      lsp_diagnostics,
+      lsp_rename,
+    },
 
     mcp: mcps,
 

+ 7 - 0
src/tools/index.ts

@@ -1 +1,8 @@
 export { createBackgroundTools } from "./background";
+export {
+  lsp_goto_definition,
+  lsp_find_references,
+  lsp_diagnostics,
+  lsp_rename,
+  lspManager,
+} from "./lsp";

+ 491 - 0
src/tools/lsp/client.ts

@@ -0,0 +1,491 @@
+// LSP Client - Full implementation with connection pooling
+
+import { spawn, type Subprocess } from "bun"
+import { readFileSync } from "fs"
+import { extname, resolve } from "path"
+import { pathToFileURL } from "node:url"
+import { getLanguageId } from "./config"
+import type { Diagnostic, ResolvedServer } from "./types"
+
+interface ManagedClient {
+  client: LSPClient
+  lastUsedAt: number
+  refCount: number
+  initPromise?: Promise<void>
+  isInitializing: boolean
+}
+
+class LSPServerManager {
+  private static instance: LSPServerManager
+  private clients = new Map<string, ManagedClient>()
+  private cleanupInterval: ReturnType<typeof setInterval> | null = null
+  private readonly IDLE_TIMEOUT = 5 * 60 * 1000
+
+  private constructor() {
+    this.startCleanupTimer()
+    this.registerProcessCleanup()
+  }
+
+  private registerProcessCleanup(): void {
+    const cleanup = () => {
+      for (const [, managed] of this.clients) {
+        try {
+          managed.client.stop()
+        } catch {}
+      }
+      this.clients.clear()
+      if (this.cleanupInterval) {
+        clearInterval(this.cleanupInterval)
+        this.cleanupInterval = null
+      }
+    }
+
+    process.on("exit", cleanup)
+    process.on("SIGINT", () => {
+      cleanup()
+      process.exit(0)
+    })
+    process.on("SIGTERM", () => {
+      cleanup()
+      process.exit(0)
+    })
+  }
+
+  static getInstance(): LSPServerManager {
+    if (!LSPServerManager.instance) {
+      LSPServerManager.instance = new LSPServerManager()
+    }
+    return LSPServerManager.instance
+  }
+
+  private getKey(root: string, serverId: string): string {
+    return `${root}::${serverId}`
+  }
+
+  private startCleanupTimer(): void {
+    if (this.cleanupInterval) return
+    this.cleanupInterval = setInterval(() => {
+      this.cleanupIdleClients()
+    }, 60000)
+  }
+
+  private cleanupIdleClients(): void {
+    const now = Date.now()
+    for (const [key, managed] of this.clients) {
+      if (managed.refCount === 0 && now - managed.lastUsedAt > this.IDLE_TIMEOUT) {
+        managed.client.stop()
+        this.clients.delete(key)
+      }
+    }
+  }
+
+  async getClient(root: string, server: ResolvedServer): Promise<LSPClient> {
+    const key = this.getKey(root, server.id)
+
+    let managed = this.clients.get(key)
+    if (managed) {
+      if (managed.initPromise) {
+        await managed.initPromise
+      }
+      if (managed.client.isAlive()) {
+        managed.refCount++
+        managed.lastUsedAt = Date.now()
+        return managed.client
+      }
+      await managed.client.stop()
+      this.clients.delete(key)
+    }
+
+    const client = new LSPClient(root, server)
+    const initPromise = (async () => {
+      await client.start()
+      await client.initialize()
+    })()
+
+    this.clients.set(key, {
+      client,
+      lastUsedAt: Date.now(),
+      refCount: 1,
+      initPromise,
+      isInitializing: true,
+    })
+
+    try {
+      await initPromise
+      const m = this.clients.get(key)
+      if (m) {
+        m.initPromise = undefined
+        m.isInitializing = false
+      }
+    } catch (err) {
+      this.clients.delete(key)
+      throw err
+    }
+
+    return client
+  }
+
+  releaseClient(root: string, serverId: string): void {
+    const key = this.getKey(root, serverId)
+    const managed = this.clients.get(key)
+    if (managed && managed.refCount > 0) {
+      managed.refCount--
+      managed.lastUsedAt = Date.now()
+    }
+  }
+
+  isServerInitializing(root: string, serverId: string): boolean {
+    const key = this.getKey(root, serverId)
+    const managed = this.clients.get(key)
+    return managed?.isInitializing ?? false
+  }
+
+  async stopAll(): Promise<void> {
+    for (const [, managed] of this.clients) {
+      await managed.client.stop()
+    }
+    this.clients.clear()
+    if (this.cleanupInterval) {
+      clearInterval(this.cleanupInterval)
+      this.cleanupInterval = null
+    }
+  }
+}
+
+export const lspManager = LSPServerManager.getInstance()
+
+export class LSPClient {
+  private proc: Subprocess<"pipe", "pipe", "pipe"> | null = null
+  private buffer: Uint8Array = new Uint8Array(0)
+  private pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>()
+  private requestIdCounter = 0
+  private openedFiles = new Set<string>()
+  private stderrBuffer: string[] = []
+  private processExited = false
+  private diagnosticsStore = new Map<string, Diagnostic[]>()
+
+  constructor(
+    private root: string,
+    private server: ResolvedServer
+  ) {}
+
+  async start(): Promise<void> {
+    this.proc = spawn(this.server.command, {
+      stdin: "pipe",
+      stdout: "pipe",
+      stderr: "pipe",
+      cwd: this.root,
+      env: {
+        ...process.env,
+        ...this.server.env,
+      },
+    })
+
+    if (!this.proc) {
+      throw new Error(`Failed to spawn LSP server: ${this.server.command.join(" ")}`)
+    }
+
+    this.startReading()
+    this.startStderrReading()
+
+    await new Promise((resolve) => setTimeout(resolve, 100))
+
+    if (this.proc.exitCode !== null) {
+      const stderr = this.stderrBuffer.join("\n")
+      throw new Error(
+        `LSP server exited immediately with code ${this.proc.exitCode}` + (stderr ? `\nstderr: ${stderr}` : "")
+      )
+    }
+  }
+
+  private startReading(): void {
+    if (!this.proc) return
+
+    const reader = this.proc.stdout.getReader()
+    const read = async () => {
+      try {
+        while (true) {
+          const { done, value } = await reader.read()
+          if (done) {
+            this.processExited = true
+            this.rejectAllPending("LSP server stdout closed")
+            break
+          }
+          const newBuf = new Uint8Array(this.buffer.length + value.length)
+          newBuf.set(this.buffer)
+          newBuf.set(value, this.buffer.length)
+          this.buffer = newBuf
+          this.processBuffer()
+        }
+      } catch (err) {
+        this.processExited = true
+        this.rejectAllPending(`LSP stdout read error: ${err}`)
+      }
+    }
+    read()
+  }
+
+  private startStderrReading(): void {
+    if (!this.proc) return
+
+    const reader = this.proc.stderr.getReader()
+    const read = async () => {
+      const decoder = new TextDecoder()
+      try {
+        while (true) {
+          const { done, value } = await reader.read()
+          if (done) break
+          const text = decoder.decode(value)
+          this.stderrBuffer.push(text)
+          if (this.stderrBuffer.length > 100) {
+            this.stderrBuffer.shift()
+          }
+        }
+      } catch {}
+    }
+    read()
+  }
+
+  private rejectAllPending(reason: string): void {
+    for (const [id, handler] of this.pending) {
+      handler.reject(new Error(reason))
+      this.pending.delete(id)
+    }
+  }
+
+  private findSequence(haystack: Uint8Array, needle: number[]): number {
+    outer: for (let i = 0; i <= haystack.length - needle.length; i++) {
+      for (let j = 0; j < needle.length; j++) {
+        if (haystack[i + j] !== needle[j]) continue outer
+      }
+      return i
+    }
+    return -1
+  }
+
+  private processBuffer(): void {
+    const decoder = new TextDecoder()
+    const CONTENT_LENGTH = [67, 111, 110, 116, 101, 110, 116, 45, 76, 101, 110, 103, 116, 104, 58]
+    const CRLF_CRLF = [13, 10, 13, 10]
+    const LF_LF = [10, 10]
+
+    while (true) {
+      const headerStart = this.findSequence(this.buffer, CONTENT_LENGTH)
+      if (headerStart === -1) break
+      if (headerStart > 0) this.buffer = this.buffer.slice(headerStart)
+
+      let headerEnd = this.findSequence(this.buffer, CRLF_CRLF)
+      let sepLen = 4
+      if (headerEnd === -1) {
+        headerEnd = this.findSequence(this.buffer, LF_LF)
+        sepLen = 2
+      }
+      if (headerEnd === -1) break
+
+      const header = decoder.decode(this.buffer.slice(0, headerEnd))
+      const match = header.match(/Content-Length:\s*(\d+)/i)
+      if (!match) break
+
+      const len = parseInt(match[1], 10)
+      const start = headerEnd + sepLen
+      const end = start + len
+      if (this.buffer.length < end) break
+
+      const content = decoder.decode(this.buffer.slice(start, end))
+      this.buffer = this.buffer.slice(end)
+
+      try {
+        const msg = JSON.parse(content)
+
+        if ("method" in msg && !("id" in msg)) {
+          if (msg.method === "textDocument/publishDiagnostics" && msg.params?.uri) {
+            this.diagnosticsStore.set(msg.params.uri, msg.params.diagnostics ?? [])
+          }
+        } else if ("id" in msg && "method" in msg) {
+          this.handleServerRequest(msg.id, msg.method, msg.params)
+        } else if ("id" in msg && this.pending.has(msg.id)) {
+          const handler = this.pending.get(msg.id)!
+          this.pending.delete(msg.id)
+          if ("error" in msg) {
+            handler.reject(new Error(msg.error.message))
+          } else {
+            handler.resolve(msg.result)
+          }
+        }
+      } catch {}
+    }
+  }
+
+  private send(method: string, params?: unknown): Promise<unknown> {
+    if (!this.proc) throw new Error("LSP client not started")
+
+    if (this.processExited || this.proc.exitCode !== null) {
+      const stderr = this.stderrBuffer.slice(-10).join("\n")
+      throw new Error(`LSP server already exited (code: ${this.proc.exitCode})` + (stderr ? `\nstderr: ${stderr}` : ""))
+    }
+
+    const id = ++this.requestIdCounter
+    const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params })
+    const header = `Content-Length: ${Buffer.byteLength(msg)}\r\n\r\n`
+    this.proc.stdin.write(header + msg)
+
+    return new Promise((resolve, reject) => {
+      this.pending.set(id, { resolve, reject })
+      setTimeout(() => {
+        if (this.pending.has(id)) {
+          this.pending.delete(id)
+          const stderr = this.stderrBuffer.slice(-5).join("\n")
+          reject(new Error(`LSP request timeout (method: ${method})` + (stderr ? `\nrecent stderr: ${stderr}` : "")))
+        }
+      }, 15000)
+    })
+  }
+
+  private notify(method: string, params?: unknown): void {
+    if (!this.proc) return
+    if (this.processExited || this.proc.exitCode !== null) return
+
+    const msg = JSON.stringify({ jsonrpc: "2.0", method, params })
+    this.proc.stdin.write(`Content-Length: ${Buffer.byteLength(msg)}\r\n\r\n${msg}`)
+  }
+
+  private respond(id: number | string, result: unknown): void {
+    if (!this.proc) return
+    if (this.processExited || this.proc.exitCode !== null) return
+
+    const msg = JSON.stringify({ jsonrpc: "2.0", id, result })
+    this.proc.stdin.write(`Content-Length: ${Buffer.byteLength(msg)}\r\n\r\n${msg}`)
+  }
+
+  private handleServerRequest(id: number | string, method: string, params?: unknown): void {
+    if (method === "workspace/configuration") {
+      const items = (params as { items?: Array<{ section?: string }> })?.items ?? []
+      const result = items.map((item) => {
+        if (item.section === "json") return { validate: { enable: true } }
+        return {}
+      })
+      this.respond(id, result)
+    } else if (method === "client/registerCapability") {
+      this.respond(id, null)
+    } else if (method === "window/workDoneProgress/create") {
+      this.respond(id, null)
+    }
+  }
+
+  async initialize(): Promise<void> {
+    const rootUri = pathToFileURL(this.root).href
+    await this.send("initialize", {
+      processId: process.pid,
+      rootUri,
+      rootPath: this.root,
+      workspaceFolders: [{ uri: rootUri, name: "workspace" }],
+      capabilities: {
+        textDocument: {
+          hover: { contentFormat: ["markdown", "plaintext"] },
+          definition: { linkSupport: true },
+          references: {},
+          documentSymbol: { hierarchicalDocumentSymbolSupport: true },
+          publishDiagnostics: {},
+          rename: {
+            prepareSupport: true,
+            prepareSupportDefaultBehavior: 1,
+            honorsChangeAnnotations: true,
+          },
+        },
+        workspace: {
+          symbol: {},
+          workspaceFolders: true,
+          configuration: true,
+          applyEdit: true,
+          workspaceEdit: { documentChanges: true },
+        },
+      },
+      ...this.server.initialization,
+    })
+    this.notify("initialized")
+    await new Promise((r) => setTimeout(r, 300))
+  }
+
+  async openFile(filePath: string): Promise<void> {
+    const absPath = resolve(filePath)
+    if (this.openedFiles.has(absPath)) return
+
+    const text = readFileSync(absPath, "utf-8")
+    const ext = extname(absPath)
+    const languageId = getLanguageId(ext)
+
+    this.notify("textDocument/didOpen", {
+      textDocument: {
+        uri: pathToFileURL(absPath).href,
+        languageId,
+        version: 1,
+        text,
+      },
+    })
+    this.openedFiles.add(absPath)
+
+    await new Promise((r) => setTimeout(r, 1000))
+  }
+
+  async definition(filePath: string, line: number, character: number): Promise<unknown> {
+    const absPath = resolve(filePath)
+    await this.openFile(absPath)
+    return this.send("textDocument/definition", {
+      textDocument: { uri: pathToFileURL(absPath).href },
+      position: { line: line - 1, character },
+    })
+  }
+
+  async references(filePath: string, line: number, character: number, includeDeclaration = true): Promise<unknown> {
+    const absPath = resolve(filePath)
+    await this.openFile(absPath)
+    return this.send("textDocument/references", {
+      textDocument: { uri: pathToFileURL(absPath).href },
+      position: { line: line - 1, character },
+      context: { includeDeclaration },
+    })
+  }
+
+  async diagnostics(filePath: string): Promise<{ items: Diagnostic[] }> {
+    const absPath = resolve(filePath)
+    const uri = pathToFileURL(absPath).href
+    await this.openFile(absPath)
+    await new Promise((r) => setTimeout(r, 500))
+
+    try {
+      const result = await this.send("textDocument/diagnostic", {
+        textDocument: { uri },
+      })
+      if (result && typeof result === "object" && "items" in result) {
+        return result as { items: Diagnostic[] }
+      }
+    } catch {}
+
+    return { items: this.diagnosticsStore.get(uri) ?? [] }
+  }
+
+  async rename(filePath: string, line: number, character: number, newName: string): Promise<unknown> {
+    const absPath = resolve(filePath)
+    await this.openFile(absPath)
+    return this.send("textDocument/rename", {
+      textDocument: { uri: pathToFileURL(absPath).href },
+      position: { line: line - 1, character },
+      newName,
+    })
+  }
+
+  isAlive(): boolean {
+    return this.proc !== null && !this.processExited && this.proc.exitCode === null
+  }
+
+  async stop(): Promise<void> {
+    try {
+      this.notify("shutdown", {})
+      this.notify("exit")
+    } catch {}
+    this.proc?.kill()
+    this.proc = null
+    this.processExited = true
+    this.diagnosticsStore.clear()
+  }
+}

+ 78 - 0
src/tools/lsp/config.ts

@@ -0,0 +1,78 @@
+// Simplified LSP config - just PATH lookup, no multi-tier config merging
+
+import { existsSync } from "fs"
+import { join } from "path"
+import { homedir } from "os"
+import { BUILTIN_SERVERS, EXT_TO_LANG, LSP_INSTALL_HINTS } from "./constants"
+import type { ResolvedServer, ServerLookupResult } from "./types"
+
+export function findServerForExtension(ext: string): ServerLookupResult {
+  // Find matching server
+  for (const [id, config] of Object.entries(BUILTIN_SERVERS)) {
+    if (config.extensions.includes(ext)) {
+      const server: ResolvedServer = {
+        id,
+        command: config.command,
+        extensions: config.extensions,
+        env: config.env,
+        initialization: config.initialization,
+      }
+
+      if (isServerInstalled(config.command)) {
+        return { status: "found", server }
+      }
+
+      return {
+        status: "not_installed",
+        server,
+        installHint: LSP_INSTALL_HINTS[id] || `Install '${config.command[0]}' and add to PATH`,
+      }
+    }
+  }
+
+  return { status: "not_configured", extension: ext }
+}
+
+export function getLanguageId(ext: string): string {
+  return EXT_TO_LANG[ext] || "plaintext"
+}
+
+export function isServerInstalled(command: string[]): boolean {
+  if (command.length === 0) return false
+
+  const cmd = command[0]
+
+  // Absolute paths
+  if (cmd.includes("/") || cmd.includes("\\")) {
+    return existsSync(cmd)
+  }
+
+  const isWindows = process.platform === "win32"
+  const ext = isWindows ? ".exe" : ""
+
+  // Check PATH
+  const pathEnv = process.env.PATH || ""
+  const pathSeparator = isWindows ? ";" : ":"
+  const paths = pathEnv.split(pathSeparator)
+
+  for (const p of paths) {
+    if (existsSync(join(p, cmd)) || existsSync(join(p, cmd + ext))) {
+      return true
+    }
+  }
+
+  // Check local node_modules
+  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
+}

+ 146 - 0
src/tools/lsp/constants.ts

@@ -0,0 +1,146 @@
+// Slim LSP constants - only essential languages
+
+import type { LSPServerConfig } from "./types"
+
+export const SYMBOL_KIND_MAP: Record<number, string> = {
+  1: "File",
+  2: "Module",
+  3: "Namespace",
+  4: "Package",
+  5: "Class",
+  6: "Method",
+  7: "Property",
+  8: "Field",
+  9: "Constructor",
+  10: "Enum",
+  11: "Interface",
+  12: "Function",
+  13: "Variable",
+  14: "Constant",
+  15: "String",
+  16: "Number",
+  17: "Boolean",
+  18: "Array",
+  19: "Object",
+  20: "Key",
+  21: "Null",
+  22: "EnumMember",
+  23: "Struct",
+  24: "Event",
+  25: "Operator",
+  26: "TypeParameter",
+}
+
+export const SEVERITY_MAP: Record<number, string> = {
+  1: "error",
+  2: "warning",
+  3: "information",
+  4: "hint",
+}
+
+export const DEFAULT_MAX_REFERENCES = 200
+export const DEFAULT_MAX_DIAGNOSTICS = 200
+
+// Slim server list - common languages + popular frontend
+export const BUILTIN_SERVERS: Record<string, Omit<LSPServerConfig, "id">> = {
+  // JavaScript/TypeScript ecosystem
+  typescript: {
+    command: ["typescript-language-server", "--stdio"],
+    extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
+  },
+  vue: {
+    command: ["vue-language-server", "--stdio"],
+    extensions: [".vue"],
+  },
+  svelte: {
+    command: ["svelteserver", "--stdio"],
+    extensions: [".svelte"],
+  },
+  astro: {
+    command: ["astro-ls", "--stdio"],
+    extensions: [".astro"],
+  },
+  eslint: {
+    command: ["vscode-eslint-language-server", "--stdio"],
+    extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte"],
+  },
+  tailwindcss: {
+    command: ["tailwindcss-language-server", "--stdio"],
+    extensions: [".html", ".jsx", ".tsx", ".vue", ".svelte", ".astro"],
+  },
+  // Backend languages
+  gopls: {
+    command: ["gopls"],
+    extensions: [".go"],
+  },
+  rust: {
+    command: ["rust-analyzer"],
+    extensions: [".rs"],
+  },
+  basedpyright: {
+    command: ["basedpyright-langserver", "--stdio"],
+    extensions: [".py", ".pyi"],
+  },
+  pyright: {
+    command: ["pyright-langserver", "--stdio"],
+    extensions: [".py", ".pyi"],
+  },
+  clangd: {
+    command: ["clangd", "--background-index"],
+    extensions: [".c", ".cpp", ".cc", ".cxx", ".h", ".hpp"],
+  },
+  zls: {
+    command: ["zls"],
+    extensions: [".zig"],
+  },
+}
+
+export const LSP_INSTALL_HINTS: Record<string, string> = {
+  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",
+  gopls: "go install golang.org/x/tools/gopls@latest",
+  rust: "rustup component add rust-analyzer",
+  basedpyright: "pip install basedpyright",
+  pyright: "pip install pyright",
+  clangd: "See https://clangd.llvm.org/installation",
+  zls: "See https://github.com/zigtools/zls",
+}
+
+// Extension to language ID mapping
+export const EXT_TO_LANG: Record<string, string> = {
+  // TypeScript/JavaScript
+  ".ts": "typescript",
+  ".tsx": "typescriptreact",
+  ".mts": "typescript",
+  ".cts": "typescript",
+  ".js": "javascript",
+  ".jsx": "javascriptreact",
+  ".mjs": "javascript",
+  ".cjs": "javascript",
+  // Frontend frameworks
+  ".vue": "vue",
+  ".svelte": "svelte",
+  ".astro": "astro",
+  // Web
+  ".html": "html",
+  ".css": "css",
+  ".scss": "scss",
+  ".less": "less",
+  ".json": "json",
+  // Backend
+  ".go": "go",
+  ".rs": "rust",
+  ".py": "python",
+  ".pyi": "python",
+  ".c": "c",
+  ".cpp": "cpp",
+  ".cc": "cpp",
+  ".cxx": "cpp",
+  ".h": "c",
+  ".hpp": "cpp",
+  ".zig": "zig",
+}

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

@@ -0,0 +1,18 @@
+// LSP Module - Explicit exports
+
+export { lspManager } from "./client"
+export {
+  lsp_goto_definition,
+  lsp_find_references,
+  lsp_diagnostics,
+  lsp_rename,
+} from "./tools"
+
+// Re-export types for external use
+export type {
+  LSPServerConfig,
+  ResolvedServer,
+  Diagnostic,
+  Location,
+  WorkspaceEdit,
+} from "./types"

+ 151 - 0
src/tools/lsp/tools.ts

@@ -0,0 +1,151 @@
+// LSP Tools - 4 essential tools only
+
+import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
+import { DEFAULT_MAX_REFERENCES, DEFAULT_MAX_DIAGNOSTICS } from "./constants"
+import {
+  withLspClient,
+  formatLocation,
+  formatDiagnostic,
+  filterDiagnosticsBySeverity,
+  applyWorkspaceEdit,
+  formatApplyResult,
+} from "./utils"
+import type { Location, LocationLink, Diagnostic, WorkspaceEdit } from "./types"
+
+const formatError = (e: unknown): string =>
+  `Error: ${e instanceof Error ? e.message : String(e)}`
+
+export const lsp_goto_definition: ToolDefinition = tool({
+  description: "Jump to symbol definition. Find WHERE something is defined.",
+  args: {
+    filePath: tool.schema.string().describe("Absolute path to the file"),
+    line: tool.schema.number().min(1).describe("1-based line number"),
+    character: tool.schema.number().min(0).describe("0-based character offset"),
+  },
+  execute: async (args) => {
+    try {
+      const result = await withLspClient(args.filePath, async (client) => {
+        return (await client.definition(args.filePath, args.line, args.character)) as
+          | Location
+          | Location[]
+          | LocationLink[]
+          | null
+      })
+
+      if (!result) {
+        return "No definition found"
+      }
+
+      const locations = Array.isArray(result) ? result : [result]
+      if (locations.length === 0) {
+        return "No definition found"
+      }
+
+      return locations.map(formatLocation).join("\n")
+    } catch (e) {
+      return formatError(e)
+    }
+  },
+})
+
+export const lsp_find_references: ToolDefinition = tool({
+  description: "Find ALL usages/references of a symbol across the entire workspace.",
+  args: {
+    filePath: tool.schema.string().describe("Absolute path to the file"),
+    line: tool.schema.number().min(1).describe("1-based line number"),
+    character: tool.schema.number().min(0).describe("0-based character offset"),
+    includeDeclaration: tool.schema.boolean().optional().describe("Include the declaration itself"),
+  },
+  execute: async (args) => {
+    try {
+      const result = await withLspClient(args.filePath, async (client) => {
+        return (await client.references(
+          args.filePath,
+          args.line,
+          args.character,
+          args.includeDeclaration ?? true
+        )) as Location[] | null
+      })
+
+      if (!result || result.length === 0) {
+        return "No references found"
+      }
+
+      const total = result.length
+      const truncated = total > DEFAULT_MAX_REFERENCES
+      const limited = truncated ? result.slice(0, DEFAULT_MAX_REFERENCES) : result
+      const lines = limited.map(formatLocation)
+      if (truncated) {
+        lines.unshift(`Found ${total} references (showing first ${DEFAULT_MAX_REFERENCES}):`)
+      }
+      return lines.join("\n")
+    } catch (e) {
+      return formatError(e)
+    }
+  },
+})
+
+export const lsp_diagnostics: ToolDefinition = tool({
+  description: "Get errors, warnings, hints from language server BEFORE running build.",
+  args: {
+    filePath: tool.schema.string().describe("Absolute path to the file"),
+    severity: tool.schema
+      .enum(["error", "warning", "information", "hint", "all"])
+      .optional()
+      .describe("Filter by severity level"),
+  },
+  execute: async (args) => {
+    try {
+      const result = await withLspClient(args.filePath, async (client) => {
+        return (await client.diagnostics(args.filePath)) as { items?: Diagnostic[] } | Diagnostic[] | null
+      })
+
+      let diagnostics: Diagnostic[] = []
+      if (result) {
+        if (Array.isArray(result)) {
+          diagnostics = result
+        } else if (result.items) {
+          diagnostics = result.items
+        }
+      }
+
+      diagnostics = filterDiagnosticsBySeverity(diagnostics, args.severity)
+
+      if (diagnostics.length === 0) {
+        return "No diagnostics found"
+      }
+
+      const total = diagnostics.length
+      const truncated = total > DEFAULT_MAX_DIAGNOSTICS
+      const limited = truncated ? diagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS) : diagnostics
+      const lines = limited.map(formatDiagnostic)
+      if (truncated) {
+        lines.unshift(`Found ${total} diagnostics (showing first ${DEFAULT_MAX_DIAGNOSTICS}):`)
+      }
+      return lines.join("\n")
+    } catch (e) {
+      return formatError(e)
+    }
+  },
+})
+
+export const lsp_rename: ToolDefinition = tool({
+  description: "Rename symbol across entire workspace. APPLIES changes to all files.",
+  args: {
+    filePath: tool.schema.string().describe("Absolute path to the file"),
+    line: tool.schema.number().min(1).describe("1-based line number"),
+    character: tool.schema.number().min(0).describe("0-based character offset"),
+    newName: tool.schema.string().describe("New symbol name"),
+  },
+  execute: async (args) => {
+    try {
+      const edit = await withLspClient(args.filePath, async (client) => {
+        return (await client.rename(args.filePath, args.line, args.character, args.newName)) as WorkspaceEdit | null
+      })
+      const result = applyWorkspaceEdit(edit)
+      return formatApplyResult(result)
+    } catch (e) {
+      return formatError(e)
+    }
+  },
+})

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

@@ -0,0 +1,110 @@
+// LSP Protocol Types - Clean, minimal definitions
+
+export interface LSPServerConfig {
+  id: string
+  command: string[]
+  extensions: string[]
+  disabled?: boolean
+  env?: Record<string, string>
+  initialization?: Record<string, unknown>
+}
+
+export interface Position {
+  line: number
+  character: number
+}
+
+export interface Range {
+  start: Position
+  end: Position
+}
+
+export interface Location {
+  uri: string
+  range: Range
+}
+
+export interface LocationLink {
+  targetUri: string
+  targetRange: Range
+  targetSelectionRange: Range
+  originSelectionRange?: Range
+}
+
+export interface SymbolInfo {
+  name: string
+  kind: number
+  location: Location
+  containerName?: string
+}
+
+export interface DocumentSymbol {
+  name: string
+  kind: number
+  range: Range
+  selectionRange: Range
+  children?: DocumentSymbol[]
+}
+
+export interface Diagnostic {
+  range: Range
+  severity?: number
+  code?: string | number
+  source?: string
+  message: string
+}
+
+export interface TextDocumentIdentifier {
+  uri: string
+}
+
+export interface VersionedTextDocumentIdentifier extends TextDocumentIdentifier {
+  version: number | null
+}
+
+export interface TextEdit {
+  range: Range
+  newText: string
+}
+
+export interface TextDocumentEdit {
+  textDocument: VersionedTextDocumentIdentifier
+  edits: TextEdit[]
+}
+
+export interface CreateFile {
+  kind: "create"
+  uri: string
+  options?: { overwrite?: boolean; ignoreIfExists?: boolean }
+}
+
+export interface RenameFile {
+  kind: "rename"
+  oldUri: string
+  newUri: string
+  options?: { overwrite?: boolean; ignoreIfExists?: boolean }
+}
+
+export interface DeleteFile {
+  kind: "delete"
+  uri: string
+  options?: { recursive?: boolean; ignoreIfNotExists?: boolean }
+}
+
+export interface WorkspaceEdit {
+  changes?: { [uri: string]: TextEdit[] }
+  documentChanges?: (TextDocumentEdit | CreateFile | RenameFile | DeleteFile)[]
+}
+
+export interface ResolvedServer {
+  id: string
+  command: string[]
+  extensions: string[]
+  env?: Record<string, string>
+  initialization?: Record<string, unknown>
+}
+
+export type ServerLookupResult =
+  | { status: "found"; server: ResolvedServer }
+  | { status: "not_configured"; extension: string }
+  | { status: "not_installed"; server: ResolvedServer; installHint: string }

+ 281 - 0
src/tools/lsp/utils.ts

@@ -0,0 +1,281 @@
+// LSP Utilities - Essential formatters and helpers
+
+import { extname, resolve, dirname, join } from "path"
+import { fileURLToPath } from "node:url"
+import { existsSync, readFileSync, writeFileSync, unlinkSync, statSync } from "fs"
+import { lspManager } from "./client"
+import type { LSPClient } from "./client"
+import { findServerForExtension } from "./config"
+import { SYMBOL_KIND_MAP, SEVERITY_MAP } from "./constants"
+import type {
+  Location,
+  LocationLink,
+  Diagnostic,
+  WorkspaceEdit,
+  TextEdit,
+  ServerLookupResult,
+} from "./types"
+
+export function findWorkspaceRoot(filePath: string): string {
+  let dir = resolve(filePath)
+
+  try {
+    if (!statSync(dir).isDirectory()) {
+      dir = dirname(dir)
+    }
+  } catch {
+    dir = dirname(dir)
+  }
+
+  const markers = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod"]
+
+  let prevDir = ""
+  while (dir !== prevDir) {
+    for (const marker of markers) {
+      if (existsSync(join(dir, marker))) {
+        return dir
+      }
+    }
+    prevDir = dir
+    dir = dirname(dir)
+  }
+
+  return dirname(resolve(filePath))
+}
+
+export function uriToPath(uri: string): string {
+  return fileURLToPath(uri)
+}
+
+export function formatServerLookupError(result: Exclude<ServerLookupResult, { status: "found" }>): string {
+  if (result.status === "not_installed") {
+    return [
+      `LSP server '${result.server.id}' is NOT INSTALLED.`,
+      ``,
+      `Command not found: ${result.server.command[0]}`,
+      ``,
+      `To install: ${result.installHint}`,
+    ].join("\n")
+  }
+
+  return `No LSP server configured for extension: ${result.extension}`
+}
+
+export async function withLspClient<T>(filePath: string, fn: (client: LSPClient) => Promise<T>): Promise<T> {
+  const absPath = resolve(filePath)
+  const ext = extname(absPath)
+  const result = findServerForExtension(ext)
+
+  if (result.status !== "found") {
+    throw new Error(formatServerLookupError(result))
+  }
+
+  const server = result.server
+  const root = findWorkspaceRoot(absPath)
+  const client = await lspManager.getClient(root, server)
+
+  try {
+    return await fn(client)
+  } catch (e) {
+    if (e instanceof Error && e.message.includes("timeout")) {
+      const isInitializing = lspManager.isServerInitializing(root, server.id)
+      if (isInitializing) {
+        throw new Error(`LSP server is still initializing. Please retry in a few seconds.`)
+      }
+    }
+    throw e
+  } finally {
+    lspManager.releaseClient(root, server.id)
+  }
+}
+
+export function formatLocation(loc: Location | LocationLink): string {
+  if ("targetUri" in loc) {
+    const uri = uriToPath(loc.targetUri)
+    const line = loc.targetRange.start.line + 1
+    const char = loc.targetRange.start.character
+    return `${uri}:${line}:${char}`
+  }
+
+  const uri = uriToPath(loc.uri)
+  const line = loc.range.start.line + 1
+  const char = loc.range.start.character
+  return `${uri}:${line}:${char}`
+}
+
+export function formatSymbolKind(kind: number): string {
+  return SYMBOL_KIND_MAP[kind] || `Unknown(${kind})`
+}
+
+export function formatSeverity(severity: number | undefined): string {
+  if (!severity) return "unknown"
+  return SEVERITY_MAP[severity] || `unknown(${severity})`
+}
+
+export function formatDiagnostic(diag: Diagnostic): string {
+  const severity = formatSeverity(diag.severity)
+  const line = diag.range.start.line + 1
+  const char = diag.range.start.character
+  const source = diag.source ? `[${diag.source}]` : ""
+  const code = diag.code ? ` (${diag.code})` : ""
+  return `${severity}${source}${code} at ${line}:${char}: ${diag.message}`
+}
+
+export function filterDiagnosticsBySeverity(
+  diagnostics: Diagnostic[],
+  severityFilter?: "error" | "warning" | "information" | "hint" | "all"
+): Diagnostic[] {
+  if (!severityFilter || severityFilter === "all") {
+    return diagnostics
+  }
+
+  const severityMap: Record<string, number> = {
+    error: 1,
+    warning: 2,
+    information: 3,
+    hint: 4,
+  }
+
+  const targetSeverity = severityMap[severityFilter]
+  return diagnostics.filter((d) => d.severity === targetSeverity)
+}
+
+// WorkspaceEdit application
+
+function applyTextEditsToFile(filePath: string, edits: TextEdit[]): { success: boolean; editCount: number; error?: string } {
+  try {
+    const content = readFileSync(filePath, "utf-8")
+    const lines = content.split("\n")
+
+    const sortedEdits = [...edits].sort((a, b) => {
+      if (b.range.start.line !== a.range.start.line) {
+        return b.range.start.line - a.range.start.line
+      }
+      return b.range.start.character - a.range.start.character
+    })
+
+    for (const edit of sortedEdits) {
+      const startLine = edit.range.start.line
+      const startChar = edit.range.start.character
+      const endLine = edit.range.end.line
+      const endChar = edit.range.end.character
+
+      if (startLine === endLine) {
+        const line = lines[startLine] || ""
+        lines[startLine] = line.substring(0, startChar) + edit.newText + line.substring(endChar)
+      } else {
+        const firstLine = lines[startLine] || ""
+        const lastLine = lines[endLine] || ""
+        const newContent = firstLine.substring(0, startChar) + edit.newText + lastLine.substring(endChar)
+        lines.splice(startLine, endLine - startLine + 1, ...newContent.split("\n"))
+      }
+    }
+
+    writeFileSync(filePath, lines.join("\n"), "utf-8")
+    return { success: true, editCount: edits.length }
+  } catch (err) {
+    return { success: false, editCount: 0, error: err instanceof Error ? err.message : String(err) }
+  }
+}
+
+export interface ApplyResult {
+  success: boolean
+  filesModified: string[]
+  totalEdits: number
+  errors: string[]
+}
+
+export function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult {
+  if (!edit) {
+    return { success: false, filesModified: [], totalEdits: 0, errors: ["No edit provided"] }
+  }
+
+  const result: ApplyResult = { success: true, filesModified: [], totalEdits: 0, errors: [] }
+
+  if (edit.changes) {
+    for (const [uri, edits] of Object.entries(edit.changes)) {
+      const filePath = uriToPath(uri)
+      const applyResult = applyTextEditsToFile(filePath, edits)
+
+      if (applyResult.success) {
+        result.filesModified.push(filePath)
+        result.totalEdits += applyResult.editCount
+      } else {
+        result.success = false
+        result.errors.push(`${filePath}: ${applyResult.error}`)
+      }
+    }
+  }
+
+  if (edit.documentChanges) {
+    for (const change of edit.documentChanges) {
+      if ("kind" in change) {
+        if (change.kind === "create") {
+          try {
+            const filePath = uriToPath(change.uri)
+            writeFileSync(filePath, "", "utf-8")
+            result.filesModified.push(filePath)
+          } catch (err) {
+            result.success = false
+            result.errors.push(`Create ${change.uri}: ${err}`)
+          }
+        } else if (change.kind === "rename") {
+          try {
+            const oldPath = uriToPath(change.oldUri)
+            const newPath = uriToPath(change.newUri)
+            const content = readFileSync(oldPath, "utf-8")
+            writeFileSync(newPath, content, "utf-8")
+            unlinkSync(oldPath)
+            result.filesModified.push(newPath)
+          } catch (err) {
+            result.success = false
+            result.errors.push(`Rename ${change.oldUri}: ${err}`)
+          }
+        } else if (change.kind === "delete") {
+          try {
+            const filePath = uriToPath(change.uri)
+            unlinkSync(filePath)
+            result.filesModified.push(filePath)
+          } catch (err) {
+            result.success = false
+            result.errors.push(`Delete ${change.uri}: ${err}`)
+          }
+        }
+      } else {
+        const filePath = uriToPath(change.textDocument.uri)
+        const applyResult = applyTextEditsToFile(filePath, change.edits)
+
+        if (applyResult.success) {
+          result.filesModified.push(filePath)
+          result.totalEdits += applyResult.editCount
+        } else {
+          result.success = false
+          result.errors.push(`${filePath}: ${applyResult.error}`)
+        }
+      }
+    }
+  }
+
+  return result
+}
+
+export function formatApplyResult(result: ApplyResult): string {
+  const lines: string[] = []
+
+  if (result.success) {
+    lines.push(`Applied ${result.totalEdits} edit(s) to ${result.filesModified.length} file(s):`)
+    for (const file of result.filesModified) {
+      lines.push(`  - ${file}`)
+    }
+  } else {
+    lines.push("Failed to apply some changes:")
+    for (const err of result.errors) {
+      lines.push(`  Error: ${err}`)
+    }
+    if (result.filesModified.length > 0) {
+      lines.push(`Successfully modified: ${result.filesModified.join(", ")}`)
+    }
+  }
+
+  return lines.join("\n")
+}