import { access, readdir, readFile, stat } from "node:fs/promises"; import { dirname, join, parse, relative, resolve } from "node:path"; export type RubyProjectKind = "plain-ruby" | "rails-app" | "rails-engine" | "monorepo"; export type RubyRootCandidate = { root: string; markers: string[]; kind: RubyProjectKind; rails: { detected: boolean; engine: boolean; markers: string[]; routesCommand?: string[]; }; }; export type CommandPlan = { command: string[]; cwd: string; strategy: "bundler" | "global"; reason: string; candidates: string[][]; }; export type CategorizedError = { code: string; message: string; root?: string; command?: string[]; cwd?: string; stderrSummary?: string; suggestions: string[]; retryable: boolean; fallbackUsed?: boolean; }; async function exists(path: string): Promise { try { await access(path); return true; } catch { return false; } } async function markerFiles(dir: string): Promise { const markers: string[] = []; if (await exists(join(dir, "Gemfile"))) markers.push("Gemfile"); if (await exists(join(dir, ".ruby-version"))) markers.push(".ruby-version"); if (await exists(join(dir, ".ruby-lsp"))) markers.push(".ruby-lsp/"); if (await exists(join(dir, "config", "application.rb"))) markers.push("config/application.rb"); if (await exists(join(dir, "config", "routes.rb"))) markers.push("config/routes.rb"); if (await exists(join(dir, "bin", "rails"))) markers.push("bin/rails"); try { const entries = await readdir(dir); if (entries.some((entry) => entry.endsWith(".gemspec"))) markers.push("*.gemspec"); } catch { // Ignore unreadable directories. } return markers; } async function railsInfo(dir: string): Promise { const markers: string[] = []; if (await exists(join(dir, "config", "application.rb"))) markers.push("config/application.rb"); if (await exists(join(dir, "config", "routes.rb"))) markers.push("config/routes.rb"); if (await exists(join(dir, "bin", "rails"))) markers.push("bin/rails"); let engine = false; try { const libEntries = await readdir(join(dir, "lib"), { recursive: true }); if (libEntries.some((entry) => String(entry).endsWith("engine.rb"))) { engine = true; markers.push("lib/*/engine.rb"); } } catch { // lib is optional. } try { const gemfile = await readFile(join(dir, "Gemfile"), "utf8"); if (/\brails\b|\brailties\b/.test(gemfile)) markers.push("Gemfile:rails"); } catch { // Gemfile is optional. } try { const lockfile = await readFile(join(dir, "Gemfile.lock"), "utf8"); if (/^\s{4}(rails|railties) \(/m.test(lockfile)) markers.push("Gemfile.lock:rails"); } catch { // Gemfile.lock is optional. } const detected = markers.length > 0; const routesCommand = detected ? await exists(join(dir, "bin", "rails")) ? ["bin/rails", "routes"] : ["bundle", "exec", "rails", "routes"] : undefined; return { detected, engine, markers: [...new Set(markers)], routesCommand }; } async function candidateFor(dir: string): Promise { const markers = await markerFiles(dir); if (markers.length === 0) return undefined; const rails = await railsInfo(dir); const kind: RubyProjectKind = rails.engine ? "rails-engine" : rails.detected && markers.includes("config/application.rb") ? "rails-app" : "plain-ruby"; return { root: dir, markers, kind, rails }; } async function descendantCandidates(root: string): Promise { const ignored = new Set([".git", "node_modules", "vendor", "tmp", "log", "coverage", ".tmp-test-build"]); const queue: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]; const found: RubyRootCandidate[] = []; let scanned = 0; while (queue.length > 0 && scanned < 600) { const { dir, depth } = queue.shift()!; scanned++; if (depth > 0) { const candidate = await candidateFor(dir); if (candidate) found.push(candidate); } if (depth >= 4) continue; let entries: Array<{ name: string; isDirectory(): boolean }>; try { entries = await readdir(dir, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (!entry.isDirectory() || ignored.has(entry.name)) continue; queue.push({ dir: join(dir, entry.name), depth: depth + 1 }); } } return found; } function ancestors(start: string, stop: string): string[] { const dirs: string[] = []; let current = resolve(start); const root = parse(current).root; const resolvedStop = resolve(stop); while (true) { dirs.push(current); if (current === resolvedStop || current === root) break; current = dirname(current); } return dirs; } function containsPath(root: string, path: string): boolean { const rel = relative(resolve(root), resolve(path)); return rel === "" || (!rel.startsWith("..") && !rel.startsWith("/") && !/^[A-Za-z]:/.test(rel)); } export async function discoverRubyRoots(workspaceCwd: string, targetPath?: string): Promise<{ activeRoot: RubyRootCandidate; candidates: RubyRootCandidate[] }> { const cwd = resolve(workspaceCwd); const target = targetPath ? resolve(targetPath) : cwd; let start = target; if (targetPath) { try { const info = await stat(target); start = info.isDirectory() ? target : dirname(target); } catch { start = dirname(target); } } const found = new Map(); for (const dir of ancestors(start, cwd)) { const candidate = await candidateFor(dir); if (candidate) found.set(candidate.root, candidate); } const cwdCandidate = await candidateFor(cwd); if (cwdCandidate) found.set(cwdCandidate.root, cwdCandidate); for (const candidate of await descendantCandidates(cwd)) { found.set(candidate.root, candidate); } const candidates = [...found.values()].sort((a, b) => b.root.length - a.root.length); const owningCandidates = candidates.filter((candidate) => containsPath(candidate.root, target)); const activeRoot = (targetPath ? owningCandidates[0] : cwdCandidate) ?? owningCandidates[0] ?? candidates[0] ?? { root: cwd, markers: [], kind: "plain-ruby", rails: { detected: false, engine: false, markers: [] }, }; const monorepo = candidates.length > 1; return { activeRoot: monorepo ? { ...activeRoot, kind: activeRoot.kind === "plain-ruby" ? "monorepo" : activeRoot.kind } : activeRoot, candidates: monorepo ? candidates.map((candidate) => candidate.root === activeRoot.root ? { ...candidate, kind: activeRoot.kind } : candidate) : candidates, }; } export async function commandPlanForRoot(root: string): Promise { const hasGemfile = await exists(join(root, "Gemfile")); const forceGlobal = process.env.PI_RUBY_LSP_GLOBAL === "1"; const candidates = hasGemfile && !forceGlobal ? [["bundle", "exec", "ruby-lsp"], ["ruby-lsp"]] : [["ruby-lsp"]]; const command = candidates[0]; return { command, cwd: root, strategy: command[0] === "bundle" ? "bundler" : "global", reason: forceGlobal ? "PI_RUBY_LSP_GLOBAL=1" : hasGemfile ? "Gemfile detected" : "No Gemfile detected", candidates, }; } export function categorizeRubyLspError(input: { error: unknown; root?: string; command?: string[]; cwd?: string; stderr?: string; fallbackUsed?: boolean }): CategorizedError { const raw = input.error instanceof Error ? input.error.message : String(input.error); const text = `${raw}\n${input.stderr ?? ""}`; const lower = text.toLowerCase(); let code = "RUBY_LSP_UNAVAILABLE"; let message = "Ruby LSP is unavailable."; let suggestions = ["Run /ruby-lsp doctor for details.", "Install Ruby LSP with `bundle add ruby-lsp --group development` or `gem install ruby-lsp`."]; let retryable = false; if (/enoent.*bundle|spawn bundle enoent|bundle: command not found/.test(lower)) { code = "BUNDLER_MISSING"; message = "Bundler is not available."; suggestions = ["Install Bundler with `gem install bundler`.", "If you use a global Ruby LSP, set PI_RUBY_LSP_GLOBAL=1."]; } else if (/could not find.*gem|bundle install|run `bundle install`|run bundle install/.test(lower)) { code = "BUNDLE_INSTALL_REQUIRED"; message = "Bundle dependencies are missing."; suggestions = ["Run `bundle install` from the selected Ruby root."]; retryable = true; } else if (/ruby version|your ruby version|requires ruby version|rbenv|rvm/.test(lower)) { code = "RUBY_VERSION_MISMATCH"; message = "Ruby version mismatch while starting Ruby LSP."; suggestions = ["Switch to the project's Ruby version, then run `bundle install`.", "Check `.ruby-version` and Gemfile ruby constraints."]; } else if (/exited during initialize|exited with code|exited from signal|connection is closed|connection got disposed|crash/.test(lower)) { code = "RUBY_LSP_CRASHED"; message = "Ruby LSP crashed during startup."; suggestions = ["Run `/ruby-lsp doctor` to inspect startup stderr.", "Start `ruby-lsp` manually from the selected Ruby root to see the full crash."]; retryable = true; } else if (/ruby-lsp|cannot load such file.*ruby_lsp|gem.*ruby-lsp/.test(lower)) { code = "RUBY_LSP_MISSING"; message = "Ruby LSP is not installed in the selected environment."; suggestions = ["Run `bundle add ruby-lsp --group development` for Bundler projects.", "Or install globally with `gem install ruby-lsp`."]; } else if (/timed out|timeout/.test(lower)) { code = "RUBY_LSP_TIMEOUT"; message = "Ruby LSP startup or request timed out."; suggestions = ["Increase PI_RUBY_LSP_INITIALIZE_TIMEOUT_MS for large projects.", "Run `/ruby-lsp doctor` to inspect startup stderr."]; retryable = true; } else if (/rails.*failed|boot|zeitwerk|database/.test(lower)) { code = "RAILS_BOOT_FAILED"; message = "Rails command failed while booting the app."; suggestions = ["Run the reported Rails command manually from the selected root.", "Fix app boot errors, missing credentials, or database configuration."]; } return { code, message, root: input.root, command: input.command, cwd: input.cwd, stderrSummary: text.trim().split(/\r?\n/).slice(-12).join("\n") || undefined, suggestions, retryable, fallbackUsed: input.fallbackUsed, }; } export function relativeRoot(workspaceCwd: string, root: string): string { const rel = relative(resolve(workspaceCwd), resolve(root)); return rel || "."; }