Skip to main content
Glama
lsp-config.ts19.4 kB
/** * LSP Configuration System - YAML-based configuration for multiple language servers */ import fs from 'fs'; import path from 'path'; import yaml from 'js-yaml'; import { z } from 'zod'; import { parse as shellParse, type ShellQuoteToken } from 'shell-quote'; import which from 'which'; import { getAppPaths } from '../utils/app-paths.js'; import { symbolKindNamesToNumbers } from './symbol-kinds.js'; import { DEFAULT_EXTENSIONS } from './default-extensions.js'; import { expandEnvVars } from '../utils/env-expansion.js'; // Zod schemas for validation const DiagnosticsConfigSchema = z.object({ strategy: z.enum(['push', 'pull']).default('push'), wait_timeout_ms: z.number().min(100).max(30000).default(2000), }); const SymbolsConfigSchema = z.object({ containerKinds: z.array(z.union([z.string(), z.number()])).optional(), }); const LspConfigSchema = z.object({ command: z.string(), extensions: z.record(z.string(), z.string()).default({}), // file extension -> language ID, merged with DEFAULT_EXTENSIONS workspace_files: z.array(z.string()).default([]), preload_files: z.array(z.string()).default([]), // files to open during initialization diagnostics: DiagnosticsConfigSchema.default({ strategy: 'push', wait_timeout_ms: 2000, }), symbols: SymbolsConfigSchema.default({}), environment: z.record(z.string(), z.string()).optional(), workspace_loader: z.string().optional(), // workspace loader type ('default', 'roslyn', etc.) }); const ConfigFileSchema = z.object({ 'language-servers': z.record(z.string(), LspConfigSchema), }); // TypeScript interfaces derived from schemas export type DiagnosticsConfig = z.infer<typeof DiagnosticsConfigSchema>; export type SymbolsConfig = z.infer<typeof SymbolsConfigSchema>; export type LspConfig = z.infer<typeof LspConfigSchema>; export type ConfigFile = z.infer<typeof ConfigFileSchema>; export interface ConfigWithSource { config: ConfigFile; source: { path: string; type: | 'cli-arg' | 'workspace' | 'repo-cwd' | 'explicit-cwd' | 'shared-config' | 'default'; description: string; }; } /** * Parsed symbols config with containerKinds converted to numbers */ export interface ParsedSymbolsConfig { containerKinds?: number[]; } /** * Extended LSP configuration with parsed command and args * Omits symbols from LspConfig and replaces it with ParsedSymbolsConfig */ export interface ParsedLspConfig extends Omit<LspConfig, 'symbols'> { name: string; commandName: string; commandArgs: string[]; symbols: ParsedSymbolsConfig; } /** * Empty default configuration - users must provide configurations via YAML files */ const DEFAULT_CONFIG: ConfigFile = { 'language-servers': {}, }; /** * Load and parse LSP configuration from YAML file */ export function loadLspConfig( configPath?: string, workspacePath?: string ): ConfigWithSource { // Get OS-specific config directory using env-paths const paths = getAppPaths(); // Try different config locations with priority: CLI > workspace > repo > cwd > home const workspaceConfigPaths = workspacePath ? [ path.join(workspacePath, 'language-servers.yaml'), path.join(workspacePath, 'language-servers.yml'), ] : []; // Define config paths with their categories const configSourceCandidates: Array<{ path?: string; type: ConfigWithSource['source']['type']; description: string; }> = [ // P1: CLI argument (highest priority) ...(configPath ? [ { path: configPath, type: 'cli-arg' as const, description: 'Provided via --config argument', }, ] : []), // P2: Workspace directory (if provided) ...workspaceConfigPaths.map((p) => ({ path: p, type: 'workspace' as const, description: `Found in workspace directory (${workspacePath})`, })), // P3: Repo folder YAML files (relative to cwd) { path: 'language-servers.yaml', type: 'repo-cwd', description: 'Found in current directory', }, { path: 'language-servers.yml', type: 'repo-cwd', description: 'Found in current directory', }, // P4: Current working directory (explicit paths) { path: path.join(process.cwd(), 'language-servers.yaml'), type: 'explicit-cwd', description: `Found in current working directory (${process.cwd()})`, }, { path: path.join(process.cwd(), 'language-servers.yml'), type: 'explicit-cwd', description: `Found in current working directory (${process.cwd()})`, }, // P5: OS-specific config directory (lowest priority) { path: path.join(paths.config, 'language-servers.yaml'), type: 'shared-config', description: `Found in shared config directory (${paths.config})`, }, { path: path.join(paths.config, 'language-servers.yml'), type: 'shared-config', description: `Found in shared config directory (${paths.config})`, }, ]; const configSources = configSourceCandidates.filter( ( source ): source is { path: string; type: ConfigWithSource['source']['type']; description: string; } => source.path !== undefined && typeof source.path === 'string' ); for (const source of configSources) { if (!fs.existsSync(source.path)) { continue; } const resolvedPath = path.resolve(source.path); try { const yamlContent = fs.readFileSync(source.path, 'utf8'); const parsed = yaml.load(yamlContent); const config = ConfigFileSchema.parse(parsed); const expandedConfig = expandEnvironmentVariables(config); return { config: expandedConfig, source: { path: resolvedPath, type: source.type, description: source.description, }, }; } catch (error) { if (error instanceof z.ZodError) { const issues = error.issues .map((issue) => { const pathStr = issue.path.length > 0 ? issue.path.join('.') : '<root>'; return `${pathStr}: ${issue.message}`; }) .join('; '); throw new Error(`Invalid configuration in ${resolvedPath}: ${issues}`); } throw new Error( `Failed to load configuration from ${resolvedPath}: ${ error instanceof Error ? error.message : String(error) }` ); } } // If no config file is found, return default configuration return { config: expandEnvironmentVariables(DEFAULT_CONFIG), source: { path: 'default', type: 'default', description: 'Using default configuration (no config file found)', }, }; } /** * Expand environment variables in configuration values * Supports $VAR and ${VAR} syntax * @param config - Configuration to expand * * Expansion order: * 1. Expand environment values using process.env * 2. Merge expanded environment with process.env * 3. Expand command using merged environment * * This allows command to reference both system env vars AND YAML environment vars */ function expandEnvironmentVariables(config: ConfigFile): ConfigFile { return { ...config, 'language-servers': Object.fromEntries( Object.entries(config['language-servers']).map(([name, lspConfig]) => { // Step 1: Expand environment values using system env vars const expandedEnvironment = lspConfig.environment ? Object.fromEntries( Object.entries(lspConfig.environment).map(([key, value]) => [ key, expandEnvVars(value, process.env), ]) ) : undefined; // Step 2: Create merged environment for command expansion const mergedEnv = expandedEnvironment ? { ...process.env, ...expandedEnvironment } : process.env; // Step 3: Expand command using merged environment const expandedCommand = expandEnvVars(lspConfig.command, mergedEnv); return [ name, { ...lspConfig, command: expandedCommand, environment: expandedEnvironment, }, ]; }) ), }; } function describeShellToken(token: ShellQuoteToken): string { if (token === null) { return 'null'; } if (typeof token === 'object') { return JSON.stringify(token as Record<string, unknown>); } if ( typeof token === 'number' || typeof token === 'bigint' || typeof token === 'boolean' ) { return String(token); } return String(token); } /** * Get LSP configuration for a specific language server */ export function getLspConfig( lspName: string, configPath?: string, workspacePath?: string ): ParsedLspConfig | null { const { config } = loadLspConfig(configPath, workspacePath); const lspConfig = config['language-servers'][lspName]; if (!lspConfig) { return null; } // Apply environment variable overrides (SYMBOLS_* prefix) // Precedence: ENV vars > YAML config > Zod defaults // Override workspace_loader if SYMBOLS_WORKSPACE_LOADER is set if (process.env.SYMBOLS_WORKSPACE_LOADER) { lspConfig.workspace_loader = process.env.SYMBOLS_WORKSPACE_LOADER; } // Override diagnostics.strategy if SYMBOLS_DIAGNOSTICS_STRATEGY is set if (process.env.SYMBOLS_DIAGNOSTICS_STRATEGY) { const strategy = process.env.SYMBOLS_DIAGNOSTICS_STRATEGY; if (strategy !== 'push' && strategy !== 'pull') { throw new Error( `Invalid SYMBOLS_DIAGNOSTICS_STRATEGY: ${strategy}. Must be 'push' or 'pull'.` ); } lspConfig.diagnostics.strategy = strategy; } // Override diagnostics.wait_timeout_ms if SYMBOLS_DIAGNOSTICS_WAIT_TIMEOUT is set if (process.env.SYMBOLS_DIAGNOSTICS_WAIT_TIMEOUT) { const timeout = parseInt(process.env.SYMBOLS_DIAGNOSTICS_WAIT_TIMEOUT, 10); if (isNaN(timeout) || timeout < 100 || timeout > 30000) { throw new Error( `Invalid SYMBOLS_DIAGNOSTICS_WAIT_TIMEOUT: ${process.env.SYMBOLS_DIAGNOSTICS_WAIT_TIMEOUT}. Must be a number between 100 and 30000.` ); } lspConfig.diagnostics.wait_timeout_ms = timeout; } // Override preload_files if SYMBOLS_PRELOAD_FILES is set // Uses OS-specific path delimiter (: on Unix, ; on Windows) if (process.env.SYMBOLS_PRELOAD_FILES) { const preloadFiles = process.env.SYMBOLS_PRELOAD_FILES.split( path.delimiter ).filter((file) => file.trim().length > 0); lspConfig.preload_files = preloadFiles; } // Parse command into name and args (respecting quoted segments and spaces) // SECURITY NOTE: Commands are parsed from configuration files using shell-quote. // Only load configuration files from trusted sources, as malicious configs could // execute arbitrary commands. Never load configs from untrusted or user-uploaded sources. const parsedParts: ShellQuoteToken[] = shellParse(lspConfig.command.trim()); const commandSegments: string[] = []; for (const part of parsedParts) { if (typeof part !== 'string') { const description = describeShellToken(part); throw new Error( `Unsupported shell token in command for LSP ${lspName}: ${description}` ); } commandSegments.push(part); } const [commandName, ...commandArgs] = commandSegments; if (!commandName) { throw new Error(`Invalid command for LSP ${lspName}: ${lspConfig.command}`); } // Convert containerKinds from string/mixed array to numbers const symbols: ParsedSymbolsConfig = {}; if (lspConfig.symbols?.containerKinds) { symbols.containerKinds = symbolKindNamesToNumbers( lspConfig.symbols.containerKinds ); } // Merge user extensions with defaults (user extensions override defaults) const extensions = { ...DEFAULT_EXTENSIONS, ...lspConfig.extensions, }; return { ...lspConfig, extensions, symbols, name: lspName, commandName, commandArgs, }; } /** * Get LSP configuration for a file based on its extension */ export function getLspConfigForFile( filePath: string, configPath?: string, workspacePath?: string ): ParsedLspConfig | null { const extension = path.extname(filePath); const { config } = loadLspConfig(configPath, workspacePath); // Find LSP that handles this file extension for (const [lspName, lspConfig] of Object.entries( config['language-servers'] )) { if (lspConfig.extensions[extension]) { return getLspConfig(lspName, configPath, workspacePath); } } return null; } /** * Get language ID for a file based on its extension * Returns 'plaintext' if no match is found */ export function getLanguageId( filePath: string, configPath?: string, workspacePath?: string ): string { const extension = path.extname(filePath); const { config } = loadLspConfig(configPath, workspacePath); // Find language ID from LSP configuration for (const lspConfig of Object.values(config['language-servers'])) { const languageId = lspConfig.extensions[extension]; if (languageId) { return languageId; } } // Default to plaintext if no match found return 'plaintext'; } /** * List all available LSP configurations */ export function listAvailableLsps( configPath?: string, workspacePath?: string ): string[] { const { config } = loadLspConfig(configPath, workspacePath); return Object.keys(config['language-servers']); } /** * Auto-detect LSP server based on workspace files in a directory */ export function autoDetectLsp( workspacePath: string, configPath?: string ): string | null { const { config } = loadLspConfig(configPath, workspacePath); try { // Get list of files in workspace directory const workspaceFiles = fs.readdirSync(workspacePath); // Check each LSP configuration for matching workspace files for (const [lspName, lspConfig] of Object.entries( config['language-servers'] )) { for (const workspaceFile of lspConfig.workspace_files) { // Handle glob patterns (basic support for * wildcards) if (workspaceFile.includes('*')) { const pattern = workspaceFile.replace(/\*/g, '.*'); const regex = new RegExp(`^${pattern}$`); if (workspaceFiles.some((file) => regex.test(file))) { return lspName; } } else { // Exact file match if (workspaceFiles.includes(workspaceFile)) { return lspName; } } } } return null; } catch { // If we can't read the directory, return null return null; } } /** * Create a minimal ParsedLspConfig from direct command (-- mode) * Uses default extension mappings - works with any LSP server */ export function createConfigFromDirectCommand( commandName: string, commandArgs: string[] ): ParsedLspConfig { // Expand environment variables in command name (e.g., $HOME/bin/lsp) const expandedCommandName = expandEnvVars(commandName); // Validate that the command exists and is executable // Check if it's a path (absolute, relative, or contains path separators) // Covers Unix (/path, ./path, ~/path) and Windows (C:\path, .\path, path\to\file) const isPath = path.isAbsolute(expandedCommandName) || expandedCommandName.includes('/') || expandedCommandName.includes('\\') || expandedCommandName.startsWith('.') || expandedCommandName.startsWith('~'); if (isPath) { // For paths, resolve and check if file exists and is executable // Handle ~ expansion: use HOME on Unix, USERPROFILE on Windows const homeDir = process.env.HOME || process.env.USERPROFILE || '~'; const resolvedPath = path.resolve(expandedCommandName.replace(/^~/, homeDir)); if (!fs.existsSync(resolvedPath)) { throw new Error( `Command not found: ${commandName}\n` + `Expanded path: ${resolvedPath}\n` + `Please ensure the language server is installed at this location.` ); } // Check if it's a file (not a directory) const stats = fs.statSync(resolvedPath); if (!stats.isFile()) { throw new Error( `Command path is not a file: ${commandName}\n` + `Expanded path: ${resolvedPath}\n` + `Please provide a path to an executable file.` ); } // On Unix, check execute permissions. On Windows, this check is less meaningful // since executability is determined by file extension (.exe, .bat, .cmd, etc.) if (process.platform !== 'win32') { try { fs.accessSync(resolvedPath, fs.constants.X_OK); } catch { throw new Error( `Command is not executable: ${commandName}\n` + `Expanded path: ${resolvedPath}\n` + `Please ensure the file has execute permissions (chmod +x).` ); } } } else { // For commands in PATH, use which try { which.sync(expandedCommandName); } catch { throw new Error( `Command not found: ${commandName}\n` + `Please ensure the language server is installed and available in your PATH.\n` + `You can verify this by running: which ${commandName}` ); } } // Expand environment variables in command arguments as well const expandedCommandArgs = commandArgs.map(arg => expandEnvVars(arg)); // Reconstruct command string from expanded parts const command = [expandedCommandName, ...expandedCommandArgs].join(' '); // Build minimal config with default extensions // The LSP will handle only the files it recognizes const config: ParsedLspConfig = { name: 'direct-command', command, commandName: expandedCommandName, commandArgs: expandedCommandArgs, extensions: DEFAULT_EXTENSIONS, // Default mappings - works for all LSPs workspace_files: [], // Empty - not used in direct mode preload_files: [], // Empty by default - can be overridden by env var diagnostics: { strategy: 'pull', wait_timeout_ms: 2000, }, symbols: {}, workspace_loader: undefined, environment: undefined, }; // Apply environment variable overrides (same as regular mode) if (process.env.SYMBOLS_WORKSPACE_LOADER) { config.workspace_loader = process.env.SYMBOLS_WORKSPACE_LOADER; } if (process.env.SYMBOLS_DIAGNOSTICS_STRATEGY) { const strategy = process.env.SYMBOLS_DIAGNOSTICS_STRATEGY; if (strategy !== 'push' && strategy !== 'pull') { throw new Error( `Invalid SYMBOLS_DIAGNOSTICS_STRATEGY: ${strategy}. Must be 'push' or 'pull'.` ); } config.diagnostics.strategy = strategy; } if (process.env.SYMBOLS_DIAGNOSTICS_WAIT_TIMEOUT) { const timeout = parseInt(process.env.SYMBOLS_DIAGNOSTICS_WAIT_TIMEOUT, 10); if (isNaN(timeout) || timeout < 100 || timeout > 30000) { throw new Error( `Invalid SYMBOLS_DIAGNOSTICS_WAIT_TIMEOUT: ${process.env.SYMBOLS_DIAGNOSTICS_WAIT_TIMEOUT}. Must be a number between 100 and 30000.` ); } config.diagnostics.wait_timeout_ms = timeout; } if (process.env.SYMBOLS_PRELOAD_FILES) { const preloadFiles = process.env.SYMBOLS_PRELOAD_FILES.split( path.delimiter ).filter((file) => file.trim().length > 0); config.preload_files = preloadFiles; } return config; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/p1va/symbols-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server