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:
431
src/services/lsp/client.ts
Normal file
431
src/services/lsp/client.ts
Normal 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
357
src/services/lsp/index.ts
Normal 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";
|
||||
182
src/services/lsp/language.ts
Normal file
182
src/services/lsp/language.ts
Normal 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
267
src/services/lsp/server.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user