#!/usr/bin/env node // detect-lsp.ts [--json] [--config=] — scan a directory for // source languages and report, per detected language: the recommended language // server, whether its executable is on PATH, an install hint, and whether the // Hermes LSP config already declares that language. // // Runtime: Node 22.6+ with `node --experimental-strip-types`, or `bun`. // node --experimental-strip-types scripts/detect-lsp.ts // bun scripts/detect-lsp.ts import { existsSync, readFileSync, readdirSync, statSync } from "node:fs" import { delimiter, extname, join, sep } from "node:path" import process from "node:process" import { LANGUAGES, type LanguageServer, LSP_CONFIG_FILE } from "./lsp-server-table.ts" const SKIP_DIRECTORIES = new Set([ "node_modules", ".git", "dist", "build", ".next", "out", "target", ".venv", "venv", "vendor", ".cache", "__pycache__", ".turbo", "coverage", ]) const MAX_FILES = 50_000 interface ConfigState { readonly path: string readonly exists: boolean readonly languages: readonly string[] } interface DetectionResult { readonly server: LanguageServer readonly executable: string readonly installed: boolean readonly resolvedPath: string | null readonly configured: boolean } function collectExtensions(root: string): ReadonlySet { const found = new Set() const stack: string[] = [root] let visited = 0 while (stack.length > 0) { const current = stack.pop() if (current === undefined) break let entries: string[] try { entries = readdirSync(current) } catch { continue } for (const entry of entries) { if (visited >= MAX_FILES) return found const fullPath = join(current, entry) let kind: "dir" | "file" | "other" = "other" try { const stat = statSync(fullPath) kind = stat.isDirectory() ? "dir" : stat.isFile() ? "file" : "other" } catch { continue } if (kind === "dir") { if (!SKIP_DIRECTORIES.has(entry)) stack.push(fullPath) } else if (kind === "file") { visited += 1 const ext = extname(entry).toLowerCase() if (ext.length > 0) found.add(ext) } } } return found } function pathDirectories(): readonly string[] { return (process.env["PATH"] ?? "").split(delimiter).filter((dir: string) => dir.length > 0) } function resolveExecutable(command: string): string | null { const extensions = process.platform === "win32" ? (process.env["PATHEXT"]?.split(";") ?? [".EXE", ".CMD", ".BAT"]) : [""] const bases = command.includes("/") || command.includes(sep) ? [command] : pathDirectories().map((dir: string) => join(dir, command)) for (const base of bases) { for (const ext of extensions) { const candidate = ext.length > 0 ? `${base}${ext}` : base try { if (existsSync(candidate) && statSync(candidate).isFile()) return candidate } catch { continue } } } return null } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } // The Hermes LSP config keys each server by language name under `lsp`: // { "lsp": { "typescript": { "command": [...], "extensions": [...] } } } function parseConfiguredLanguages(path: string): readonly string[] { let parsed: unknown try { parsed = JSON.parse(readFileSync(path, "utf-8")) } catch { return [] } if (!isRecord(parsed)) return [] const lsp = parsed["lsp"] return isRecord(lsp) ? Object.keys(lsp) : [] } function readConfigState(configPath: string): ConfigState { if (!existsSync(configPath)) return { path: configPath, exists: false, languages: [] } return { path: configPath, exists: true, languages: parseConfiguredLanguages(configPath) } } function detect(root: string, configured: readonly string[]): readonly DetectionResult[] { const extensions = collectExtensions(root) const configuredSet = new Set(configured) const results: DetectionResult[] = [] for (const server of LANGUAGES) { if (!server.extensions.some((ext: string) => extensions.has(ext))) continue const executable = server.command[0] ?? server.language const resolvedPath = resolveExecutable(executable) results.push({ server, executable, installed: resolvedPath !== null, resolvedPath, configured: configuredSet.has(server.language), }) } return results } function renderReport(root: string, results: readonly DetectionResult[], config: ConfigState): string { const lines: string[] = [`LSP setup scan: ${root}`] lines.push( `Config file: ${config.path} (${config.exists ? `present: ${config.languages.length} language(s)` : "absent"})`, "", ) if (results.length === 0) { lines.push("No languages with a recommended LSP server were detected here.") return lines.join("\n") } lines.push("DETECTED LANGUAGES (recommended server per language)") for (const result of results) { const mark = result.installed ? "OK " : "MISS" const state = result.installed ? `installed (${result.resolvedPath})` : "NOT installed" const config = result.configured ? "declared in the Hermes LSP config" : "NOT in the Hermes LSP config" lines.push( `[${mark}] ${result.server.language.padEnd(12)} exe=${result.executable} ${state} ${config}`, ) if (!result.installed) lines.push(` install: ${result.server.installHint}`) } const missing = results.filter((result) => !result.installed) const notConfigured = results.filter((result) => !result.configured) lines.push( "", missing.length === 0 ? `All ${results.length} detected server(s) installed.` : `${missing.length}/${results.length} server(s) NOT installed: ${missing.map((m) => m.server.language).join(", ")}`, notConfigured.length === 0 ? "All detected languages are declared in the Hermes LSP config." : `${notConfigured.length} language(s) absent from the Hermes LSP config: ${notConfigured.map((m) => m.server.language).join(", ")}`, "Next: read references//README.md, then add the language under `lsp` in the Hermes LSP config.", ) return lines.join("\n") } function main(): void { const args = process.argv.slice(2) const wantsJson = args.includes("--json") const configFlag = args.find((arg: string) => arg.startsWith("--config=")) const configPath = configFlag ? configFlag.slice("--config=".length) : LSP_CONFIG_FILE const root = args.find((arg: string) => !arg.startsWith("--")) ?? process.cwd() if (!existsSync(root)) { process.stderr.write(`detect-lsp: target directory does not exist: ${root}\n`) process.exit(2) } const config = readConfigState(configPath) const results = detect(root, config.languages) if (wantsJson) { process.stdout.write(`${JSON.stringify({ root, config, results }, null, 2)}\n`) return } process.stdout.write(`${renderReport(root, results, config)}\n`) } main()