feat: implement hooks, plugins, session forks, and vim motions

Add 4 major features to codetyper-cli:

- Hooks System: Lifecycle hooks (PreToolUse, PostToolUse, SessionStart,
  SessionEnd, UserPromptSubmit, Stop) with exit code control flow
- Plugin System: Custom tools, commands, and hooks via plugin manifest
- Session Forking: Snapshots, rewind, fork, and switch between branches
- Vim Motions: Normal/Insert/Command/Visual modes with keyboard navigation

New files:
- src/types/{hooks,plugin,session-fork,vim}.ts
- src/constants/{hooks,plugin,session-fork,vim}.ts
- src/services/{hooks-service,plugin-loader,plugin-service,session-fork-service}.ts
- src/stores/vim-store.ts (vanilla)
- src/tui/hooks/{useVimMode,useVimStore,useTodoStore,useThemeStore}.ts
- src/tui/components/VimStatusLine.tsx

Modified:
- src/services/agent.ts (hook integration)
- src/tools/index.ts (plugin tool registration)
- src/stores/{todo-store,theme-store}.ts (converted to vanilla)
- TUI components (updated hook imports)
This commit is contained in:
2026-01-31 22:22:04 -05:00
parent 37d4a43154
commit a3c407d89a
56 changed files with 7507 additions and 90 deletions

431
src/services/lsp/client.ts Normal file
View File

@@ -0,0 +1,431 @@
/**
* LSP Client Implementation
*
* Handles LSP protocol communication with language servers
*/
import type { ChildProcess } from "child_process";
import { createInterface } from "readline";
import { EventEmitter } from "events";
import { getLanguageId } from "@services/lsp/language";
export interface Position {
line: number;
character: number;
}
export interface Range {
start: Position;
end: Position;
}
export interface Location {
uri: string;
range: Range;
}
export interface Diagnostic {
range: Range;
severity?: 1 | 2 | 3 | 4; // Error, Warning, Info, Hint
code?: string | number;
source?: string;
message: string;
}
export interface CompletionItem {
label: string;
kind?: number;
detail?: string;
documentation?: string | { kind: string; value: string };
insertText?: string;
}
export interface DocumentSymbol {
name: string;
kind: number;
range: Range;
selectionRange: Range;
children?: DocumentSymbol[];
}
export interface Hover {
contents: string | { kind: string; value: string } | Array<string | { kind: string; value: string }>;
range?: Range;
}
export interface LSPClientInfo {
serverId: string;
root: string;
capabilities: Record<string, unknown>;
}
export interface LSPClientEvents {
diagnostics: (uri: string, diagnostics: Diagnostic[]) => void;
error: (error: Error) => void;
close: () => void;
}
type RequestId = number;
interface PendingRequest {
resolve: (result: unknown) => void;
reject: (error: Error) => void;
}
export class LSPClient extends EventEmitter {
private process: ChildProcess;
private serverId: string;
private root: string;
private requestId: RequestId = 0;
private pendingRequests: Map<RequestId, PendingRequest> = new Map();
private initialized: boolean = false;
private capabilities: Record<string, unknown> = {};
private openFiles: Map<string, number> = new Map(); // uri -> version
private diagnosticsMap: Map<string, Diagnostic[]> = new Map();
private buffer: string = "";
constructor(process: ChildProcess, serverId: string, root: string) {
super();
this.process = process;
this.serverId = serverId;
this.root = root;
this.setupHandlers();
}
private setupHandlers(): void {
const rl = createInterface({
input: this.process.stdout!,
crlfDelay: Infinity,
});
let contentLength = 0;
let headers = true;
rl.on("line", (line) => {
if (headers) {
if (line.startsWith("Content-Length:")) {
contentLength = parseInt(line.slice(15).trim(), 10);
} else if (line === "") {
headers = false;
this.buffer = "";
}
} else {
this.buffer += line;
if (this.buffer.length >= contentLength) {
try {
const message = JSON.parse(this.buffer);
this.handleMessage(message);
} catch {
// Ignore parse errors
}
headers = true;
contentLength = 0;
this.buffer = "";
}
}
});
this.process.on("close", () => {
this.emit("close");
});
this.process.on("error", (err) => {
this.emit("error", err);
});
}
private handleMessage(message: {
id?: RequestId;
method?: string;
result?: unknown;
error?: { code: number; message: string };
params?: unknown;
}): void {
// Response to our request
if (message.id !== undefined && this.pendingRequests.has(message.id)) {
const pending = this.pendingRequests.get(message.id)!;
this.pendingRequests.delete(message.id);
if (message.error) {
pending.reject(new Error(message.error.message));
} else {
pending.resolve(message.result);
}
return;
}
// Notification from server
if (message.method) {
this.handleNotification(message.method, message.params);
}
}
private handleNotification(method: string, params: unknown): void {
if (method === "textDocument/publishDiagnostics") {
const { uri, diagnostics } = params as { uri: string; diagnostics: Diagnostic[] };
this.diagnosticsMap.set(uri, diagnostics);
this.emit("diagnostics", uri, diagnostics);
}
// Handle other notifications as needed
}
private send(message: Record<string, unknown>): void {
const content = JSON.stringify(message);
const header = `Content-Length: ${Buffer.byteLength(content)}\r\n\r\n`;
this.process.stdin!.write(header + content);
}
private async request<T>(method: string, params?: unknown): Promise<T> {
const id = ++this.requestId;
return new Promise<T>((resolve, reject) => {
this.pendingRequests.set(id, {
resolve: resolve as (result: unknown) => void,
reject,
});
this.send({
jsonrpc: "2.0",
id,
method,
params,
});
// Timeout after 30 seconds
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error(`Request ${method} timed out`));
}
}, 30000);
});
}
private notify(method: string, params?: unknown): void {
this.send({
jsonrpc: "2.0",
method,
params,
});
}
async initialize(): Promise<void> {
if (this.initialized) return;
const result = await this.request<{ capabilities: Record<string, unknown> }>("initialize", {
processId: process.pid,
rootUri: `file://${this.root}`,
rootPath: this.root,
capabilities: {
textDocument: {
synchronization: {
didSave: true,
didOpen: true,
didClose: true,
didChange: 2, // Incremental
},
completion: {
completionItem: {
snippetSupport: true,
documentationFormat: ["markdown", "plaintext"],
},
},
hover: {
contentFormat: ["markdown", "plaintext"],
},
definition: {
linkSupport: true,
},
references: {},
documentSymbol: {
hierarchicalDocumentSymbolSupport: true,
},
publishDiagnostics: {
relatedInformation: true,
},
},
workspace: {
workspaceFolders: true,
didChangeConfiguration: {
dynamicRegistration: true,
},
},
},
workspaceFolders: [
{
uri: `file://${this.root}`,
name: this.root.split("/").pop(),
},
],
});
this.capabilities = result.capabilities;
this.initialized = true;
this.notify("initialized", {});
}
async openFile(filePath: string, content: string): Promise<void> {
const uri = `file://${filePath}`;
const languageId = getLanguageId(filePath) ?? "plaintext";
const version = 1;
this.openFiles.set(uri, version);
this.notify("textDocument/didOpen", {
textDocument: {
uri,
languageId,
version,
text: content,
},
});
}
async updateFile(filePath: string, content: string): Promise<void> {
const uri = `file://${filePath}`;
const currentVersion = this.openFiles.get(uri) ?? 0;
const newVersion = currentVersion + 1;
this.openFiles.set(uri, newVersion);
this.notify("textDocument/didChange", {
textDocument: { uri, version: newVersion },
contentChanges: [{ text: content }],
});
}
async closeFile(filePath: string): Promise<void> {
const uri = `file://${filePath}`;
this.openFiles.delete(uri);
this.diagnosticsMap.delete(uri);
this.notify("textDocument/didClose", {
textDocument: { uri },
});
}
async getHover(filePath: string, position: Position): Promise<Hover | null> {
const uri = `file://${filePath}`;
try {
return await this.request<Hover | null>("textDocument/hover", {
textDocument: { uri },
position,
});
} catch {
return null;
}
}
async getDefinition(filePath: string, position: Position): Promise<Location | Location[] | null> {
const uri = `file://${filePath}`;
try {
return await this.request<Location | Location[] | null>("textDocument/definition", {
textDocument: { uri },
position,
});
} catch {
return null;
}
}
async getReferences(filePath: string, position: Position, includeDeclaration = true): Promise<Location[]> {
const uri = `file://${filePath}`;
try {
const result = await this.request<Location[] | null>("textDocument/references", {
textDocument: { uri },
position,
context: { includeDeclaration },
});
return result ?? [];
} catch {
return [];
}
}
async getCompletions(filePath: string, position: Position): Promise<CompletionItem[]> {
const uri = `file://${filePath}`;
try {
const result = await this.request<{ items: CompletionItem[] } | CompletionItem[] | null>(
"textDocument/completion",
{
textDocument: { uri },
position,
},
);
if (!result) return [];
return Array.isArray(result) ? result : result.items;
} catch {
return [];
}
}
async getDocumentSymbols(filePath: string): Promise<DocumentSymbol[]> {
const uri = `file://${filePath}`;
try {
const result = await this.request<DocumentSymbol[] | null>("textDocument/documentSymbol", {
textDocument: { uri },
});
return result ?? [];
} catch {
return [];
}
}
getDiagnostics(filePath?: string): Diagnostic[] {
if (filePath) {
const uri = `file://${filePath}`;
return this.diagnosticsMap.get(uri) ?? [];
}
// Return all diagnostics
const all: Diagnostic[] = [];
for (const diagnostics of this.diagnosticsMap.values()) {
all.push(...diagnostics);
}
return all;
}
getAllDiagnostics(): Map<string, Diagnostic[]> {
return new Map(this.diagnosticsMap);
}
getInfo(): LSPClientInfo {
return {
serverId: this.serverId,
root: this.root,
capabilities: this.capabilities,
};
}
isFileOpen(filePath: string): boolean {
const uri = `file://${filePath}`;
return this.openFiles.has(uri);
}
shutdown(): void {
this.request("shutdown", null)
.then(() => {
this.notify("exit");
this.process.kill();
})
.catch(() => {
this.process.kill();
});
}
}
export const createLSPClient = (
process: ChildProcess,
serverId: string,
root: string,
): LSPClient => {
return new LSPClient(process, serverId, root);
};

357
src/services/lsp/index.ts Normal file
View File

@@ -0,0 +1,357 @@
/**
* LSP Service - Main entry point for language server functionality
*
* Provides:
* - Language detection
* - Server startup/shutdown management
* - Real-time diagnostics
* - Code completion
* - Document symbols
* - References finding
* - Definition jumping
* - Hover information
*/
import fs from "fs/promises";
import path from "path";
import { EventEmitter } from "events";
import {
LSPClient,
createLSPClient,
type Diagnostic,
type Position,
type Location,
type CompletionItem,
type DocumentSymbol,
type Hover,
} from "@services/lsp/client";
import {
getServersForFile,
findRootForServer,
spawnServer,
type ServerInfo,
} from "@services/lsp/server";
import { getLanguageId } from "@services/lsp/language";
interface LSPState {
clients: Map<string, LSPClient>; // key: `${root}:${serverId}`
spawning: Map<string, Promise<LSPClient | null>>;
broken: Set<string>;
}
const state: LSPState = {
clients: new Map(),
spawning: new Map(),
broken: new Set(),
};
const events = new EventEmitter();
const getClientKey = (root: string, serverId: string): string => {
return `${root}:${serverId}`;
};
const getClientsForFile = async (filePath: string): Promise<LSPClient[]> => {
const servers = getServersForFile(filePath);
const clients: LSPClient[] = [];
for (const server of servers) {
const root = await findRootForServer(filePath, server);
if (!root) continue;
const key = getClientKey(root, server.id);
// Skip broken servers
if (state.broken.has(key)) continue;
// Check for existing client
if (state.clients.has(key)) {
clients.push(state.clients.get(key)!);
continue;
}
// Check for in-flight spawn
if (state.spawning.has(key)) {
const client = await state.spawning.get(key);
if (client) clients.push(client);
continue;
}
// Spawn new client
const spawnPromise = spawnClient(server, root);
state.spawning.set(key, spawnPromise);
try {
const client = await spawnPromise;
if (client) {
clients.push(client);
}
} finally {
state.spawning.delete(key);
}
}
return clients;
};
const spawnClient = async (
server: ServerInfo,
root: string,
): Promise<LSPClient | null> => {
const key = getClientKey(root, server.id);
try {
const handle = await spawnServer(server, root);
if (!handle) {
state.broken.add(key);
return null;
}
const client = createLSPClient(handle.process, server.id, root);
client.on("close", () => {
state.clients.delete(key);
events.emit("clientClosed", { serverId: server.id, root });
});
client.on("error", () => {
state.clients.delete(key);
state.broken.add(key);
});
client.on("diagnostics", (uri: string, diagnostics: Diagnostic[]) => {
events.emit("diagnostics", { uri, diagnostics, serverId: server.id });
});
await client.initialize();
state.clients.set(key, client);
events.emit("clientConnected", { serverId: server.id, root });
return client;
} catch {
state.broken.add(key);
return null;
}
};
// Public API
export const openFile = async (filePath: string): Promise<void> => {
const absolutePath = path.resolve(filePath);
const clients = await getClientsForFile(absolutePath);
if (clients.length === 0) return;
const content = await fs.readFile(absolutePath, "utf-8");
for (const client of clients) {
if (!client.isFileOpen(absolutePath)) {
await client.openFile(absolutePath, content);
}
}
};
export const updateFile = async (filePath: string, content: string): Promise<void> => {
const absolutePath = path.resolve(filePath);
const clients = await getClientsForFile(absolutePath);
for (const client of clients) {
if (client.isFileOpen(absolutePath)) {
await client.updateFile(absolutePath, content);
} else {
await client.openFile(absolutePath, content);
}
}
};
export const closeFile = async (filePath: string): Promise<void> => {
const absolutePath = path.resolve(filePath);
const clients = await getClientsForFile(absolutePath);
for (const client of clients) {
if (client.isFileOpen(absolutePath)) {
await client.closeFile(absolutePath);
}
}
};
export const getHover = async (filePath: string, position: Position): Promise<Hover | null> => {
const absolutePath = path.resolve(filePath);
const clients = await getClientsForFile(absolutePath);
for (const client of clients) {
const hover = await client.getHover(absolutePath, position);
if (hover) return hover;
}
return null;
};
export const getDefinition = async (
filePath: string,
position: Position,
): Promise<Location | Location[] | null> => {
const absolutePath = path.resolve(filePath);
const clients = await getClientsForFile(absolutePath);
for (const client of clients) {
const definition = await client.getDefinition(absolutePath, position);
if (definition) return definition;
}
return null;
};
export const getReferences = async (
filePath: string,
position: Position,
includeDeclaration = true,
): Promise<Location[]> => {
const absolutePath = path.resolve(filePath);
const clients = await getClientsForFile(absolutePath);
const allRefs: Location[] = [];
for (const client of clients) {
const refs = await client.getReferences(absolutePath, position, includeDeclaration);
allRefs.push(...refs);
}
// Deduplicate by URI and range
const seen = new Set<string>();
return allRefs.filter((loc) => {
const key = `${loc.uri}:${loc.range.start.line}:${loc.range.start.character}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
export const getCompletions = async (
filePath: string,
position: Position,
): Promise<CompletionItem[]> => {
const absolutePath = path.resolve(filePath);
const clients = await getClientsForFile(absolutePath);
const allCompletions: CompletionItem[] = [];
for (const client of clients) {
const completions = await client.getCompletions(absolutePath, position);
allCompletions.push(...completions);
}
return allCompletions;
};
export const getDocumentSymbols = async (filePath: string): Promise<DocumentSymbol[]> => {
const absolutePath = path.resolve(filePath);
const clients = await getClientsForFile(absolutePath);
for (const client of clients) {
const symbols = await client.getDocumentSymbols(absolutePath);
if (symbols.length > 0) return symbols;
}
return [];
};
export const getDiagnostics = (filePath?: string): Map<string, Diagnostic[]> => {
const allDiagnostics = new Map<string, Diagnostic[]>();
for (const client of state.clients.values()) {
const clientDiagnostics = client.getAllDiagnostics();
for (const [uri, diagnostics] of clientDiagnostics) {
if (filePath) {
const expectedUri = `file://${path.resolve(filePath)}`;
if (uri !== expectedUri) continue;
}
const existing = allDiagnostics.get(uri) ?? [];
allDiagnostics.set(uri, [...existing, ...diagnostics]);
}
}
return allDiagnostics;
};
export const getStatus = (): {
connected: Array<{ serverId: string; root: string }>;
broken: string[];
} => {
const connected = Array.from(state.clients.values()).map((client) => client.getInfo());
const broken = Array.from(state.broken);
return { connected, broken };
};
export const hasSupport = (filePath: string): boolean => {
const servers = getServersForFile(filePath);
return servers.length > 0;
};
export const getLanguage = (filePath: string): string | null => {
return getLanguageId(filePath);
};
export const shutdown = (): void => {
for (const client of state.clients.values()) {
client.shutdown();
}
state.clients.clear();
state.spawning.clear();
state.broken.clear();
};
export const onDiagnostics = (
callback: (data: { uri: string; diagnostics: Diagnostic[]; serverId: string }) => void,
): (() => void) => {
events.on("diagnostics", callback);
return () => events.off("diagnostics", callback);
};
export const onClientConnected = (
callback: (data: { serverId: string; root: string }) => void,
): (() => void) => {
events.on("clientConnected", callback);
return () => events.off("clientConnected", callback);
};
export const onClientClosed = (
callback: (data: { serverId: string; root: string }) => void,
): (() => void) => {
events.on("clientClosed", callback);
return () => events.off("clientClosed", callback);
};
export const lspService = {
openFile,
updateFile,
closeFile,
getHover,
getDefinition,
getReferences,
getCompletions,
getDocumentSymbols,
getDiagnostics,
getStatus,
hasSupport,
getLanguage,
shutdown,
onDiagnostics,
onClientConnected,
onClientClosed,
};
// Re-export types
export type {
Diagnostic,
Position,
Range,
Location,
CompletionItem,
DocumentSymbol,
Hover,
} from "@services/lsp/client";
export { getLanguageId, getSupportedExtensions } from "@services/lsp/language";
export { SERVERS, getAvailableServers } from "@services/lsp/server";

View File

@@ -0,0 +1,182 @@
/**
* Language Detection and Extension Mapping
*
* Maps file extensions to LSP language IDs
*/
export const LANGUAGE_EXTENSIONS: Record<string, string> = {
// TypeScript/JavaScript
".ts": "typescript",
".tsx": "typescriptreact",
".js": "javascript",
".jsx": "javascriptreact",
".mjs": "javascript",
".cjs": "javascript",
".mts": "typescript",
".cts": "typescript",
// Web
".html": "html",
".htm": "html",
".css": "css",
".scss": "scss",
".sass": "sass",
".less": "less",
".vue": "vue",
".svelte": "svelte",
".astro": "astro",
// Python
".py": "python",
".pyi": "python",
".pyw": "python",
// Go
".go": "go",
".mod": "go.mod",
".sum": "go.sum",
// Rust
".rs": "rust",
// C/C++
".c": "c",
".h": "c",
".cpp": "cpp",
".cxx": "cpp",
".cc": "cpp",
".hpp": "cpp",
".hxx": "cpp",
".hh": "cpp",
// Java/Kotlin
".java": "java",
".kt": "kotlin",
".kts": "kotlin",
// C#/F#
".cs": "csharp",
".fs": "fsharp",
".fsx": "fsharp",
// Ruby
".rb": "ruby",
".rake": "ruby",
".gemspec": "ruby",
// PHP
".php": "php",
// Swift
".swift": "swift",
// Lua
".lua": "lua",
// Shell
".sh": "shellscript",
".bash": "shellscript",
".zsh": "shellscript",
".fish": "fish",
// Data formats
".json": "json",
".jsonc": "jsonc",
".yaml": "yaml",
".yml": "yaml",
".toml": "toml",
".xml": "xml",
// Markdown/Docs
".md": "markdown",
".mdx": "mdx",
".rst": "restructuredtext",
// SQL
".sql": "sql",
// Docker
Dockerfile: "dockerfile",
".dockerfile": "dockerfile",
// Config
".env": "dotenv",
".ini": "ini",
".conf": "conf",
// Elixir
".ex": "elixir",
".exs": "elixir",
// Zig
".zig": "zig",
// Dart
".dart": "dart",
// Haskell
".hs": "haskell",
".lhs": "haskell",
// OCaml
".ml": "ocaml",
".mli": "ocaml",
// Clojure
".clj": "clojure",
".cljs": "clojurescript",
".cljc": "clojure",
// Scala
".scala": "scala",
".sc": "scala",
// Erlang
".erl": "erlang",
".hrl": "erlang",
// Nix
".nix": "nix",
// Terraform
".tf": "terraform",
".tfvars": "terraform",
// Prisma
".prisma": "prisma",
// GraphQL
".graphql": "graphql",
".gql": "graphql",
// Protobuf
".proto": "proto",
// Makefile
Makefile: "makefile",
".mk": "makefile",
// Gleam
".gleam": "gleam",
// Typst
".typ": "typst",
};
export const getLanguageId = (filePath: string): string | null => {
const ext = filePath.includes(".")
? "." + filePath.split(".").pop()
: filePath.split("/").pop() ?? "";
return LANGUAGE_EXTENSIONS[ext] ?? LANGUAGE_EXTENSIONS[filePath.split("/").pop() ?? ""] ?? null;
};
export const getExtensionsForLanguage = (languageId: string): string[] => {
return Object.entries(LANGUAGE_EXTENSIONS)
.filter(([_, lang]) => lang === languageId)
.map(([ext]) => ext);
};
export const getSupportedExtensions = (): string[] => {
return Object.keys(LANGUAGE_EXTENSIONS);
};

267
src/services/lsp/server.ts Normal file
View File

@@ -0,0 +1,267 @@
/**
* LSP Server Definitions
*
* Defines how to find and spawn language servers
*/
import { spawn, execSync, type ChildProcess } from "child_process";
import path from "path";
import fs from "fs/promises";
export interface ServerHandle {
process: ChildProcess;
capabilities?: Record<string, unknown>;
}
export interface ServerInfo {
id: string;
name: string;
extensions: string[];
rootPatterns: string[];
command: string;
args?: string[];
env?: Record<string, string>;
}
const fileExists = async (filePath: string): Promise<boolean> => {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
};
const findProjectRoot = async (
startDir: string,
patterns: string[],
): Promise<string | null> => {
let currentDir = startDir;
const root = path.parse(currentDir).root;
while (currentDir !== root) {
for (const pattern of patterns) {
const checkPath = path.join(currentDir, pattern);
if (await fileExists(checkPath)) {
return currentDir;
}
}
currentDir = path.dirname(currentDir);
}
return null;
};
const findBinary = async (name: string): Promise<string | null> => {
try {
const command = process.platform === "win32" ? `where ${name}` : `which ${name}`;
const result = execSync(command, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
return result.trim().split("\n")[0] || null;
} catch {
return null;
}
};
export const SERVERS: Record<string, ServerInfo> = {
typescript: {
id: "typescript",
name: "TypeScript Language Server",
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
rootPatterns: ["package.json", "tsconfig.json", "jsconfig.json"],
command: "typescript-language-server",
args: ["--stdio"],
},
deno: {
id: "deno",
name: "Deno Language Server",
extensions: [".ts", ".tsx", ".js", ".jsx"],
rootPatterns: ["deno.json", "deno.jsonc"],
command: "deno",
args: ["lsp"],
},
python: {
id: "python",
name: "Pyright",
extensions: [".py", ".pyi"],
rootPatterns: ["pyproject.toml", "setup.py", "requirements.txt", "pyrightconfig.json"],
command: "pyright-langserver",
args: ["--stdio"],
},
gopls: {
id: "gopls",
name: "Go Language Server",
extensions: [".go"],
rootPatterns: ["go.mod", "go.work"],
command: "gopls",
args: ["serve"],
},
rust: {
id: "rust-analyzer",
name: "Rust Analyzer",
extensions: [".rs"],
rootPatterns: ["Cargo.toml"],
command: "rust-analyzer",
},
clangd: {
id: "clangd",
name: "Clangd",
extensions: [".c", ".cpp", ".h", ".hpp", ".cc", ".cxx"],
rootPatterns: ["compile_commands.json", "CMakeLists.txt", ".clangd"],
command: "clangd",
},
lua: {
id: "lua-language-server",
name: "Lua Language Server",
extensions: [".lua"],
rootPatterns: [".luarc.json", ".luarc.jsonc"],
command: "lua-language-server",
},
bash: {
id: "bash-language-server",
name: "Bash Language Server",
extensions: [".sh", ".bash", ".zsh"],
rootPatterns: [".bashrc", ".zshrc"],
command: "bash-language-server",
args: ["start"],
},
yaml: {
id: "yaml-language-server",
name: "YAML Language Server",
extensions: [".yaml", ".yml"],
rootPatterns: [".yamllint", ".yaml-lint.yml"],
command: "yaml-language-server",
args: ["--stdio"],
},
json: {
id: "vscode-json-language-server",
name: "JSON Language Server",
extensions: [".json", ".jsonc"],
rootPatterns: ["package.json", "tsconfig.json"],
command: "vscode-json-language-server",
args: ["--stdio"],
},
html: {
id: "vscode-html-language-server",
name: "HTML Language Server",
extensions: [".html", ".htm"],
rootPatterns: ["package.json", "index.html"],
command: "vscode-html-language-server",
args: ["--stdio"],
},
css: {
id: "vscode-css-language-server",
name: "CSS Language Server",
extensions: [".css", ".scss", ".less"],
rootPatterns: ["package.json"],
command: "vscode-css-language-server",
args: ["--stdio"],
},
eslint: {
id: "eslint",
name: "ESLint Language Server",
extensions: [".ts", ".tsx", ".js", ".jsx"],
rootPatterns: [".eslintrc", ".eslintrc.js", ".eslintrc.json", "eslint.config.js"],
command: "vscode-eslint-language-server",
args: ["--stdio"],
},
svelte: {
id: "svelte-language-server",
name: "Svelte Language Server",
extensions: [".svelte"],
rootPatterns: ["svelte.config.js", "svelte.config.ts"],
command: "svelteserver",
args: ["--stdio"],
},
vue: {
id: "vue-language-server",
name: "Vue Language Server",
extensions: [".vue"],
rootPatterns: ["vue.config.js", "vite.config.ts", "nuxt.config.ts"],
command: "vue-language-server",
args: ["--stdio"],
},
prisma: {
id: "prisma-language-server",
name: "Prisma Language Server",
extensions: [".prisma"],
rootPatterns: ["schema.prisma"],
command: "prisma-language-server",
args: ["--stdio"],
},
terraform: {
id: "terraform-ls",
name: "Terraform Language Server",
extensions: [".tf", ".tfvars"],
rootPatterns: [".terraform", "main.tf"],
command: "terraform-ls",
args: ["serve"],
},
docker: {
id: "docker-langserver",
name: "Dockerfile Language Server",
extensions: [".dockerfile"],
rootPatterns: ["Dockerfile", "docker-compose.yml"],
command: "docker-langserver",
args: ["--stdio"],
},
};
export const getServersForFile = (filePath: string): ServerInfo[] => {
const ext = "." + (filePath.split(".").pop() ?? "");
const fileName = path.basename(filePath);
return Object.values(SERVERS).filter((server) => {
return (
server.extensions.includes(ext) ||
server.extensions.includes(fileName)
);
});
};
export const findRootForServer = async (
filePath: string,
server: ServerInfo,
): Promise<string | null> => {
const dir = path.dirname(filePath);
return findProjectRoot(dir, server.rootPatterns);
};
export const spawnServer = async (
server: ServerInfo,
root: string,
): Promise<ServerHandle | null> => {
const binary = await findBinary(server.command);
if (!binary) {
return null;
}
const proc = spawn(binary, server.args ?? [], {
cwd: root,
env: { ...process.env, ...server.env },
stdio: ["pipe", "pipe", "pipe"],
});
if (!proc.pid) {
return null;
}
return { process: proc };
};
export const isServerAvailable = async (server: ServerInfo): Promise<boolean> => {
const binary = await findBinary(server.command);
return binary !== null;
};
export const getAvailableServers = async (): Promise<ServerInfo[]> => {
const available: ServerInfo[] = [];
for (const server of Object.values(SERVERS)) {
if (await isServerAvailable(server)) {
available.push(server);
}
}
return available;
};