Files
oh-my-pi/packages/coding-agent/src/lsp/config.ts
T
can1357 e22d123009 refactor(fs): migrated remaining sync file operations to async
- Converted readdirSync, readFileSync, and statSync to async readdir, readFile, stat across skills and agent discovery.
- Made scanDirectoryForSkills async and refactored custom directory scanning to use Promise.all for concurrent processing.
- Updated agent discovery to use fs/promises for async file reading and refactored helper patterns.
- Added AgentParsingError exception class for better error handling during agent parsing.
- Added filesystem error type guards (isEnoent, isEacces, isPerm, etc.) to pi-utils for safe error checking.
- Added color manipulation utilities to pi-utils for accessibility features.
- Added color-blind mode setting to settings manager.
- Migrated plugins, settings, and config modules from sync to async file operations.
- Updated error handling to use new pi-utils type guards for type-safe checking.
2026-01-24 03:18:03 +01:00

396 lines
13 KiB
TypeScript

import { homedir } from "node:os";
import * as path from "node:path";
import { logger } from "@oh-my-pi/pi-utils";
import { YAML } from "bun";
import { globSync } from "glob";
import { getConfigDirPaths } from "../config";
import { BiomeClient } from "./clients/biome-client";
import DEFAULTS from "./defaults.json" with { type: "json" };
import type { ServerConfig } from "./types";
export interface LspConfig {
servers: Record<string, ServerConfig>;
/** Idle timeout in milliseconds. If set, LSP clients will be shutdown after this period of inactivity. Disabled by default. */
idleTimeoutMs?: number;
}
// =============================================================================
// Default Server Configuration Loading
// =============================================================================
const PID_TOKEN = "$PID";
interface NormalizedConfig {
servers: Record<string, Partial<ServerConfig>>;
idleTimeoutMs?: number;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseConfigContent(content: string, filePath: string): unknown {
const extension = path.extname(filePath).toLowerCase();
if (extension === ".yaml" || extension === ".yml") {
return YAML.parse(content) as unknown;
}
return JSON.parse(content) as unknown;
}
function normalizeConfig(value: unknown): NormalizedConfig | null {
if (!isRecord(value)) return null;
const idleTimeoutMs = typeof value.idleTimeoutMs === "number" ? value.idleTimeoutMs : undefined;
const rawServers = value.servers;
if (isRecord(rawServers)) {
return { servers: rawServers as Record<string, Partial<ServerConfig>>, idleTimeoutMs };
}
const servers = Object.fromEntries(Object.entries(value).filter(([key]) => key !== "idleTimeoutMs")) as Record<
string,
Partial<ServerConfig>
>;
return { servers, idleTimeoutMs };
}
function normalizeStringArray(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const items = value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
return items.length > 0 ? items : null;
}
function normalizeServerConfig(name: string, config: Partial<ServerConfig>): ServerConfig | null {
const command = typeof config.command === "string" && config.command.length > 0 ? config.command : null;
const fileTypes = normalizeStringArray(config.fileTypes);
const rootMarkers = normalizeStringArray(config.rootMarkers);
if (!command || !fileTypes || !rootMarkers) {
logger.warn("Ignoring invalid LSP server config (missing required fields).", { name });
return null;
}
const args = Array.isArray(config.args)
? config.args.filter((entry): entry is string => typeof entry === "string")
: undefined;
return {
...config,
command,
args,
fileTypes,
rootMarkers,
};
}
async function readConfigFile(filePath: string): Promise<NormalizedConfig | null> {
try {
const file = Bun.file(filePath);
if (!(await file.exists())) {
return null;
}
const content = await file.text();
const parsed = parseConfigContent(content, filePath);
return normalizeConfig(parsed);
} catch {
return null;
}
}
function coerceServerConfigs(servers: Record<string, Partial<ServerConfig>>): Record<string, ServerConfig> {
const result: Record<string, ServerConfig> = {};
for (const [name, config] of Object.entries(servers)) {
const normalized = normalizeServerConfig(name, config);
if (normalized) {
result[name] = normalized;
}
}
return result;
}
function mergeServers(
base: Record<string, ServerConfig>,
overrides: Record<string, Partial<ServerConfig>>,
): Record<string, ServerConfig> {
const merged: Record<string, ServerConfig> = { ...base };
for (const [name, config] of Object.entries(overrides)) {
if (merged[name]) {
const candidate = { ...merged[name], ...config };
const normalized = normalizeServerConfig(name, candidate);
if (normalized) {
merged[name] = normalized;
} else {
logger.warn("Ignoring invalid LSP overrides (keeping previous config).", { name });
}
} else {
const normalized = normalizeServerConfig(name, config);
if (normalized) {
merged[name] = normalized;
}
}
}
return merged;
}
function applyRuntimeDefaults(servers: Record<string, ServerConfig>): Record<string, ServerConfig> {
const updated: Record<string, ServerConfig> = { ...servers };
if (updated.biome) {
updated.biome = { ...updated.biome, createClient: BiomeClient.create };
}
if (updated.omnisharp?.args) {
const args = updated.omnisharp.args.map((arg) => (arg === PID_TOKEN ? String(process.pid) : arg));
updated.omnisharp = { ...updated.omnisharp, args };
}
return updated;
}
// =============================================================================
// Configuration Loading
// =============================================================================
/**
* Check if any root marker file exists in the directory
*/
export async function hasRootMarkers(cwd: string, markers: string[]): Promise<boolean> {
for (const marker of markers) {
// Handle glob-like patterns (e.g., "*.cabal")
if (marker.includes("*")) {
try {
const matches = globSync(path.join(cwd, marker));
if (matches.length > 0) {
return true;
}
} catch {
logger.warn("Failed to resolve glob root marker.", { marker, cwd });
}
continue;
}
const filePath = path.join(cwd, marker);
if (await Bun.file(filePath).exists()) {
return true;
}
}
return false;
}
// =============================================================================
// Local Binary Resolution
// =============================================================================
/**
* Local bin directories to check before $PATH, ordered by priority.
* Each entry maps a root marker to the bin directory to check.
*/
const LOCAL_BIN_PATHS: Array<{ markers: string[]; binDir: string }> = [
// Node.js - check node_modules/.bin/
{ markers: ["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"], binDir: "node_modules/.bin" },
// Python - check virtual environment bin directories
{ markers: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"], binDir: ".venv/bin" },
{ markers: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"], binDir: "venv/bin" },
{ markers: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile"], binDir: ".env/bin" },
// Ruby - check vendor bundle and binstubs
{ markers: ["Gemfile", "Gemfile.lock"], binDir: "vendor/bundle/bin" },
{ markers: ["Gemfile", "Gemfile.lock"], binDir: "bin" },
// Go - check project-local bin
{ markers: ["go.mod", "go.sum"], binDir: "bin" },
];
/**
* Resolve a command to an executable path.
* Checks project-local bin directories first, then falls back to $PATH.
*
* @param command - The command name (e.g., "typescript-language-server")
* @param cwd - Working directory to search from
* @returns Absolute path to the executable, or null if not found
*/
export async function resolveCommand(command: string, cwd: string): Promise<string | null> {
// Check local bin directories based on project markers
for (const { markers, binDir } of LOCAL_BIN_PATHS) {
if (await hasRootMarkers(cwd, markers)) {
const localPath = path.join(cwd, binDir, command);
if (await Bun.file(localPath).exists()) {
return localPath;
}
}
}
// Fall back to $PATH
return Bun.which(command);
}
/**
* Configuration file search paths (in priority order).
* Supports both visible and hidden variants at each config location.
*/
function getConfigPaths(cwd: string): string[] {
const filenames = ["lsp.json", ".lsp.json", "lsp.yaml", ".lsp.yaml", "lsp.yml", ".lsp.yml"];
const paths: string[] = [];
// Project root files (highest priority)
for (const filename of filenames) {
paths.push(path.join(cwd, filename));
}
// Project config directories (.omp/, .pi/, .claude/)
const projectDirs = getConfigDirPaths("", { user: false, project: true, cwd });
for (const dir of projectDirs) {
for (const filename of filenames) {
paths.push(path.join(dir, filename));
}
}
// User config directories (~/.omp/agent/, ~/.pi/agent/, ~/.claude/)
const userDirs = getConfigDirPaths("", { user: true, project: false });
for (const dir of userDirs) {
for (const filename of filenames) {
paths.push(path.join(dir, filename));
}
}
// User home root files (lowest priority fallback)
for (const filename of filenames) {
paths.push(path.join(homedir(), filename));
}
return paths;
}
/**
* Load LSP configuration.
*
* Priority (highest to lowest):
* 1. Project root: lsp.json/.lsp.json/lsp.yml/.lsp.yml/lsp.yaml/.lsp.yaml
* 2. Project config dirs: .omp/lsp.*, .pi/lsp.*, .claude/lsp.* (+ hidden variants)
* 3. User config dirs: ~/.omp/agent/lsp.*, ~/.pi/agent/lsp.*, ~/.claude/lsp.* (+ hidden variants)
* 4. User home root: ~/lsp.*, ~/.lsp.*
* 5. Auto-detect from project markers + available binaries
*
* Config files are merged from lowest to highest priority; later files override earlier settings.
*
* Config file format (JSON or YAML):
* ```json
* {
* "servers": {
* "typescript-language-server": {
* "command": "typescript-language-server",
* "args": ["--stdio", "--log-level", "4"],
* "disabled": false
* },
* "my-custom-server": {
* "command": "/path/to/server",
* "args": ["--stdio"],
* "fileTypes": [".xyz"],
* "rootMarkers": [".xyz-project"]
* }
* }
* }
* ```
*/
export async function loadConfig(cwd: string): Promise<LspConfig> {
let mergedServers = coerceServerConfigs(DEFAULTS);
const configPaths = getConfigPaths(cwd).reverse();
let hasOverrides = false;
let idleTimeoutMs: number | undefined;
for (const configPath of configPaths) {
const parsed = await readConfigFile(configPath);
if (!parsed) continue;
const hasServerOverrides = Object.keys(parsed.servers).length > 0;
if (hasServerOverrides) {
hasOverrides = true;
mergedServers = mergeServers(mergedServers, parsed.servers);
}
if (parsed.idleTimeoutMs !== undefined) {
idleTimeoutMs = parsed.idleTimeoutMs;
}
}
if (!hasOverrides) {
// Auto-detect: find servers based on project markers AND available binaries
const detected: Record<string, ServerConfig> = {};
const defaultsWithRuntime = applyRuntimeDefaults(mergedServers);
for (const [name, config] of Object.entries(defaultsWithRuntime)) {
// Check if project has root markers for this language
if (!(await hasRootMarkers(cwd, config.rootMarkers))) continue;
// Check if the language server binary is available (local or $PATH)
const resolved = await resolveCommand(config.command, cwd);
if (!resolved) continue;
detected[name] = { ...config, resolvedCommand: resolved };
}
return { servers: detected, idleTimeoutMs };
}
// Merge overrides with defaults and filter to available servers
const mergedWithRuntime = applyRuntimeDefaults(mergedServers);
const available: Record<string, ServerConfig> = {};
for (const [name, config] of Object.entries(mergedWithRuntime)) {
if (config.disabled) continue;
const resolved = await resolveCommand(config.command, cwd);
if (!resolved) continue;
available[name] = { ...config, resolvedCommand: resolved };
}
return { servers: available, idleTimeoutMs };
}
// =============================================================================
// Server Selection
// =============================================================================
/**
* Find all servers that can handle a file based on extension.
* Returns servers sorted with primary (non-linter) servers first.
*/
export function getServersForFile(config: LspConfig, filePath: string): Array<[string, ServerConfig]> {
const ext = path.extname(filePath).toLowerCase();
const fileName = path.basename(filePath).toLowerCase();
const matches: Array<[string, ServerConfig]> = [];
for (const [name, serverConfig] of Object.entries(config.servers)) {
const supportsFile = serverConfig.fileTypes.some((fileType) => {
const normalized = fileType.toLowerCase();
return normalized === ext || normalized === fileName;
});
if (supportsFile) {
matches.push([name, serverConfig]);
}
}
// Sort: primary servers (non-linters) first, then linters
return matches.sort((a, b) => {
const aIsLinter = a[1].isLinter ? 1 : 0;
const bIsLinter = b[1].isLinter ? 1 : 0;
return aIsLinter - bIsLinter;
});
}
/**
* Find the primary server for a file (prefers type-checkers over linters).
* Used for operations like definition, hover, references that need type intelligence.
*/
export function getServerForFile(config: LspConfig, filePath: string): [string, ServerConfig] | null {
const servers = getServersForFile(config, filePath);
return servers.length > 0 ? servers[0] : null;
}
/**
* Check if a server has a specific capability
*/
export function hasCapability(
config: ServerConfig,
capability: keyof NonNullable<ServerConfig["capabilities"]>,
): boolean {
return config.capabilities?.[capability] === true;
}