main
   1/**
   2 * LSP Core - Language Server Protocol client management
   3 */
   4import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
   5import * as path from "node:path";
   6import * as fs from "node:fs";
   7import * as os from "node:os";
   8import { pathToFileURL, fileURLToPath } from "node:url";
   9import {
  10  createMessageConnection,
  11  StreamMessageReader,
  12  StreamMessageWriter,
  13  type MessageConnection,
  14  InitializeRequest,
  15  InitializedNotification,
  16  DidOpenTextDocumentNotification,
  17  DidChangeTextDocumentNotification,
  18  DidCloseTextDocumentNotification,
  19  DidSaveTextDocumentNotification,
  20  PublishDiagnosticsNotification,
  21  DocumentDiagnosticRequest,
  22  WorkspaceDiagnosticRequest,
  23  DefinitionRequest,
  24  ReferencesRequest,
  25  HoverRequest,
  26  SignatureHelpRequest,
  27  DocumentSymbolRequest,
  28  RenameRequest,
  29  CodeActionRequest,
  30} from "vscode-languageserver-protocol/node.js";
  31import {
  32  type Diagnostic,
  33  type Location,
  34  type LocationLink,
  35  type DocumentSymbol,
  36  type SymbolInformation,
  37  type Hover,
  38  type SignatureHelp,
  39  type WorkspaceEdit,
  40  type CodeAction,
  41  type Command,
  42  DiagnosticSeverity,
  43  CodeActionKind,
  44  DocumentDiagnosticReportKind,
  45} from "vscode-languageserver-protocol";
  46
  47// Config
  48const INIT_TIMEOUT_MS = 30000;
  49const MAX_OPEN_FILES = 30;
  50const IDLE_TIMEOUT_MS = 60_000;
  51const CLEANUP_INTERVAL_MS = 30_000;
  52
  53export const LANGUAGE_IDS: Record<string, string> = {
  54  ".dart": "dart", ".ts": "typescript", ".tsx": "typescriptreact",
  55  ".js": "javascript", ".jsx": "javascriptreact", ".mjs": "javascript",
  56  ".cjs": "javascript", ".mts": "typescript", ".cts": "typescript",
  57  ".vue": "vue", ".svelte": "svelte", ".astro": "astro",
  58  ".py": "python", ".pyi": "python", ".go": "go", ".rs": "rust",
  59  ".kt": "kotlin", ".kts": "kotlin",
  60  ".swift": "swift",
  61  ".nix": "nix",
  62};
  63
  64// Types
  65interface LSPServerConfig {
  66  id: string;
  67  extensions: string[];
  68  findRoot: (file: string, cwd: string) => string | undefined;
  69  spawn: (root: string) => Promise<{ process: ChildProcessWithoutNullStreams; initOptions?: Record<string, unknown> } | undefined>;
  70}
  71
  72interface OpenFile { version: number; lastAccess: number; }
  73
  74interface LSPClient {
  75  connection: MessageConnection;
  76  process: ChildProcessWithoutNullStreams;
  77  diagnostics: Map<string, Diagnostic[]>;
  78  openFiles: Map<string, OpenFile>;
  79  listeners: Map<string, Array<() => void>>;
  80  stderr: string[];
  81  capabilities?: any;
  82  root: string;
  83  closed: boolean;
  84}
  85
  86export interface FileDiagnosticItem {
  87  file: string;
  88  diagnostics: Diagnostic[];
  89  status: 'ok' | 'timeout' | 'error' | 'unsupported';
  90  error?: string;
  91}
  92
  93export interface FileDiagnosticsResult { items: FileDiagnosticItem[]; }
  94
  95// Utilities
  96const SEARCH_PATHS = [
  97  ...(process.env.PATH?.split(path.delimiter) || []),
  98  "/usr/local/bin", "/opt/homebrew/bin",
  99  `${process.env.HOME}/.pub-cache/bin`, `${process.env.HOME}/fvm/default/bin`,
 100  `${process.env.HOME}/go/bin`, `${process.env.HOME}/.cargo/bin`,
 101];
 102
 103function which(cmd: string): string | undefined {
 104  const ext = process.platform === "win32" ? ".exe" : "";
 105  for (const dir of SEARCH_PATHS) {
 106    const full = path.join(dir, cmd + ext);
 107    try { if (fs.existsSync(full) && fs.statSync(full).isFile()) return full; } catch {}
 108  }
 109}
 110
 111function normalizeFsPath(p: string): string {
 112  try {
 113    // realpathSync.native is faster on some platforms, but not always present
 114    const fn: any = (fs as any).realpathSync?.native || fs.realpathSync;
 115    return fn(p);
 116  } catch {
 117    return p;
 118  }
 119}
 120
 121function findNearestFile(startDir: string, targets: string[], stopDir: string): string | undefined {
 122  let current = path.resolve(startDir);
 123  const stop = path.resolve(stopDir);
 124  while (current.length >= stop.length) {
 125    for (const t of targets) {
 126      const candidate = path.join(current, t);
 127      if (fs.existsSync(candidate)) return candidate;
 128    }
 129    const parent = path.dirname(current);
 130    if (parent === current) break;
 131    current = parent;
 132  }
 133}
 134
 135function findRoot(file: string, cwd: string, markers: string[]): string | undefined {
 136  const found = findNearestFile(path.dirname(file), markers, cwd);
 137  return found ? path.dirname(found) : undefined;
 138}
 139
 140function timeout<T>(promise: Promise<T>, ms: number, name: string): Promise<T> {
 141  return new Promise((resolve, reject) => {
 142    const timer = setTimeout(() => reject(new Error(`${name} timed out`)), ms);
 143    promise.then(r => { clearTimeout(timer); resolve(r); }, e => { clearTimeout(timer); reject(e); });
 144  });
 145}
 146
 147function simpleSpawn(bin: string, args: string[] = ["--stdio"]) {
 148  return async (root: string) => {
 149    const cmd = which(bin);
 150    if (!cmd) return undefined;
 151    return { process: spawn(cmd, args, { cwd: root, stdio: ["pipe", "pipe", "pipe"] }) };
 152  };
 153}
 154
 155async function spawnChecked(cmd: string, args: string[], cwd: string): Promise<ChildProcessWithoutNullStreams | undefined> {
 156  try {
 157    const child = spawn(cmd, args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
 158
 159    // If the process exits immediately (e.g. unsupported flag), treat it as a failure
 160    return await new Promise((resolve) => {
 161      let settled = false;
 162
 163      const cleanup = () => {
 164        child.removeListener("exit", onExit);
 165        child.removeListener("error", onError);
 166      };
 167
 168      let timer: NodeJS.Timeout | null = null;
 169
 170      const finish = (value: ChildProcessWithoutNullStreams | undefined) => {
 171        if (settled) return;
 172        settled = true;
 173        if (timer) clearTimeout(timer);
 174        cleanup();
 175        resolve(value);
 176      };
 177
 178      const onExit = () => finish(undefined);
 179      const onError = () => finish(undefined);
 180
 181      child.once("exit", onExit);
 182      child.once("error", onError);
 183
 184      timer = setTimeout(() => finish(child), 200);
 185      (timer as any).unref?.();
 186    });
 187  } catch {
 188    return undefined;
 189  }
 190}
 191
 192async function spawnWithFallback(cmd: string, argsVariants: string[][], cwd: string): Promise<ChildProcessWithoutNullStreams | undefined> {
 193  for (const args of argsVariants) {
 194    const child = await spawnChecked(cmd, args, cwd);
 195    if (child) return child;
 196  }
 197  return undefined;
 198}
 199
 200function findRootKotlin(file: string, cwd: string): string | undefined {
 201  // Prefer Gradle settings root for multi-module projects
 202  const gradleRoot = findRoot(file, cwd, ["settings.gradle.kts", "settings.gradle"]);
 203  if (gradleRoot) return gradleRoot;
 204
 205  // Fallbacks for single-module Gradle or Maven builds
 206  return findRoot(file, cwd, [
 207    "build.gradle.kts",
 208    "build.gradle",
 209    "gradlew",
 210    "gradlew.bat",
 211    "gradle.properties",
 212    "pom.xml",
 213  ]);
 214}
 215
 216function dirContainsNestedProjectFile(dir: string, dirSuffix: string, markerFile: string): boolean {
 217  try {
 218    const entries = fs.readdirSync(dir, { withFileTypes: true });
 219    for (const e of entries) {
 220      if (!e.isDirectory()) continue;
 221      if (!e.name.endsWith(dirSuffix)) continue;
 222      if (fs.existsSync(path.join(dir, e.name, markerFile))) return true;
 223    }
 224  } catch {
 225    // ignore
 226  }
 227  return false;
 228}
 229
 230function findRootSwift(file: string, cwd: string): string | undefined {
 231  let current = path.resolve(path.dirname(file));
 232  const stop = path.resolve(cwd);
 233
 234  while (current.length >= stop.length) {
 235    if (fs.existsSync(path.join(current, "Package.swift"))) return current;
 236
 237    // Xcode projects/workspaces store their marker files *inside* a directory
 238    if (dirContainsNestedProjectFile(current, ".xcodeproj", "project.pbxproj")) return current;
 239    if (dirContainsNestedProjectFile(current, ".xcworkspace", "contents.xcworkspacedata")) return current;
 240
 241    const parent = path.dirname(current);
 242    if (parent === current) break;
 243    current = parent;
 244  }
 245
 246  return undefined;
 247}
 248
 249async function runCommand(cmd: string, args: string[], cwd: string): Promise<boolean> {
 250  return await new Promise((resolve) => {
 251    try {
 252      const p = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
 253      p.on("error", () => resolve(false));
 254      p.on("exit", (code) => resolve(code === 0));
 255    } catch {
 256      resolve(false);
 257    }
 258  });
 259}
 260
 261async function ensureJetBrainsKotlinLspInstalled(): Promise<string | undefined> {
 262  // Opt-in download (to avoid surprising network activity)
 263  const allowDownload = process.env.PI_LSP_AUTO_DOWNLOAD_KOTLIN_LSP === "1" || process.env.PI_LSP_AUTO_DOWNLOAD_KOTLIN_LSP === "true";
 264  const installDir = path.join(os.homedir(), ".pi", "agent", "lsp", "kotlin-ls");
 265  const launcher = process.platform === "win32"
 266    ? path.join(installDir, "kotlin-lsp.cmd")
 267    : path.join(installDir, "kotlin-lsp.sh");
 268
 269  if (fs.existsSync(launcher)) return launcher;
 270  if (!allowDownload) return undefined;
 271
 272  const curl = which("curl");
 273  const unzip = which("unzip");
 274  if (!curl || !unzip) return undefined;
 275
 276  try {
 277    // Determine latest version
 278    const res = await fetch("https://api.github.com/repos/Kotlin/kotlin-lsp/releases/latest", {
 279      headers: { "User-Agent": "pi-lsp" },
 280    });
 281    if (!res.ok) return undefined;
 282    const release: any = await res.json();
 283    const versionRaw = (release?.name || release?.tag_name || "").toString();
 284    const version = versionRaw.replace(/^v/, "");
 285    if (!version) return undefined;
 286
 287    // Map platform/arch to JetBrains naming
 288    const platform = process.platform;
 289    const arch = process.arch;
 290
 291    let kotlinArch: string = arch;
 292    if (arch === "arm64") kotlinArch = "aarch64";
 293    else if (arch === "x64") kotlinArch = "x64";
 294
 295    let kotlinPlatform: string = platform;
 296    if (platform === "darwin") kotlinPlatform = "mac";
 297    else if (platform === "linux") kotlinPlatform = "linux";
 298    else if (platform === "win32") kotlinPlatform = "win";
 299
 300    const supportedCombos = new Set(["mac-x64", "mac-aarch64", "linux-x64", "linux-aarch64", "win-x64", "win-aarch64"]);
 301    const combo = `${kotlinPlatform}-${kotlinArch}`;
 302    if (!supportedCombos.has(combo)) return undefined;
 303
 304    const assetName = `kotlin-lsp-${version}-${kotlinPlatform}-${kotlinArch}.zip`;
 305    const url = `https://download-cdn.jetbrains.com/kotlin-lsp/${version}/${assetName}`;
 306
 307    fs.mkdirSync(installDir, { recursive: true });
 308    const zipPath = path.join(installDir, "kotlin-lsp.zip");
 309
 310    const okDownload = await runCommand(curl, ["-L", "-o", zipPath, url], installDir);
 311    if (!okDownload || !fs.existsSync(zipPath)) return undefined;
 312
 313    const okUnzip = await runCommand(unzip, ["-o", zipPath, "-d", installDir], installDir);
 314    try { fs.rmSync(zipPath, { force: true }); } catch {}
 315    if (!okUnzip) return undefined;
 316
 317    if (process.platform !== "win32") {
 318      try { fs.chmodSync(launcher, 0o755); } catch {}
 319    }
 320
 321    return fs.existsSync(launcher) ? launcher : undefined;
 322  } catch {
 323    return undefined;
 324  }
 325}
 326
 327async function spawnKotlinLanguageServer(root: string): Promise<ChildProcessWithoutNullStreams | undefined> {
 328  // Prefer JetBrains Kotlin LSP (Kotlin/kotlin-lsp) – better diagnostics for Gradle/Android projects.
 329  const explicit = process.env.PI_LSP_KOTLIN_LSP_PATH;
 330  if (explicit && fs.existsSync(explicit)) {
 331    return spawnWithFallback(explicit, [["--stdio"]], root);
 332  }
 333
 334  const jetbrains = which("kotlin-lsp") || which("kotlin-lsp.sh") || which("kotlin-lsp.cmd") || await ensureJetBrainsKotlinLspInstalled();
 335  if (jetbrains) {
 336    return spawnWithFallback(jetbrains, [["--stdio"]], root);
 337  }
 338
 339  // Fallback: org.javacs/kotlin-language-server (often lacks diagnostics without full classpath)
 340  const kls = which("kotlin-language-server");
 341  if (!kls) return undefined;
 342  return spawnWithFallback(kls, [[]], root);
 343}
 344
 345async function spawnSourcekitLsp(root: string): Promise<ChildProcessWithoutNullStreams | undefined> {
 346  const direct = which("sourcekit-lsp");
 347  if (direct) return spawnWithFallback(direct, [[], ["--stdio"]], root);
 348
 349  // macOS/Xcode: sourcekit-lsp is often available via xcrun
 350  const xcrun = which("xcrun");
 351  if (!xcrun) return undefined;
 352  return spawnWithFallback(xcrun, [["sourcekit-lsp"], ["sourcekit-lsp", "--stdio"]], root);
 353}
 354
 355// Server Configs
 356export const LSP_SERVERS: LSPServerConfig[] = [
 357  {
 358    id: "dart", extensions: [".dart"],
 359    findRoot: (f, cwd) => findRoot(f, cwd, ["pubspec.yaml", "analysis_options.yaml"]),
 360    spawn: async (root) => {
 361      let dart = which("dart");
 362      const pubspec = path.join(root, "pubspec.yaml");
 363      if (fs.existsSync(pubspec)) {
 364        try {
 365          const content = fs.readFileSync(pubspec, "utf-8");
 366          if (content.includes("flutter:") || content.includes("sdk: flutter")) {
 367            const flutter = which("flutter");
 368            if (flutter) {
 369              const dir = path.dirname(fs.realpathSync(flutter));
 370              for (const p of ["cache/dart-sdk/bin/dart", "../cache/dart-sdk/bin/dart"]) {
 371                const c = path.join(dir, p);
 372                if (fs.existsSync(c)) { dart = c; break; }
 373              }
 374            }
 375          }
 376        } catch {}
 377      }
 378      if (!dart) return undefined;
 379      return { process: spawn(dart, ["language-server", "--protocol=lsp"], { cwd: root, stdio: ["pipe", "pipe", "pipe"] }) };
 380    },
 381  },
 382  {
 383    id: "typescript", extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
 384    findRoot: (f, cwd) => {
 385      if (findNearestFile(path.dirname(f), ["deno.json", "deno.jsonc"], cwd)) return undefined;
 386      return findRoot(f, cwd, ["package.json", "tsconfig.json", "jsconfig.json"]);
 387    },
 388    spawn: async (root) => {
 389      const local = path.join(root, "node_modules/.bin/typescript-language-server");
 390      const cmd = fs.existsSync(local) ? local : which("typescript-language-server");
 391      if (!cmd) return undefined;
 392      return { process: spawn(cmd, ["--stdio"], { cwd: root, stdio: ["pipe", "pipe", "pipe"] }) };
 393    },
 394  },
 395  { id: "vue", extensions: [".vue"], findRoot: (f, cwd) => findRoot(f, cwd, ["package.json", "vite.config.ts", "vite.config.js"]), spawn: simpleSpawn("vue-language-server") },
 396  { id: "svelte", extensions: [".svelte"], findRoot: (f, cwd) => findRoot(f, cwd, ["package.json", "svelte.config.js"]), spawn: simpleSpawn("svelteserver") },
 397  { id: "pyright", extensions: [".py", ".pyi"], findRoot: (f, cwd) => findRoot(f, cwd, ["pyproject.toml", "setup.py", "requirements.txt", "pyrightconfig.json"]), spawn: simpleSpawn("pyright-langserver") },
 398  { id: "gopls", extensions: [".go"], findRoot: (f, cwd) => findRoot(f, cwd, ["go.work"]) || findRoot(f, cwd, ["go.mod"]), spawn: simpleSpawn("gopls", []) },
 399  {
 400    id: "kotlin", extensions: [".kt", ".kts"],
 401    findRoot: (f, cwd) => findRootKotlin(f, cwd),
 402    spawn: async (root) => {
 403      const proc = await spawnKotlinLanguageServer(root);
 404      if (!proc) return undefined;
 405      return { process: proc };
 406    },
 407  },
 408  {
 409    id: "swift", extensions: [".swift"],
 410    findRoot: (f, cwd) => findRootSwift(f, cwd),
 411    spawn: async (root) => {
 412      const proc = await spawnSourcekitLsp(root);
 413      if (!proc) return undefined;
 414      return { process: proc };
 415    },
 416  },
 417  { id: "rust-analyzer", extensions: [".rs"], findRoot: (f, cwd) => findRoot(f, cwd, ["Cargo.toml"]), spawn: simpleSpawn("rust-analyzer", []) },
 418  {
 419    id: "nil", extensions: [".nix"],
 420    findRoot: (f, cwd) => findRoot(f, cwd, ["flake.nix", "default.nix", "shell.nix"]),
 421    spawn: async (root) => {
 422      // Prefer nil, fall back to nixd
 423      const nil = which("nil");
 424      if (nil) return { process: spawn(nil, [], { cwd: root, stdio: ["pipe", "pipe", "pipe"] }) };
 425      const nixd = which("nixd");
 426      if (nixd) return { process: spawn(nixd, [], { cwd: root, stdio: ["pipe", "pipe", "pipe"] }) };
 427      return undefined;
 428    },
 429  },
 430];
 431
 432// Singleton Manager
 433let sharedManager: LSPManager | null = null;
 434let managerCwd: string | null = null;
 435
 436export function getOrCreateManager(cwd: string): LSPManager {
 437  if (!sharedManager || managerCwd !== cwd) {
 438    sharedManager?.shutdown().catch(() => {});
 439    sharedManager = new LSPManager(cwd);
 440    managerCwd = cwd;
 441  }
 442  return sharedManager;
 443}
 444
 445export function getManager(): LSPManager | null { return sharedManager; }
 446
 447export async function shutdownManager(): Promise<void> {
 448  const manager = sharedManager;
 449  if (!manager) return;
 450
 451  // Clear singleton pointers first so new requests never receive a manager
 452  // that's currently being shut down.
 453  sharedManager = null;
 454  managerCwd = null;
 455
 456  await manager.shutdown();
 457}
 458
 459// LSP Manager
 460export class LSPManager {
 461  private clients = new Map<string, LSPClient>();
 462  private spawning = new Map<string, Promise<LSPClient | undefined>>();
 463  private broken = new Set<string>();
 464  private cwd: string;
 465  private cleanupTimer: NodeJS.Timeout | null = null;
 466
 467  constructor(cwd: string) {
 468    this.cwd = cwd;
 469    this.cleanupTimer = setInterval(() => this.cleanupIdleFiles(), CLEANUP_INTERVAL_MS);
 470    this.cleanupTimer.unref();
 471  }
 472
 473  private cleanupIdleFiles() {
 474    const now = Date.now();
 475    for (const client of this.clients.values()) {
 476      for (const [fp, state] of client.openFiles) {
 477        if (now - state.lastAccess > IDLE_TIMEOUT_MS) this.closeFile(client, fp);
 478      }
 479    }
 480  }
 481
 482  private closeFile(client: LSPClient, absPath: string) {
 483    if (!client.openFiles.has(absPath)) return;
 484    client.openFiles.delete(absPath);
 485    if (client.closed) return;
 486    try {
 487      void client.connection.sendNotification(DidCloseTextDocumentNotification.type, {
 488        textDocument: { uri: pathToFileURL(absPath).href },
 489      }).catch(() => {});
 490    } catch {}
 491  }
 492
 493  private evictLRU(client: LSPClient) {
 494    if (client.openFiles.size <= MAX_OPEN_FILES) return;
 495    let oldest: { path: string; time: number } | null = null;
 496    for (const [fp, s] of client.openFiles) {
 497      if (!oldest || s.lastAccess < oldest.time) oldest = { path: fp, time: s.lastAccess };
 498    }
 499    if (oldest) this.closeFile(client, oldest.path);
 500  }
 501
 502  private key(id: string, root: string) { return `${id}:${root}`; }
 503
 504  private async initClient(config: LSPServerConfig, root: string): Promise<LSPClient | undefined> {
 505    const k = this.key(config.id, root);
 506    try {
 507      const handle = await config.spawn(root);
 508      if (!handle) { this.broken.add(k); return undefined; }
 509
 510      const reader = new StreamMessageReader(handle.process.stdout!);
 511      const writer = new StreamMessageWriter(handle.process.stdin!);
 512      const conn = createMessageConnection(reader, writer);
 513      
 514      // Prevent crashes from stream errors
 515      handle.process.stdin?.on("error", () => {});
 516      handle.process.stdout?.on("error", () => {});
 517
 518      const stderr: string[] = [];
 519      const MAX_STDERR_LINES = 200;
 520      handle.process.stderr?.on("data", (chunk: Buffer) => {
 521        try {
 522          const text = chunk.toString("utf-8");
 523          for (const line of text.split(/\r?\n/)) {
 524            if (!line.trim()) continue;
 525            stderr.push(line);
 526            if (stderr.length > MAX_STDERR_LINES) stderr.splice(0, stderr.length - MAX_STDERR_LINES);
 527          }
 528        } catch {
 529          // ignore
 530        }
 531      });
 532      handle.process.stderr?.on("error", () => {});
 533
 534      const client: LSPClient = {
 535        connection: conn,
 536        process: handle.process,
 537        diagnostics: new Map(),
 538        openFiles: new Map(),
 539        listeners: new Map(),
 540        stderr,
 541        root,
 542        closed: false,
 543      };
 544
 545      conn.onNotification("textDocument/publishDiagnostics", (params: { uri: string; diagnostics: Diagnostic[] }) => {
 546        const fpRaw = decodeURIComponent(new URL(params.uri).pathname);
 547        const fp = normalizeFsPath(fpRaw);
 548
 549        client.diagnostics.set(fp, params.diagnostics);
 550        // Notify both raw and normalized paths (macOS often reports /private/var vs /var)
 551        const listeners1 = client.listeners.get(fp);
 552        const listeners2 = fp !== fpRaw ? client.listeners.get(fpRaw) : undefined;
 553
 554        listeners1?.slice().forEach(fn => { try { fn(); } catch { /* listener error */ } });
 555        listeners2?.slice().forEach(fn => { try { fn(); } catch { /* listener error */ } });
 556      });
 557
 558      // Handle errors to prevent crashes
 559      conn.onError(() => {});
 560      conn.onClose(() => { client.closed = true; this.clients.delete(k); });
 561
 562      conn.onRequest("workspace/configuration", () => [handle.initOptions ?? {}]);
 563      conn.onRequest("window/workDoneProgress/create", () => null);
 564      conn.onRequest("client/registerCapability", () => {});
 565      conn.onRequest("client/unregisterCapability", () => {});
 566      conn.onRequest("workspace/workspaceFolders", () => [{ name: "workspace", uri: pathToFileURL(root).href }]);
 567
 568      handle.process.on("exit", () => { client.closed = true; this.clients.delete(k); });
 569      handle.process.on("error", () => { client.closed = true; this.clients.delete(k); this.broken.add(k); });
 570
 571      conn.listen();
 572
 573      const initResult = await timeout(conn.sendRequest(InitializeRequest.method, {
 574        rootUri: pathToFileURL(root).href,
 575        rootPath: root,
 576        processId: process.pid,
 577        workspaceFolders: [{ name: "workspace", uri: pathToFileURL(root).href }],
 578        initializationOptions: handle.initOptions ?? {},
 579        capabilities: {
 580          window: { workDoneProgress: true },
 581          workspace: { configuration: true },
 582          textDocument: {
 583            synchronization: { didSave: true, didOpen: true, didChange: true, didClose: true },
 584            publishDiagnostics: { versionSupport: true },
 585            diagnostic: { dynamicRegistration: false, relatedDocumentSupport: false },
 586          },
 587        },
 588      }), INIT_TIMEOUT_MS, `${config.id} init`);
 589
 590      client.capabilities = (initResult as any)?.capabilities;
 591
 592      conn.sendNotification(InitializedNotification.type, {});
 593      if (handle.initOptions) {
 594        conn.sendNotification("workspace/didChangeConfiguration", { settings: handle.initOptions });
 595      }
 596      return client;
 597    } catch { this.broken.add(k); return undefined; }
 598  }
 599
 600  async getClientsForFile(filePath: string): Promise<LSPClient[]> {
 601    const ext = path.extname(filePath);
 602    const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(this.cwd, filePath);
 603    const clients: LSPClient[] = [];
 604
 605    for (const config of LSP_SERVERS) {
 606      if (!config.extensions.includes(ext)) continue;
 607      const root = config.findRoot(absPath, this.cwd);
 608      if (!root) continue;
 609      const k = this.key(config.id, root);
 610      if (this.broken.has(k)) continue;
 611
 612      const existing = this.clients.get(k);
 613      if (existing) { clients.push(existing); continue; }
 614
 615      if (!this.spawning.has(k)) {
 616        const p = this.initClient(config, root);
 617        this.spawning.set(k, p);
 618        p.finally(() => this.spawning.delete(k));
 619      }
 620      const client = await this.spawning.get(k);
 621      if (client) { this.clients.set(k, client); clients.push(client); }
 622    }
 623    return clients;
 624  }
 625
 626  private resolve(fp: string) {
 627    const abs = path.isAbsolute(fp) ? fp : path.resolve(this.cwd, fp);
 628    return normalizeFsPath(abs);
 629  }
 630  private langId(fp: string) { return LANGUAGE_IDS[path.extname(fp)] || "plaintext"; }
 631  private readFile(fp: string): string | null { try { return fs.readFileSync(fp, "utf-8"); } catch { return null; } }
 632
 633  private explainNoLsp(absPath: string): string {
 634    const ext = path.extname(absPath);
 635
 636    if (ext === ".kt" || ext === ".kts") {
 637      const root = findRootKotlin(absPath, this.cwd);
 638      if (!root) return `No Kotlin project root detected (looked for settings.gradle(.kts), build.gradle(.kts), gradlew, pom.xml under cwd)`;
 639
 640      const hasJetbrains = !!(which("kotlin-lsp") || which("kotlin-lsp.sh") || which("kotlin-lsp.cmd") || process.env.PI_LSP_KOTLIN_LSP_PATH);
 641      const hasKls = !!which("kotlin-language-server");
 642
 643      if (!hasJetbrains && !hasKls) {
 644        return "No Kotlin LSP binary found. Install Kotlin/kotlin-lsp (recommended) or org.javacs/kotlin-language-server.";
 645      }
 646
 647      const k = this.key("kotlin", root);
 648      if (this.broken.has(k)) return `Kotlin LSP failed to initialize for root: ${root}`;
 649
 650      if (!hasJetbrains && hasKls) {
 651        return "Kotlin LSP is running via kotlin-language-server, but that server often does not produce diagnostics for Gradle/Android projects. Prefer Kotlin/kotlin-lsp.";
 652      }
 653
 654      return `Kotlin LSP unavailable for root: ${root}`;
 655    }
 656
 657    if (ext === ".swift") {
 658      const root = findRootSwift(absPath, this.cwd);
 659      if (!root) return `No Swift project root detected (looked for Package.swift, *.xcodeproj, *.xcworkspace under cwd)`;
 660      if (!which("sourcekit-lsp") && !which("xcrun")) return "sourcekit-lsp not found (and xcrun missing)";
 661      const k = this.key("swift", root);
 662      if (this.broken.has(k)) return `sourcekit-lsp failed to initialize for root: ${root}`;
 663      return `Swift LSP unavailable for root: ${root}`;
 664    }
 665
 666    return `No LSP for ${ext}`;
 667  }
 668
 669  private toPos(line: number, col: number) { return { line: Math.max(0, line - 1), character: Math.max(0, col - 1) }; }
 670
 671  private normalizeLocs(result: Location | Location[] | LocationLink[] | null | undefined): Location[] {
 672    if (!result) return [];
 673    const items = Array.isArray(result) ? result : [result];
 674    if (!items.length) return [];
 675    if ("uri" in items[0] && "range" in items[0]) return items as Location[];
 676    return (items as LocationLink[]).map(l => ({ uri: l.targetUri, range: l.targetSelectionRange ?? l.targetRange }));
 677  }
 678
 679  private normalizeSymbols(result: DocumentSymbol[] | SymbolInformation[] | null | undefined): DocumentSymbol[] {
 680    if (!result?.length) return [];
 681    const first = result[0];
 682    if ("location" in first) {
 683      return (result as SymbolInformation[]).map(s => ({
 684        name: s.name, kind: s.kind, range: s.location.range, selectionRange: s.location.range,
 685        detail: s.containerName, tags: s.tags, deprecated: s.deprecated, children: [],
 686      }));
 687    }
 688    return result as DocumentSymbol[];
 689  }
 690
 691  private async openOrUpdate(clients: LSPClient[], absPath: string, uri: string, langId: string, content: string, evict = true) {
 692    const now = Date.now();
 693    for (const client of clients) {
 694      if (client.closed) continue;
 695      const state = client.openFiles.get(absPath);
 696      try {
 697        if (state) {
 698          const v = state.version + 1;
 699          client.openFiles.set(absPath, { version: v, lastAccess: now });
 700          void client.connection.sendNotification(DidChangeTextDocumentNotification.type, {
 701            textDocument: { uri, version: v }, contentChanges: [{ text: content }],
 702          }).catch(() => {});
 703        } else {
 704          // For some servers (e.g. kotlin-language-server), diagnostics only start flowing after a didChange.
 705          // We open at version 0, then immediately send a full-content didChange at version 1.
 706          client.openFiles.set(absPath, { version: 1, lastAccess: now });
 707          void client.connection.sendNotification(DidOpenTextDocumentNotification.type, {
 708            textDocument: { uri, languageId: langId, version: 0, text: content },
 709          }).catch(() => {});
 710          void client.connection.sendNotification(DidChangeTextDocumentNotification.type, {
 711            textDocument: { uri, version: 1 }, contentChanges: [{ text: content }],
 712          }).catch(() => {});
 713          if (evict) this.evictLRU(client);
 714        }
 715        // Send didSave to trigger analysis (important for TypeScript)
 716        void client.connection.sendNotification(DidSaveTextDocumentNotification.type, {
 717          textDocument: { uri }, text: content,
 718        }).catch(() => {});
 719      } catch {}
 720    }
 721  }
 722
 723  private async loadFile(filePath: string) {
 724    const absPath = this.resolve(filePath);
 725    const clients = await this.getClientsForFile(absPath);
 726    if (!clients.length) return null;
 727    const content = this.readFile(absPath);
 728    if (content === null) return null;
 729    return { clients, absPath, uri: pathToFileURL(absPath).href, langId: this.langId(absPath), content };
 730  }
 731
 732  private waitForDiagnostics(client: LSPClient, absPath: string, timeoutMs: number, isNew: boolean): Promise<boolean> {
 733    return new Promise(resolve => {
 734      if (client.closed) return resolve(false);
 735
 736      let resolved = false;
 737      let settleTimer: NodeJS.Timeout | null = null;
 738      let listener: () => void = () => {};
 739
 740      const cleanupListener = () => {
 741        const listeners = client.listeners.get(absPath);
 742        if (!listeners) return;
 743        const idx = listeners.indexOf(listener);
 744        if (idx !== -1) listeners.splice(idx, 1);
 745        if (listeners.length === 0) client.listeners.delete(absPath);
 746      };
 747
 748      const finish = (value: boolean) => {
 749        if (resolved) return;
 750        resolved = true;
 751        if (settleTimer) clearTimeout(settleTimer);
 752        clearTimeout(timer);
 753        cleanupListener();
 754        resolve(value);
 755      };
 756
 757      // Some servers publish diagnostics multiple times (often empty first, then real results).
 758      // For new documents, if diagnostics are still empty, debounce a bit.
 759      listener = () => {
 760        if (resolved) return;
 761
 762        const current = client.diagnostics.get(absPath);
 763        if (current && current.length > 0) return finish(true);
 764
 765        if (!isNew) return finish(true);
 766
 767        if (settleTimer) clearTimeout(settleTimer);
 768        settleTimer = setTimeout(() => finish(true), 2500);
 769        (settleTimer as any).unref?.();
 770      };
 771
 772      const timer = setTimeout(() => finish(false), timeoutMs);
 773      (timer as any).unref?.();
 774
 775      const listeners = client.listeners.get(absPath) || [];
 776      listeners.push(listener);
 777      client.listeners.set(absPath, listeners);
 778    });
 779  }
 780
 781  private async pullDiagnostics(client: LSPClient, absPath: string, uri: string): Promise<{ diagnostics: Diagnostic[]; responded: boolean }> {
 782    if (client.closed) return { diagnostics: [], responded: false };
 783
 784    // Only attempt Pull Diagnostics if the server advertises support.
 785    // (Some servers throw and log noisy errors if we call these methods.)
 786    if (!client.capabilities || !(client.capabilities as any).diagnosticProvider) {
 787      return { diagnostics: [], responded: false };
 788    }
 789
 790    // Prefer new Pull Diagnostics if supported by the server
 791    try {
 792      const res: any = await client.connection.sendRequest(DocumentDiagnosticRequest.method, {
 793        textDocument: { uri },
 794      });
 795
 796      if (res?.kind === DocumentDiagnosticReportKind.Full) {
 797        return { diagnostics: Array.isArray(res.items) ? res.items : [], responded: true };
 798      }
 799      if (res?.kind === DocumentDiagnosticReportKind.Unchanged) {
 800        return { diagnostics: client.diagnostics.get(absPath) || [], responded: true };
 801      }
 802      if (Array.isArray(res?.items)) {
 803        return { diagnostics: res.items, responded: true };
 804      }
 805      return { diagnostics: [], responded: true };
 806    } catch {
 807      // ignore
 808    }
 809
 810    // Fallback: some servers only support WorkspaceDiagnosticRequest
 811    try {
 812      const res: any = await client.connection.sendRequest(WorkspaceDiagnosticRequest.method, {
 813        previousResultIds: [],
 814      });
 815
 816      const items: any[] = res?.items || [];
 817      const match = items.find((it: any) => it?.uri === uri);
 818      if (match?.kind === DocumentDiagnosticReportKind.Full) {
 819        return { diagnostics: Array.isArray(match.items) ? match.items : [], responded: true };
 820      }
 821      if (Array.isArray(match?.items)) {
 822        return { diagnostics: match.items, responded: true };
 823      }
 824      return { diagnostics: [], responded: true };
 825    } catch {
 826      return { diagnostics: [], responded: false };
 827    }
 828  }
 829
 830  async touchFileAndWait(filePath: string, timeoutMs: number): Promise<{ diagnostics: Diagnostic[]; receivedResponse: boolean; unsupported?: boolean; error?: string }> {
 831    const absPath = this.resolve(filePath);
 832
 833    if (!fs.existsSync(absPath)) {
 834      return { diagnostics: [], receivedResponse: false, unsupported: true, error: "File not found" };
 835    }
 836
 837    const clients = await this.getClientsForFile(absPath);
 838    if (!clients.length) {
 839      return { diagnostics: [], receivedResponse: false, unsupported: true, error: this.explainNoLsp(absPath) };
 840    }
 841
 842    const content = this.readFile(absPath);
 843    if (content === null) {
 844      return { diagnostics: [], receivedResponse: false, unsupported: true, error: "Could not read file" };
 845    }
 846
 847    const uri = pathToFileURL(absPath).href;
 848    const langId = this.langId(absPath);
 849    const isNew = clients.some(c => !c.openFiles.has(absPath));
 850
 851    const waits = clients.map(c => this.waitForDiagnostics(c, absPath, timeoutMs, isNew));
 852    await this.openOrUpdate(clients, absPath, uri, langId, content);
 853    const results = await Promise.all(waits);
 854
 855    let responded = results.some(r => r);
 856    const diags: Diagnostic[] = [];
 857    for (const c of clients) {
 858      const d = c.diagnostics.get(absPath);
 859      if (d) diags.push(...d);
 860    }
 861    if (!responded && clients.some(c => c.diagnostics.has(absPath))) responded = true;
 862
 863    // If we didn't get pushed diagnostics (common for some servers), try pull diagnostics.
 864    if (!responded || diags.length === 0) {
 865      const pulled = await Promise.all(clients.map(c => this.pullDiagnostics(c, absPath, uri)));
 866      for (let i = 0; i < clients.length; i++) {
 867        const r = pulled[i];
 868        if (r.responded) responded = true;
 869        if (r.diagnostics.length) {
 870          clients[i].diagnostics.set(absPath, r.diagnostics);
 871          diags.push(...r.diagnostics);
 872        }
 873      }
 874    }
 875
 876    return { diagnostics: diags, receivedResponse: responded };
 877  }
 878
 879  async getDiagnosticsForFiles(files: string[], timeoutMs: number): Promise<FileDiagnosticsResult> {
 880    const unique = [...new Set(files.map(f => this.resolve(f)))];
 881    const results: FileDiagnosticItem[] = [];
 882    const toClose: Map<LSPClient, string[]> = new Map();
 883
 884    for (const absPath of unique) {
 885      if (!fs.existsSync(absPath)) {
 886        results.push({ file: absPath, diagnostics: [], status: 'error', error: 'File not found' });
 887        continue;
 888      }
 889
 890      let clients: LSPClient[];
 891      try { clients = await this.getClientsForFile(absPath); }
 892      catch (e) { results.push({ file: absPath, diagnostics: [], status: 'error', error: String(e) }); continue; }
 893
 894      if (!clients.length) {
 895        results.push({ file: absPath, diagnostics: [], status: 'unsupported', error: this.explainNoLsp(absPath) });
 896        continue;
 897      }
 898
 899      const content = this.readFile(absPath);
 900      if (!content) {
 901        results.push({ file: absPath, diagnostics: [], status: 'error', error: 'Could not read file' });
 902        continue;
 903      }
 904
 905      const uri = pathToFileURL(absPath).href;
 906      const langId = this.langId(absPath);
 907      const isNew = clients.some(c => !c.openFiles.has(absPath));
 908
 909      for (const c of clients) {
 910        if (!c.openFiles.has(absPath)) {
 911          if (!toClose.has(c)) toClose.set(c, []);
 912          toClose.get(c)!.push(absPath);
 913        }
 914      }
 915
 916      const waits = clients.map(c => this.waitForDiagnostics(c, absPath, timeoutMs, isNew));
 917      await this.openOrUpdate(clients, absPath, uri, langId, content, false);
 918      const waitResults = await Promise.all(waits);
 919
 920      const diags: Diagnostic[] = [];
 921      for (const c of clients) { const d = c.diagnostics.get(absPath); if (d) diags.push(...d); }
 922
 923      let responded = waitResults.some(r => r) || diags.length > 0;
 924
 925      if (!responded || diags.length === 0) {
 926        const pulled = await Promise.all(clients.map(c => this.pullDiagnostics(c, absPath, uri)));
 927        for (let i = 0; i < clients.length; i++) {
 928          const r = pulled[i];
 929          if (r.responded) responded = true;
 930          if (r.diagnostics.length) {
 931            clients[i].diagnostics.set(absPath, r.diagnostics);
 932            diags.push(...r.diagnostics);
 933          }
 934        }
 935      }
 936
 937      if (!responded && !diags.length) {
 938        results.push({ file: absPath, diagnostics: [], status: 'timeout', error: 'LSP did not respond' });
 939      } else {
 940        results.push({ file: absPath, diagnostics: diags, status: 'ok' });
 941      }
 942    }
 943
 944    // Cleanup opened files
 945    for (const [c, fps] of toClose) { for (const fp of fps) this.closeFile(c, fp); }
 946    for (const c of this.clients.values()) { while (c.openFiles.size > MAX_OPEN_FILES) this.evictLRU(c); }
 947
 948    return { items: results };
 949  }
 950
 951  async getDefinition(fp: string, line: number, col: number): Promise<Location[]> {
 952    const l = await this.loadFile(fp);
 953    if (!l) return [];
 954    await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content);
 955    const pos = this.toPos(line, col);
 956    const results = await Promise.all(l.clients.map(async c => {
 957      if (c.closed) return [];
 958      try { return this.normalizeLocs(await c.connection.sendRequest(DefinitionRequest.type, { textDocument: { uri: l.uri }, position: pos })); }
 959      catch { return []; }
 960    }));
 961    return results.flat();
 962  }
 963
 964  async getReferences(fp: string, line: number, col: number): Promise<Location[]> {
 965    const l = await this.loadFile(fp);
 966    if (!l) return [];
 967    await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content);
 968    const pos = this.toPos(line, col);
 969    const results = await Promise.all(l.clients.map(async c => {
 970      if (c.closed) return [];
 971      try { return this.normalizeLocs(await c.connection.sendRequest(ReferencesRequest.type, { textDocument: { uri: l.uri }, position: pos, context: { includeDeclaration: true } })); }
 972      catch { return []; }
 973    }));
 974    return results.flat();
 975  }
 976
 977  async getHover(fp: string, line: number, col: number): Promise<Hover | null> {
 978    const l = await this.loadFile(fp);
 979    if (!l) return null;
 980    await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content);
 981    const pos = this.toPos(line, col);
 982    for (const c of l.clients) {
 983      if (c.closed) continue;
 984      try { const r = await c.connection.sendRequest(HoverRequest.type, { textDocument: { uri: l.uri }, position: pos }); if (r) return r; }
 985      catch {}
 986    }
 987    return null;
 988  }
 989
 990  async getSignatureHelp(fp: string, line: number, col: number): Promise<SignatureHelp | null> {
 991    const l = await this.loadFile(fp);
 992    if (!l) return null;
 993    await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content);
 994    const pos = this.toPos(line, col);
 995    for (const c of l.clients) {
 996      if (c.closed) continue;
 997      try { const r = await c.connection.sendRequest(SignatureHelpRequest.type, { textDocument: { uri: l.uri }, position: pos }); if (r) return r; }
 998      catch {}
 999    }
1000    return null;
1001  }
1002
1003  async getDocumentSymbols(fp: string): Promise<DocumentSymbol[]> {
1004    const l = await this.loadFile(fp);
1005    if (!l) return [];
1006    await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content);
1007    const results = await Promise.all(l.clients.map(async c => {
1008      if (c.closed) return [];
1009      try { return this.normalizeSymbols(await c.connection.sendRequest(DocumentSymbolRequest.type, { textDocument: { uri: l.uri } })); }
1010      catch { return []; }
1011    }));
1012    return results.flat();
1013  }
1014
1015  async rename(fp: string, line: number, col: number, newName: string): Promise<WorkspaceEdit | null> {
1016    const l = await this.loadFile(fp);
1017    if (!l) return null;
1018    await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content);
1019    const pos = this.toPos(line, col);
1020    for (const c of l.clients) {
1021      if (c.closed) continue;
1022      try {
1023        const r = await c.connection.sendRequest(RenameRequest.type, {
1024          textDocument: { uri: l.uri },
1025          position: pos,
1026          newName,
1027        });
1028        if (r) return r;
1029      } catch {}
1030    }
1031    return null;
1032  }
1033
1034  async getCodeActions(fp: string, startLine: number, startCol: number, endLine?: number, endCol?: number): Promise<(CodeAction | Command)[]> {
1035    const l = await this.loadFile(fp);
1036    if (!l) return [];
1037    await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content);
1038    
1039    const start = this.toPos(startLine, startCol);
1040    const end = this.toPos(endLine ?? startLine, endCol ?? startCol);
1041    const range = { start, end };
1042    
1043    // Get diagnostics for this range to include in context
1044    const diagnostics: Diagnostic[] = [];
1045    for (const c of l.clients) {
1046      const fileDiags = c.diagnostics.get(l.absPath) || [];
1047      for (const d of fileDiags) {
1048        if (this.rangesOverlap(d.range, range)) diagnostics.push(d);
1049      }
1050    }
1051    
1052    const results = await Promise.all(l.clients.map(async c => {
1053      if (c.closed) return [];
1054      try {
1055        const r = await c.connection.sendRequest(CodeActionRequest.type, {
1056          textDocument: { uri: l.uri },
1057          range,
1058          context: { diagnostics, only: [CodeActionKind.QuickFix, CodeActionKind.Refactor, CodeActionKind.Source] },
1059        });
1060        return r || [];
1061      } catch { return []; }
1062    }));
1063    return results.flat();
1064  }
1065
1066  private rangesOverlap(a: { start: { line: number; character: number }; end: { line: number; character: number } }, 
1067                        b: { start: { line: number; character: number }; end: { line: number; character: number } }): boolean {
1068    if (a.end.line < b.start.line || b.end.line < a.start.line) return false;
1069    if (a.end.line === b.start.line && a.end.character < b.start.character) return false;
1070    if (b.end.line === a.start.line && b.end.character < a.start.character) return false;
1071    return true;
1072  }
1073
1074  async shutdown() {
1075    if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = null; }
1076    const clients = Array.from(this.clients.values());
1077    this.clients.clear();
1078    for (const c of clients) {
1079      const wasClosed = c.closed;
1080      c.closed = true;
1081      if (!wasClosed) {
1082        try {
1083          await Promise.race([
1084            c.connection.sendRequest("shutdown"),
1085            new Promise(r => setTimeout(r, 1000))
1086          ]);
1087        } catch {}
1088        try { void c.connection.sendNotification("exit").catch(() => {}); } catch {}
1089      }
1090      try { c.connection.end(); } catch {}
1091      try { c.process.kill(); } catch {}
1092    }
1093  }
1094}
1095
1096// Diagnostic Formatting
1097export { DiagnosticSeverity };
1098export type SeverityFilter = "all" | "error" | "warning" | "info" | "hint";
1099
1100export function formatDiagnostic(d: Diagnostic): string {
1101  const sev = ["", "ERROR", "WARN", "INFO", "HINT"][d.severity || 1];
1102  return `${sev} [${d.range.start.line + 1}:${d.range.start.character + 1}] ${d.message}`;
1103}
1104
1105export function filterDiagnosticsBySeverity(diags: Diagnostic[], filter: SeverityFilter): Diagnostic[] {
1106  if (filter === "all") return diags;
1107  const max = { error: 1, warning: 2, info: 3, hint: 4 }[filter];
1108  return diags.filter(d => (d.severity || 1) <= max);
1109}
1110
1111// URI utilities
1112export function uriToPath(uri: string): string {
1113  if (uri.startsWith("file://")) try { return fileURLToPath(uri); } catch {}
1114  return uri;
1115}
1116
1117// Symbol search
1118export function findSymbolPosition(symbols: DocumentSymbol[], query: string): { line: number; character: number } | null {
1119  const q = query.toLowerCase();
1120  let exact: { line: number; character: number } | null = null;
1121  let partial: { line: number; character: number } | null = null;
1122
1123  const visit = (items: DocumentSymbol[]) => {
1124    for (const sym of items) {
1125      const name = String(sym?.name ?? "").toLowerCase();
1126      const pos = sym?.selectionRange?.start ?? sym?.range?.start;
1127      if (pos && typeof pos.line === "number" && typeof pos.character === "number") {
1128        if (!exact && name === q) exact = pos;
1129        if (!partial && name.includes(q)) partial = pos;
1130      }
1131      if (sym?.children?.length) visit(sym.children);
1132    }
1133  };
1134  visit(symbols);
1135  return exact ?? partial;
1136}
1137
1138export async function resolvePosition(manager: LSPManager, file: string, query: string): Promise<{ line: number; column: number } | null> {
1139  const symbols = await manager.getDocumentSymbols(file);
1140  const pos = findSymbolPosition(symbols, query);
1141  return pos ? { line: pos.line + 1, column: pos.character + 1 } : null;
1142}