init claude-code
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
import { type ChildProcess, spawn } from 'child_process'
|
||||
import {
|
||||
createMessageConnection,
|
||||
type MessageConnection,
|
||||
StreamMessageReader,
|
||||
StreamMessageWriter,
|
||||
Trace,
|
||||
} from 'vscode-jsonrpc/node.js'
|
||||
import type {
|
||||
InitializeParams,
|
||||
InitializeResult,
|
||||
ServerCapabilities,
|
||||
} from 'vscode-languageserver-protocol'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { subprocessEnv } from '../../utils/subprocessEnv.js'
|
||||
/**
|
||||
* LSP client interface.
|
||||
*/
|
||||
export type LSPClient = {
|
||||
readonly capabilities: ServerCapabilities | undefined
|
||||
readonly isInitialized: boolean
|
||||
start: (
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: {
|
||||
env?: Record<string, string>
|
||||
cwd?: string
|
||||
},
|
||||
) => Promise<void>
|
||||
initialize: (params: InitializeParams) => Promise<InitializeResult>
|
||||
sendRequest: <TResult>(method: string, params: unknown) => Promise<TResult>
|
||||
sendNotification: (method: string, params: unknown) => Promise<void>
|
||||
onNotification: (method: string, handler: (params: unknown) => void) => void
|
||||
onRequest: <TParams, TResult>(
|
||||
method: string,
|
||||
handler: (params: TParams) => TResult | Promise<TResult>,
|
||||
) => void
|
||||
stop: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an LSP client wrapper using vscode-jsonrpc.
|
||||
* Manages communication with an LSP server process via stdio.
|
||||
*
|
||||
* @param onCrash - Called when the server process exits unexpectedly (non-zero
|
||||
* exit code during operation, not during intentional stop). Allows the owner
|
||||
* to propagate crash state so the server can be restarted on next use.
|
||||
*/
|
||||
export function createLSPClient(
|
||||
serverName: string,
|
||||
onCrash?: (error: Error) => void,
|
||||
): LSPClient {
|
||||
// State variables in closure
|
||||
let process: ChildProcess | undefined
|
||||
let connection: MessageConnection | undefined
|
||||
let capabilities: ServerCapabilities | undefined
|
||||
let isInitialized = false
|
||||
let startFailed = false
|
||||
let startError: Error | undefined
|
||||
let isStopping = false // Track intentional shutdown to avoid spurious error logging
|
||||
// Queue handlers registered before connection ready (lazy initialization support)
|
||||
const pendingHandlers: Array<{
|
||||
method: string
|
||||
handler: (params: unknown) => void
|
||||
}> = []
|
||||
const pendingRequestHandlers: Array<{
|
||||
method: string
|
||||
handler: (params: unknown) => unknown | Promise<unknown>
|
||||
}> = []
|
||||
|
||||
function checkStartFailed(): void {
|
||||
if (startFailed) {
|
||||
throw startError || new Error(`LSP server ${serverName} failed to start`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get capabilities(): ServerCapabilities | undefined {
|
||||
return capabilities
|
||||
},
|
||||
|
||||
get isInitialized(): boolean {
|
||||
return isInitialized
|
||||
},
|
||||
|
||||
async start(
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: {
|
||||
env?: Record<string, string>
|
||||
cwd?: string
|
||||
},
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 1. Spawn LSP server process
|
||||
process = spawn(command, args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...subprocessEnv(), ...options?.env },
|
||||
cwd: options?.cwd,
|
||||
// Prevent visible console window on Windows (no-op on other platforms)
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
if (!process.stdout || !process.stdin) {
|
||||
throw new Error('LSP server process stdio not available')
|
||||
}
|
||||
|
||||
// 1.5. Wait for process to successfully spawn before using streams
|
||||
// This is CRITICAL: spawn() returns immediately, but the 'error' event
|
||||
// (e.g., ENOENT for command not found) fires asynchronously.
|
||||
// If we use the streams before confirming spawn succeeded, we get
|
||||
// unhandled promise rejections when writes fail on invalid streams.
|
||||
const spawnedProcess = process // Capture for closure
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onSpawn = (): void => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const onError = (error: Error): void => {
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
const cleanup = (): void => {
|
||||
spawnedProcess.removeListener('spawn', onSpawn)
|
||||
spawnedProcess.removeListener('error', onError)
|
||||
}
|
||||
spawnedProcess.once('spawn', onSpawn)
|
||||
spawnedProcess.once('error', onError)
|
||||
})
|
||||
|
||||
// Capture stderr for server diagnostics and errors
|
||||
if (process.stderr) {
|
||||
process.stderr.on('data', (data: Buffer) => {
|
||||
const output = data.toString().trim()
|
||||
if (output) {
|
||||
logForDebugging(`[LSP SERVER ${serverName}] ${output}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Handle process errors (after successful spawn, e.g., crash during operation)
|
||||
process.on('error', error => {
|
||||
if (!isStopping) {
|
||||
startFailed = true
|
||||
startError = error
|
||||
logError(
|
||||
new Error(
|
||||
`LSP server ${serverName} failed to start: ${error.message}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
process.on('exit', (code, _signal) => {
|
||||
if (code !== 0 && code !== null && !isStopping) {
|
||||
isInitialized = false
|
||||
startFailed = false
|
||||
startError = undefined
|
||||
const crashError = new Error(
|
||||
`LSP server ${serverName} crashed with exit code ${code}`,
|
||||
)
|
||||
logError(crashError)
|
||||
onCrash?.(crashError)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle stdin stream errors to prevent unhandled promise rejections
|
||||
// when the LSP server process exits before we finish writing
|
||||
process.stdin.on('error', (error: Error) => {
|
||||
if (!isStopping) {
|
||||
logForDebugging(
|
||||
`LSP server ${serverName} stdin error: ${error.message}`,
|
||||
)
|
||||
}
|
||||
// Error is logged but not thrown - the connection error handler will catch this
|
||||
})
|
||||
|
||||
// 2. Create JSON-RPC connection
|
||||
const reader = new StreamMessageReader(process.stdout)
|
||||
const writer = new StreamMessageWriter(process.stdin)
|
||||
connection = createMessageConnection(reader, writer)
|
||||
|
||||
// 2.5. Register error/close handlers BEFORE listen() to catch all errors
|
||||
// This prevents unhandled promise rejections when the server crashes or closes unexpectedly
|
||||
connection.onError(([error, _message, _code]) => {
|
||||
// Only log if not intentionally stopping (avoid spurious errors during shutdown)
|
||||
if (!isStopping) {
|
||||
startFailed = true
|
||||
startError = error
|
||||
logError(
|
||||
new Error(
|
||||
`LSP server ${serverName} connection error: ${error.message}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
connection.onClose(() => {
|
||||
// Only treat as error if not intentionally stopping
|
||||
if (!isStopping) {
|
||||
isInitialized = false
|
||||
// Don't set startFailed here - the connection may close after graceful shutdown
|
||||
logForDebugging(`LSP server ${serverName} connection closed`)
|
||||
}
|
||||
})
|
||||
|
||||
// 3. Start listening for messages
|
||||
connection.listen()
|
||||
|
||||
// 3.5. Enable protocol tracing for debugging
|
||||
// Note: trace() sends a $/setTrace notification which can fail if the server
|
||||
// process has already exited. We catch and log the error rather than letting
|
||||
// it become an unhandled promise rejection.
|
||||
connection
|
||||
.trace(Trace.Verbose, {
|
||||
log: (message: string) => {
|
||||
logForDebugging(`[LSP PROTOCOL ${serverName}] ${message}`)
|
||||
},
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
logForDebugging(
|
||||
`Failed to enable tracing for ${serverName}: ${error.message}`,
|
||||
)
|
||||
})
|
||||
|
||||
// 4. Apply any queued notification handlers
|
||||
for (const { method, handler } of pendingHandlers) {
|
||||
connection.onNotification(method, handler)
|
||||
logForDebugging(
|
||||
`Applied queued notification handler for ${serverName}.${method}`,
|
||||
)
|
||||
}
|
||||
pendingHandlers.length = 0 // Clear the queue
|
||||
|
||||
// 5. Apply any queued request handlers
|
||||
for (const { method, handler } of pendingRequestHandlers) {
|
||||
connection.onRequest(method, handler)
|
||||
logForDebugging(
|
||||
`Applied queued request handler for ${serverName}.${method}`,
|
||||
)
|
||||
}
|
||||
pendingRequestHandlers.length = 0 // Clear the queue
|
||||
|
||||
logForDebugging(`LSP client started for ${serverName}`)
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
logError(
|
||||
new Error(`LSP server ${serverName} failed to start: ${err.message}`),
|
||||
)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
async initialize(params: InitializeParams): Promise<InitializeResult> {
|
||||
if (!connection) {
|
||||
throw new Error('LSP client not started')
|
||||
}
|
||||
|
||||
checkStartFailed()
|
||||
|
||||
try {
|
||||
const result: InitializeResult = await connection.sendRequest(
|
||||
'initialize',
|
||||
params,
|
||||
)
|
||||
|
||||
capabilities = result.capabilities
|
||||
|
||||
// Send initialized notification
|
||||
await connection.sendNotification('initialized', {})
|
||||
|
||||
isInitialized = true
|
||||
logForDebugging(`LSP server ${serverName} initialized`)
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
logError(
|
||||
new Error(
|
||||
`LSP server ${serverName} initialize failed: ${err.message}`,
|
||||
),
|
||||
)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
async sendRequest<TResult>(
|
||||
method: string,
|
||||
params: unknown,
|
||||
): Promise<TResult> {
|
||||
if (!connection) {
|
||||
throw new Error('LSP client not started')
|
||||
}
|
||||
|
||||
checkStartFailed()
|
||||
|
||||
if (!isInitialized) {
|
||||
throw new Error('LSP server not initialized')
|
||||
}
|
||||
|
||||
try {
|
||||
return await connection.sendRequest(method, params)
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
logError(
|
||||
new Error(
|
||||
`LSP server ${serverName} request ${method} failed: ${err.message}`,
|
||||
),
|
||||
)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
async sendNotification(method: string, params: unknown): Promise<void> {
|
||||
if (!connection) {
|
||||
throw new Error('LSP client not started')
|
||||
}
|
||||
|
||||
checkStartFailed()
|
||||
|
||||
try {
|
||||
await connection.sendNotification(method, params)
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
logError(
|
||||
new Error(
|
||||
`LSP server ${serverName} notification ${method} failed: ${err.message}`,
|
||||
),
|
||||
)
|
||||
// Don't re-throw for notifications - they're fire-and-forget
|
||||
logForDebugging(`Notification ${method} failed but continuing`)
|
||||
}
|
||||
},
|
||||
|
||||
onNotification(method: string, handler: (params: unknown) => void): void {
|
||||
if (!connection) {
|
||||
// Queue handler for application when connection is ready (lazy initialization)
|
||||
pendingHandlers.push({ method, handler })
|
||||
logForDebugging(
|
||||
`Queued notification handler for ${serverName}.${method} (connection not ready)`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
checkStartFailed()
|
||||
|
||||
connection.onNotification(method, handler)
|
||||
},
|
||||
|
||||
onRequest<TParams, TResult>(
|
||||
method: string,
|
||||
handler: (params: TParams) => TResult | Promise<TResult>,
|
||||
): void {
|
||||
if (!connection) {
|
||||
// Queue handler for application when connection is ready (lazy initialization)
|
||||
pendingRequestHandlers.push({
|
||||
method,
|
||||
handler: handler as (params: unknown) => unknown | Promise<unknown>,
|
||||
})
|
||||
logForDebugging(
|
||||
`Queued request handler for ${serverName}.${method} (connection not ready)`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
checkStartFailed()
|
||||
|
||||
connection.onRequest(method, handler)
|
||||
},
|
||||
|
||||
async stop(): Promise<void> {
|
||||
let shutdownError: Error | undefined
|
||||
|
||||
// Mark as stopping to prevent error handlers from logging spurious errors
|
||||
isStopping = true
|
||||
|
||||
try {
|
||||
if (connection) {
|
||||
// Try to send shutdown request and exit notification
|
||||
await connection.sendRequest('shutdown', {})
|
||||
await connection.sendNotification('exit', {})
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
logError(
|
||||
new Error(`LSP server ${serverName} stop failed: ${err.message}`),
|
||||
)
|
||||
shutdownError = err
|
||||
// Continue to cleanup despite shutdown failure
|
||||
} finally {
|
||||
// Always cleanup resources, even if shutdown/exit failed
|
||||
if (connection) {
|
||||
try {
|
||||
connection.dispose()
|
||||
} catch (error) {
|
||||
// Log but don't throw - disposal errors are less critical
|
||||
logForDebugging(
|
||||
`Connection disposal failed for ${serverName}: ${errorMessage(error)}`,
|
||||
)
|
||||
}
|
||||
connection = undefined
|
||||
}
|
||||
|
||||
if (process) {
|
||||
// Remove event listeners to prevent memory leaks
|
||||
process.removeAllListeners('error')
|
||||
process.removeAllListeners('exit')
|
||||
if (process.stdin) {
|
||||
process.stdin.removeAllListeners('error')
|
||||
}
|
||||
if (process.stderr) {
|
||||
process.stderr.removeAllListeners('data')
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill()
|
||||
} catch (error) {
|
||||
// Process might already be dead, which is fine
|
||||
logForDebugging(
|
||||
`Process kill failed for ${serverName} (may already be dead): ${errorMessage(error)}`,
|
||||
)
|
||||
}
|
||||
process = undefined
|
||||
}
|
||||
|
||||
isInitialized = false
|
||||
capabilities = undefined
|
||||
isStopping = false // Reset for potential restart
|
||||
// Don't reset startFailed - preserve error state for diagnostics
|
||||
// startFailed and startError remain as-is
|
||||
if (shutdownError) {
|
||||
startFailed = true
|
||||
startError = shutdownError
|
||||
}
|
||||
|
||||
logForDebugging(`LSP client stopped for ${serverName}`)
|
||||
}
|
||||
|
||||
// Re-throw shutdown error after cleanup is complete
|
||||
if (shutdownError) {
|
||||
throw shutdownError
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { toError } from '../../utils/errors.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { jsonStringify } from '../../utils/slowOperations.js'
|
||||
import type { DiagnosticFile } from '../diagnosticTracking.js'
|
||||
|
||||
/**
|
||||
* Pending LSP diagnostic notification
|
||||
*/
|
||||
export type PendingLSPDiagnostic = {
|
||||
/** Server that sent the diagnostic */
|
||||
serverName: string
|
||||
/** Diagnostic files */
|
||||
files: DiagnosticFile[]
|
||||
/** When diagnostic was received */
|
||||
timestamp: number
|
||||
/** Whether attachment was already sent to conversation */
|
||||
attachmentSent: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* LSP Diagnostic Registry
|
||||
*
|
||||
* Stores LSP diagnostics received asynchronously from LSP servers via
|
||||
* textDocument/publishDiagnostics notifications. Follows the same pattern
|
||||
* as AsyncHookRegistry for consistent async attachment delivery.
|
||||
*
|
||||
* Pattern:
|
||||
* 1. LSP server sends publishDiagnostics notification
|
||||
* 2. registerPendingLSPDiagnostic() stores diagnostic
|
||||
* 3. checkForLSPDiagnostics() retrieves pending diagnostics
|
||||
* 4. getLSPDiagnosticAttachments() converts to Attachment[]
|
||||
* 5. getAttachments() delivers to conversation automatically
|
||||
*
|
||||
* Similar to AsyncHookRegistry but simpler since diagnostics arrive
|
||||
* synchronously (no need to accumulate output over time).
|
||||
*/
|
||||
|
||||
// Volume limiting constants
|
||||
const MAX_DIAGNOSTICS_PER_FILE = 10
|
||||
const MAX_TOTAL_DIAGNOSTICS = 30
|
||||
|
||||
// Max files to track for deduplication - prevents unbounded memory growth
|
||||
const MAX_DELIVERED_FILES = 500
|
||||
|
||||
// Global registry state
|
||||
const pendingDiagnostics = new Map<string, PendingLSPDiagnostic>()
|
||||
|
||||
// Cross-turn deduplication: tracks diagnostics that have been delivered
|
||||
// Maps file URI to a set of diagnostic keys (hash of message+severity+range)
|
||||
// Using LRUCache to prevent unbounded growth in long sessions
|
||||
const deliveredDiagnostics = new LRUCache<string, Set<string>>({
|
||||
max: MAX_DELIVERED_FILES,
|
||||
})
|
||||
|
||||
/**
|
||||
* Register LSP diagnostics received from a server.
|
||||
* These will be delivered as attachments in the next query.
|
||||
*
|
||||
* @param serverName - Name of LSP server that sent diagnostics
|
||||
* @param files - Diagnostic files to deliver
|
||||
*/
|
||||
export function registerPendingLSPDiagnostic({
|
||||
serverName,
|
||||
files,
|
||||
}: {
|
||||
serverName: string
|
||||
files: DiagnosticFile[]
|
||||
}): void {
|
||||
// Use UUID for guaranteed uniqueness (handles rapid registrations)
|
||||
const diagnosticId = randomUUID()
|
||||
|
||||
logForDebugging(
|
||||
`LSP Diagnostics: Registering ${files.length} diagnostic file(s) from ${serverName} (ID: ${diagnosticId})`,
|
||||
)
|
||||
|
||||
pendingDiagnostics.set(diagnosticId, {
|
||||
serverName,
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
attachmentSent: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps severity string to numeric value for sorting.
|
||||
* Error=1, Warning=2, Info=3, Hint=4
|
||||
*/
|
||||
function severityToNumber(severity: string | undefined): number {
|
||||
switch (severity) {
|
||||
case 'Error':
|
||||
return 1
|
||||
case 'Warning':
|
||||
return 2
|
||||
case 'Info':
|
||||
return 3
|
||||
case 'Hint':
|
||||
return 4
|
||||
default:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a unique key for a diagnostic based on its content.
|
||||
* Used for both within-batch and cross-turn deduplication.
|
||||
*/
|
||||
function createDiagnosticKey(diag: {
|
||||
message: string
|
||||
severity?: string
|
||||
range?: unknown
|
||||
source?: string
|
||||
code?: unknown
|
||||
}): string {
|
||||
return jsonStringify({
|
||||
message: diag.message,
|
||||
severity: diag.severity,
|
||||
range: diag.range,
|
||||
source: diag.source || null,
|
||||
code: diag.code || null,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates diagnostics by file URI and diagnostic content.
|
||||
* Also filters out diagnostics that were already delivered in previous turns.
|
||||
* Two diagnostics are considered duplicates if they have the same:
|
||||
* - File URI
|
||||
* - Range (start/end line and character)
|
||||
* - Message
|
||||
* - Severity
|
||||
* - Source and code (if present)
|
||||
*/
|
||||
function deduplicateDiagnosticFiles(
|
||||
allFiles: DiagnosticFile[],
|
||||
): DiagnosticFile[] {
|
||||
// Group diagnostics by file URI
|
||||
const fileMap = new Map<string, Set<string>>()
|
||||
const dedupedFiles: DiagnosticFile[] = []
|
||||
|
||||
for (const file of allFiles) {
|
||||
if (!fileMap.has(file.uri)) {
|
||||
fileMap.set(file.uri, new Set())
|
||||
dedupedFiles.push({ uri: file.uri, diagnostics: [] })
|
||||
}
|
||||
|
||||
const seenDiagnostics = fileMap.get(file.uri)!
|
||||
const dedupedFile = dedupedFiles.find(f => f.uri === file.uri)!
|
||||
|
||||
// Get previously delivered diagnostics for this file (for cross-turn dedup)
|
||||
const previouslyDelivered = deliveredDiagnostics.get(file.uri) || new Set()
|
||||
|
||||
for (const diag of file.diagnostics) {
|
||||
try {
|
||||
const key = createDiagnosticKey(diag)
|
||||
|
||||
// Skip if already seen in this batch OR already delivered in previous turns
|
||||
if (seenDiagnostics.has(key) || previouslyDelivered.has(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seenDiagnostics.add(key)
|
||||
dedupedFile.diagnostics.push(diag)
|
||||
} catch (error: unknown) {
|
||||
const err = toError(error)
|
||||
const truncatedMessage =
|
||||
diag.message?.substring(0, 100) || '<no message>'
|
||||
logError(
|
||||
new Error(
|
||||
`Failed to deduplicate diagnostic in ${file.uri}: ${err.message}. ` +
|
||||
`Diagnostic message: ${truncatedMessage}`,
|
||||
),
|
||||
)
|
||||
// Include the diagnostic anyway to avoid losing information
|
||||
dedupedFile.diagnostics.push(diag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out files with no diagnostics after deduplication
|
||||
return dedupedFiles.filter(f => f.diagnostics.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending LSP diagnostics that haven't been delivered yet.
|
||||
* Deduplicates diagnostics to prevent sending the same diagnostic multiple times.
|
||||
* Marks diagnostics as sent to prevent duplicate delivery.
|
||||
*
|
||||
* @returns Array of pending diagnostics ready for delivery (deduplicated)
|
||||
*/
|
||||
export function checkForLSPDiagnostics(): Array<{
|
||||
serverName: string
|
||||
files: DiagnosticFile[]
|
||||
}> {
|
||||
logForDebugging(
|
||||
`LSP Diagnostics: Checking registry - ${pendingDiagnostics.size} pending`,
|
||||
)
|
||||
|
||||
// Collect all diagnostic files from all pending notifications
|
||||
const allFiles: DiagnosticFile[] = []
|
||||
const serverNames = new Set<string>()
|
||||
const diagnosticsToMark: PendingLSPDiagnostic[] = []
|
||||
|
||||
for (const diagnostic of pendingDiagnostics.values()) {
|
||||
if (!diagnostic.attachmentSent) {
|
||||
allFiles.push(...diagnostic.files)
|
||||
serverNames.add(diagnostic.serverName)
|
||||
diagnosticsToMark.push(diagnostic)
|
||||
}
|
||||
}
|
||||
|
||||
if (allFiles.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Deduplicate diagnostics across all files
|
||||
let dedupedFiles: DiagnosticFile[]
|
||||
try {
|
||||
dedupedFiles = deduplicateDiagnosticFiles(allFiles)
|
||||
} catch (error: unknown) {
|
||||
const err = toError(error)
|
||||
logError(new Error(`Failed to deduplicate LSP diagnostics: ${err.message}`))
|
||||
// Fall back to undedup'd files to avoid losing diagnostics
|
||||
dedupedFiles = allFiles
|
||||
}
|
||||
|
||||
// Only mark as sent AFTER successful deduplication, then delete from map.
|
||||
// Entries are tracked in deliveredDiagnostics LRU for dedup, so we don't
|
||||
// need to keep them in pendingDiagnostics after delivery.
|
||||
for (const diagnostic of diagnosticsToMark) {
|
||||
diagnostic.attachmentSent = true
|
||||
}
|
||||
for (const [id, diagnostic] of pendingDiagnostics) {
|
||||
if (diagnostic.attachmentSent) {
|
||||
pendingDiagnostics.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
const originalCount = allFiles.reduce(
|
||||
(sum, f) => sum + f.diagnostics.length,
|
||||
0,
|
||||
)
|
||||
const dedupedCount = dedupedFiles.reduce(
|
||||
(sum, f) => sum + f.diagnostics.length,
|
||||
0,
|
||||
)
|
||||
|
||||
if (originalCount > dedupedCount) {
|
||||
logForDebugging(
|
||||
`LSP Diagnostics: Deduplication removed ${originalCount - dedupedCount} duplicate diagnostic(s)`,
|
||||
)
|
||||
}
|
||||
|
||||
// Apply volume limiting: cap per file and total
|
||||
let totalDiagnostics = 0
|
||||
let truncatedCount = 0
|
||||
for (const file of dedupedFiles) {
|
||||
// Sort by severity (Error=1 < Warning=2 < Info=3 < Hint=4) to prioritize errors
|
||||
file.diagnostics.sort(
|
||||
(a, b) => severityToNumber(a.severity) - severityToNumber(b.severity),
|
||||
)
|
||||
|
||||
// Cap per file
|
||||
if (file.diagnostics.length > MAX_DIAGNOSTICS_PER_FILE) {
|
||||
truncatedCount += file.diagnostics.length - MAX_DIAGNOSTICS_PER_FILE
|
||||
file.diagnostics = file.diagnostics.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||
}
|
||||
|
||||
// Cap total
|
||||
const remainingCapacity = MAX_TOTAL_DIAGNOSTICS - totalDiagnostics
|
||||
if (file.diagnostics.length > remainingCapacity) {
|
||||
truncatedCount += file.diagnostics.length - remainingCapacity
|
||||
file.diagnostics = file.diagnostics.slice(0, remainingCapacity)
|
||||
}
|
||||
|
||||
totalDiagnostics += file.diagnostics.length
|
||||
}
|
||||
|
||||
// Filter out files that ended up with no diagnostics after limiting
|
||||
dedupedFiles = dedupedFiles.filter(f => f.diagnostics.length > 0)
|
||||
|
||||
if (truncatedCount > 0) {
|
||||
logForDebugging(
|
||||
`LSP Diagnostics: Volume limiting removed ${truncatedCount} diagnostic(s) (max ${MAX_DIAGNOSTICS_PER_FILE}/file, ${MAX_TOTAL_DIAGNOSTICS} total)`,
|
||||
)
|
||||
}
|
||||
|
||||
// Track delivered diagnostics for cross-turn deduplication
|
||||
for (const file of dedupedFiles) {
|
||||
if (!deliveredDiagnostics.has(file.uri)) {
|
||||
deliveredDiagnostics.set(file.uri, new Set())
|
||||
}
|
||||
const delivered = deliveredDiagnostics.get(file.uri)!
|
||||
for (const diag of file.diagnostics) {
|
||||
try {
|
||||
delivered.add(createDiagnosticKey(diag))
|
||||
} catch (error: unknown) {
|
||||
// Log but continue - failure to track shouldn't prevent delivery
|
||||
const err = toError(error)
|
||||
const truncatedMessage =
|
||||
diag.message?.substring(0, 100) || '<no message>'
|
||||
logError(
|
||||
new Error(
|
||||
`Failed to track delivered diagnostic in ${file.uri}: ${err.message}. ` +
|
||||
`Diagnostic message: ${truncatedMessage}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalCount = dedupedFiles.reduce(
|
||||
(sum, f) => sum + f.diagnostics.length,
|
||||
0,
|
||||
)
|
||||
|
||||
// Return empty if no diagnostics to deliver (all filtered by deduplication)
|
||||
if (finalCount === 0) {
|
||||
logForDebugging(
|
||||
`LSP Diagnostics: No new diagnostics to deliver (all filtered by deduplication)`,
|
||||
)
|
||||
return []
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`LSP Diagnostics: Delivering ${dedupedFiles.length} file(s) with ${finalCount} diagnostic(s) from ${serverNames.size} server(s)`,
|
||||
)
|
||||
|
||||
// Return single result with all deduplicated diagnostics
|
||||
return [
|
||||
{
|
||||
serverName: Array.from(serverNames).join(', '),
|
||||
files: dedupedFiles,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending diagnostics.
|
||||
* Used during cleanup/shutdown or for testing.
|
||||
* Note: Does NOT clear deliveredDiagnostics - that's for cross-turn deduplication
|
||||
* and should only be cleared when files are edited or on session reset.
|
||||
*/
|
||||
export function clearAllLSPDiagnostics(): void {
|
||||
logForDebugging(
|
||||
`LSP Diagnostics: Clearing ${pendingDiagnostics.size} pending diagnostic(s)`,
|
||||
)
|
||||
pendingDiagnostics.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all diagnostic state including cross-turn tracking.
|
||||
* Used on session reset or for testing.
|
||||
*/
|
||||
export function resetAllLSPDiagnosticState(): void {
|
||||
logForDebugging(
|
||||
`LSP Diagnostics: Resetting all state (${pendingDiagnostics.size} pending, ${deliveredDiagnostics.size} files tracked)`,
|
||||
)
|
||||
pendingDiagnostics.clear()
|
||||
deliveredDiagnostics.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear delivered diagnostics for a specific file.
|
||||
* Should be called when a file is edited so that new diagnostics for that file
|
||||
* will be shown even if they match previously delivered ones.
|
||||
*
|
||||
* @param fileUri - URI of the file that was edited
|
||||
*/
|
||||
export function clearDeliveredDiagnosticsForFile(fileUri: string): void {
|
||||
if (deliveredDiagnostics.has(fileUri)) {
|
||||
logForDebugging(
|
||||
`LSP Diagnostics: Clearing delivered diagnostics for ${fileUri}`,
|
||||
)
|
||||
deliveredDiagnostics.delete(fileUri)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of pending diagnostics (for monitoring)
|
||||
*/
|
||||
export function getPendingLSPDiagnosticCount(): number {
|
||||
return pendingDiagnostics.size
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
import * as path from 'path'
|
||||
import { pathToFileURL } from 'url'
|
||||
import type { InitializeParams } from 'vscode-languageserver-protocol'
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { sleep } from '../../utils/sleep.js'
|
||||
import type { createLSPClient as createLSPClientType } from './LSPClient.js'
|
||||
import type { LspServerState, ScopedLspServerConfig } from './types.js'
|
||||
|
||||
/**
|
||||
* LSP error code for "content modified" - indicates the server's state changed
|
||||
* during request processing (e.g., rust-analyzer still indexing the project).
|
||||
* This is a transient error that can be retried.
|
||||
*/
|
||||
const LSP_ERROR_CONTENT_MODIFIED = -32801
|
||||
|
||||
/**
|
||||
* Maximum number of retries for transient LSP errors like "content modified".
|
||||
*/
|
||||
const MAX_RETRIES_FOR_TRANSIENT_ERRORS = 3
|
||||
|
||||
/**
|
||||
* Base delay in milliseconds for exponential backoff on transient errors.
|
||||
* Actual delays: 500ms, 1000ms, 2000ms
|
||||
*/
|
||||
const RETRY_BASE_DELAY_MS = 500
|
||||
/**
|
||||
* LSP server instance interface returned by createLSPServerInstance.
|
||||
* Manages the lifecycle of a single LSP server with state tracking and health monitoring.
|
||||
*/
|
||||
export type LSPServerInstance = {
|
||||
/** Unique server identifier */
|
||||
readonly name: string
|
||||
/** Server configuration */
|
||||
readonly config: ScopedLspServerConfig
|
||||
/** Current server state */
|
||||
readonly state: LspServerState
|
||||
/** When the server was last started */
|
||||
readonly startTime: Date | undefined
|
||||
/** Last error encountered */
|
||||
readonly lastError: Error | undefined
|
||||
/** Number of times restart() has been called */
|
||||
readonly restartCount: number
|
||||
/** Start the server and initialize it */
|
||||
start(): Promise<void>
|
||||
/** Stop the server gracefully */
|
||||
stop(): Promise<void>
|
||||
/** Manually restart the server (stop then start) */
|
||||
restart(): Promise<void>
|
||||
/** Check if server is healthy and ready for requests */
|
||||
isHealthy(): boolean
|
||||
/** Send an LSP request to the server */
|
||||
sendRequest<T>(method: string, params: unknown): Promise<T>
|
||||
/** Send an LSP notification to the server (fire-and-forget) */
|
||||
sendNotification(method: string, params: unknown): Promise<void>
|
||||
/** Register a handler for LSP notifications */
|
||||
onNotification(method: string, handler: (params: unknown) => void): void
|
||||
/** Register a handler for LSP requests from the server */
|
||||
onRequest<TParams, TResult>(
|
||||
method: string,
|
||||
handler: (params: TParams) => TResult | Promise<TResult>,
|
||||
): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and manages a single LSP server instance.
|
||||
*
|
||||
* Uses factory function pattern with closures for state encapsulation (avoiding classes).
|
||||
* Provides state tracking, health monitoring, and request forwarding for an LSP server.
|
||||
* Supports manual restart with configurable retry limits.
|
||||
*
|
||||
* State machine transitions:
|
||||
* - stopped → starting → running
|
||||
* - running → stopping → stopped
|
||||
* - any → error (on failure)
|
||||
* - error → starting (on retry)
|
||||
*
|
||||
* @param name - Unique identifier for this server instance
|
||||
* @param config - Server configuration including command, args, and limits
|
||||
* @returns LSP server instance with lifecycle management methods
|
||||
*
|
||||
* @example
|
||||
* const instance = createLSPServerInstance('my-server', config)
|
||||
* await instance.start()
|
||||
* const result = await instance.sendRequest('textDocument/definition', params)
|
||||
* await instance.stop()
|
||||
*/
|
||||
export function createLSPServerInstance(
|
||||
name: string,
|
||||
config: ScopedLspServerConfig,
|
||||
): LSPServerInstance {
|
||||
// Validate that unimplemented fields are not set
|
||||
if (config.restartOnCrash !== undefined) {
|
||||
throw new Error(
|
||||
`LSP server '${name}': restartOnCrash is not yet implemented. Remove this field from the configuration.`,
|
||||
)
|
||||
}
|
||||
if (config.shutdownTimeout !== undefined) {
|
||||
throw new Error(
|
||||
`LSP server '${name}': shutdownTimeout is not yet implemented. Remove this field from the configuration.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Private state encapsulated via closures. Lazy-require LSPClient so
|
||||
// vscode-jsonrpc (~129KB) only loads when an LSP server is actually
|
||||
// instantiated, not when the static import chain reaches this module.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { createLSPClient } = require('./LSPClient.js') as {
|
||||
createLSPClient: typeof createLSPClientType
|
||||
}
|
||||
let state: LspServerState = 'stopped'
|
||||
let startTime: Date | undefined
|
||||
let lastError: Error | undefined
|
||||
let restartCount = 0
|
||||
let crashRecoveryCount = 0
|
||||
// Propagate crash state so ensureServerStarted can restart on next use.
|
||||
// Without this, state stays 'running' after crash and the server is never
|
||||
// restarted (zombie state).
|
||||
const client = createLSPClient(name, error => {
|
||||
state = 'error'
|
||||
lastError = error
|
||||
crashRecoveryCount++
|
||||
})
|
||||
|
||||
/**
|
||||
* Starts the LSP server and initializes it with workspace information.
|
||||
*
|
||||
* If the server is already running or starting, this method returns immediately.
|
||||
* On failure, sets state to 'error', logs for monitoring, and throws.
|
||||
*
|
||||
* @throws {Error} If server fails to start or initialize
|
||||
*/
|
||||
async function start(): Promise<void> {
|
||||
if (state === 'running' || state === 'starting') {
|
||||
return
|
||||
}
|
||||
|
||||
// Cap crash-recovery attempts so a persistently crashing server doesn't
|
||||
// spawn unbounded child processes on every incoming request.
|
||||
const maxRestarts = config.maxRestarts ?? 3
|
||||
if (state === 'error' && crashRecoveryCount > maxRestarts) {
|
||||
const error = new Error(
|
||||
`LSP server '${name}' exceeded max crash recovery attempts (${maxRestarts})`,
|
||||
)
|
||||
lastError = error
|
||||
logError(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
let initPromise: Promise<unknown> | undefined
|
||||
try {
|
||||
state = 'starting'
|
||||
logForDebugging(`Starting LSP server instance: ${name}`)
|
||||
|
||||
// Start the client
|
||||
await client.start(config.command, config.args || [], {
|
||||
env: config.env,
|
||||
cwd: config.workspaceFolder,
|
||||
})
|
||||
|
||||
// Initialize with workspace info
|
||||
const workspaceFolder = config.workspaceFolder || getCwd()
|
||||
const workspaceUri = pathToFileURL(workspaceFolder).href
|
||||
|
||||
const initParams: InitializeParams = {
|
||||
processId: process.pid,
|
||||
|
||||
// Pass server-specific initialization options from plugin config
|
||||
// Required by vue-language-server, optional for others
|
||||
// Provide empty object as default to avoid undefined errors in servers
|
||||
// that expect this field to exist
|
||||
initializationOptions: config.initializationOptions ?? {},
|
||||
|
||||
// Modern approach (LSP 3.16+) - required for Pyright, gopls
|
||||
workspaceFolders: [
|
||||
{
|
||||
uri: workspaceUri,
|
||||
name: path.basename(workspaceFolder),
|
||||
},
|
||||
],
|
||||
|
||||
// Deprecated fields - some servers still need these for proper URI resolution
|
||||
rootPath: workspaceFolder, // Deprecated in LSP 3.8 but needed by some servers
|
||||
rootUri: workspaceUri, // Deprecated in LSP 3.16 but needed by typescript-language-server for goToDefinition
|
||||
|
||||
// Client capabilities - declare what features we support
|
||||
capabilities: {
|
||||
workspace: {
|
||||
// Don't claim to support workspace/configuration since we don't implement it
|
||||
// This prevents servers from requesting config we can't provide
|
||||
configuration: false,
|
||||
// Don't claim to support workspace folders changes since we don't handle
|
||||
// workspace/didChangeWorkspaceFolders notifications
|
||||
workspaceFolders: false,
|
||||
},
|
||||
textDocument: {
|
||||
synchronization: {
|
||||
dynamicRegistration: false,
|
||||
willSave: false,
|
||||
willSaveWaitUntil: false,
|
||||
didSave: true,
|
||||
},
|
||||
publishDiagnostics: {
|
||||
relatedInformation: true,
|
||||
tagSupport: {
|
||||
valueSet: [1, 2], // Unnecessary (1), Deprecated (2)
|
||||
},
|
||||
versionSupport: false,
|
||||
codeDescriptionSupport: true,
|
||||
dataSupport: false,
|
||||
},
|
||||
hover: {
|
||||
dynamicRegistration: false,
|
||||
contentFormat: ['markdown', 'plaintext'],
|
||||
},
|
||||
definition: {
|
||||
dynamicRegistration: false,
|
||||
linkSupport: true,
|
||||
},
|
||||
references: {
|
||||
dynamicRegistration: false,
|
||||
},
|
||||
documentSymbol: {
|
||||
dynamicRegistration: false,
|
||||
hierarchicalDocumentSymbolSupport: true,
|
||||
},
|
||||
callHierarchy: {
|
||||
dynamicRegistration: false,
|
||||
},
|
||||
},
|
||||
general: {
|
||||
positionEncodings: ['utf-16'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
initPromise = client.initialize(initParams)
|
||||
if (config.startupTimeout !== undefined) {
|
||||
await withTimeout(
|
||||
initPromise,
|
||||
config.startupTimeout,
|
||||
`LSP server '${name}' timed out after ${config.startupTimeout}ms during initialization`,
|
||||
)
|
||||
} else {
|
||||
await initPromise
|
||||
}
|
||||
|
||||
state = 'running'
|
||||
startTime = new Date()
|
||||
crashRecoveryCount = 0
|
||||
logForDebugging(`LSP server instance started: ${name}`)
|
||||
} catch (error) {
|
||||
// Clean up the spawned child process on timeout/error
|
||||
client.stop().catch(() => {})
|
||||
// Prevent unhandled rejection from abandoned initialize promise
|
||||
initPromise?.catch(() => {})
|
||||
state = 'error'
|
||||
lastError = error as Error
|
||||
logError(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the LSP server gracefully.
|
||||
*
|
||||
* If already stopped or stopping, returns immediately.
|
||||
* On failure, sets state to 'error', logs for monitoring, and throws.
|
||||
*
|
||||
* @throws {Error} If server fails to stop
|
||||
*/
|
||||
async function stop(): Promise<void> {
|
||||
if (state === 'stopped' || state === 'stopping') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
state = 'stopping'
|
||||
await client.stop()
|
||||
state = 'stopped'
|
||||
logForDebugging(`LSP server instance stopped: ${name}`)
|
||||
} catch (error) {
|
||||
state = 'error'
|
||||
lastError = error as Error
|
||||
logError(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually restarts the server by stopping and starting it.
|
||||
*
|
||||
* Increments restartCount and enforces maxRestarts limit.
|
||||
* Note: This is NOT automatic - must be called explicitly.
|
||||
*
|
||||
* @throws {Error} If stop or start fails, or if restartCount exceeds config.maxRestarts (default: 3)
|
||||
*/
|
||||
async function restart(): Promise<void> {
|
||||
try {
|
||||
await stop()
|
||||
} catch (error) {
|
||||
const stopError = new Error(
|
||||
`Failed to stop LSP server '${name}' during restart: ${errorMessage(error)}`,
|
||||
)
|
||||
logError(stopError)
|
||||
throw stopError
|
||||
}
|
||||
|
||||
restartCount++
|
||||
|
||||
const maxRestarts = config.maxRestarts ?? 3
|
||||
if (restartCount > maxRestarts) {
|
||||
const error = new Error(
|
||||
`Max restart attempts (${maxRestarts}) exceeded for server '${name}'`,
|
||||
)
|
||||
logError(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
await start()
|
||||
} catch (error) {
|
||||
const startError = new Error(
|
||||
`Failed to start LSP server '${name}' during restart (attempt ${restartCount}/${maxRestarts}): ${errorMessage(error)}`,
|
||||
)
|
||||
logError(startError)
|
||||
throw startError
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the server is healthy and ready to handle requests.
|
||||
*
|
||||
* @returns true if state is 'running' AND the client has completed initialization
|
||||
*/
|
||||
function isHealthy(): boolean {
|
||||
return state === 'running' && client.isInitialized
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an LSP request to the server with retry logic for transient errors.
|
||||
*
|
||||
* Checks server health before sending and wraps errors with context.
|
||||
* Automatically retries on "content modified" errors (code -32801) which occur
|
||||
* when servers like rust-analyzer are still indexing. This is expected LSP behavior
|
||||
* and clients should retry silently per the LSP specification.
|
||||
*
|
||||
* @param method - LSP method name (e.g., 'textDocument/definition')
|
||||
* @param params - Method-specific parameters
|
||||
* @returns The server's response
|
||||
* @throws {Error} If server is not healthy or request fails after all retries
|
||||
*/
|
||||
async function sendRequest<T>(method: string, params: unknown): Promise<T> {
|
||||
if (!isHealthy()) {
|
||||
const error = new Error(
|
||||
`Cannot send request to LSP server '${name}': server is ${state}` +
|
||||
`${lastError ? `, last error: ${lastError.message}` : ''}`,
|
||||
)
|
||||
logError(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
let lastAttemptError: Error | undefined
|
||||
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt <= MAX_RETRIES_FOR_TRANSIENT_ERRORS;
|
||||
attempt++
|
||||
) {
|
||||
try {
|
||||
return await client.sendRequest(method, params)
|
||||
} catch (error) {
|
||||
lastAttemptError = error as Error
|
||||
|
||||
// Check if this is a transient "content modified" error that we should retry
|
||||
// This commonly happens with rust-analyzer during initial project indexing.
|
||||
// We use duck typing instead of instanceof because there may be multiple
|
||||
// versions of vscode-jsonrpc in the dependency tree (8.2.0 vs 8.2.1).
|
||||
const errorCode = (error as { code?: number }).code
|
||||
const isContentModifiedError =
|
||||
typeof errorCode === 'number' &&
|
||||
errorCode === LSP_ERROR_CONTENT_MODIFIED
|
||||
|
||||
if (
|
||||
isContentModifiedError &&
|
||||
attempt < MAX_RETRIES_FOR_TRANSIENT_ERRORS
|
||||
) {
|
||||
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt)
|
||||
logForDebugging(
|
||||
`LSP request '${method}' to '${name}' got ContentModified error, ` +
|
||||
`retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRIES_FOR_TRANSIENT_ERRORS})…`,
|
||||
)
|
||||
await sleep(delay)
|
||||
continue
|
||||
}
|
||||
|
||||
// Non-retryable error or max retries exceeded
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// All retries failed or non-retryable error
|
||||
const requestError = new Error(
|
||||
`LSP request '${method}' failed for server '${name}': ${lastAttemptError?.message ?? 'unknown error'}`,
|
||||
)
|
||||
logError(requestError)
|
||||
throw requestError
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to the LSP server (fire-and-forget).
|
||||
* Used for file synchronization (didOpen, didChange, didClose).
|
||||
*/
|
||||
async function sendNotification(
|
||||
method: string,
|
||||
params: unknown,
|
||||
): Promise<void> {
|
||||
if (!isHealthy()) {
|
||||
const error = new Error(
|
||||
`Cannot send notification to LSP server '${name}': server is ${state}`,
|
||||
)
|
||||
logError(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
await client.sendNotification(method, params)
|
||||
} catch (error) {
|
||||
const notificationError = new Error(
|
||||
`LSP notification '${method}' failed for server '${name}': ${errorMessage(error)}`,
|
||||
)
|
||||
logError(notificationError)
|
||||
throw notificationError
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a handler for LSP notifications from the server.
|
||||
*
|
||||
* @param method - LSP notification method (e.g., 'window/logMessage')
|
||||
* @param handler - Callback function to handle the notification
|
||||
*/
|
||||
function onNotification(
|
||||
method: string,
|
||||
handler: (params: unknown) => void,
|
||||
): void {
|
||||
client.onNotification(method, handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a handler for LSP requests from the server.
|
||||
*
|
||||
* Some LSP servers send requests TO the client (reverse direction).
|
||||
* This allows registering handlers for such requests.
|
||||
*
|
||||
* @param method - LSP request method (e.g., 'workspace/configuration')
|
||||
* @param handler - Callback function to handle the request and return a response
|
||||
*/
|
||||
function onRequest<TParams, TResult>(
|
||||
method: string,
|
||||
handler: (params: TParams) => TResult | Promise<TResult>,
|
||||
): void {
|
||||
client.onRequest(method, handler)
|
||||
}
|
||||
|
||||
// Return public API
|
||||
return {
|
||||
name,
|
||||
config,
|
||||
get state() {
|
||||
return state
|
||||
},
|
||||
get startTime() {
|
||||
return startTime
|
||||
},
|
||||
get lastError() {
|
||||
return lastError
|
||||
},
|
||||
get restartCount() {
|
||||
return restartCount
|
||||
},
|
||||
start,
|
||||
stop,
|
||||
restart,
|
||||
isHealthy,
|
||||
sendRequest,
|
||||
sendNotification,
|
||||
onNotification,
|
||||
onRequest,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Race a promise against a timeout. Cleans up the timer regardless of outcome
|
||||
* to avoid unhandled rejections from orphaned setTimeout callbacks.
|
||||
*/
|
||||
function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
ms: number,
|
||||
message: string,
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout((rej, msg) => rej(new Error(msg)), ms, reject, message)
|
||||
})
|
||||
return Promise.race([promise, timeoutPromise]).finally(() =>
|
||||
clearTimeout(timer!),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import * as path from 'path'
|
||||
import { pathToFileURL } from 'url'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { getAllLspServers } from './config.js'
|
||||
import {
|
||||
createLSPServerInstance,
|
||||
type LSPServerInstance,
|
||||
} from './LSPServerInstance.js'
|
||||
import type { ScopedLspServerConfig } from './types.js'
|
||||
/**
|
||||
* LSP Server Manager interface returned by createLSPServerManager.
|
||||
* Manages multiple LSP server instances and routes requests based on file extensions.
|
||||
*/
|
||||
export type LSPServerManager = {
|
||||
/** Initialize the manager by loading all configured LSP servers */
|
||||
initialize(): Promise<void>
|
||||
/** Shutdown all running servers and clear state */
|
||||
shutdown(): Promise<void>
|
||||
/** Get the LSP server instance for a given file path */
|
||||
getServerForFile(filePath: string): LSPServerInstance | undefined
|
||||
/** Ensure the appropriate LSP server is started for the given file */
|
||||
ensureServerStarted(filePath: string): Promise<LSPServerInstance | undefined>
|
||||
/** Send a request to the appropriate LSP server for the given file */
|
||||
sendRequest<T>(
|
||||
filePath: string,
|
||||
method: string,
|
||||
params: unknown,
|
||||
): Promise<T | undefined>
|
||||
/** Get all running server instances */
|
||||
getAllServers(): Map<string, LSPServerInstance>
|
||||
/** Synchronize file open to LSP server (sends didOpen notification) */
|
||||
openFile(filePath: string, content: string): Promise<void>
|
||||
/** Synchronize file change to LSP server (sends didChange notification) */
|
||||
changeFile(filePath: string, content: string): Promise<void>
|
||||
/** Synchronize file save to LSP server (sends didSave notification) */
|
||||
saveFile(filePath: string): Promise<void>
|
||||
/** Synchronize file close to LSP server (sends didClose notification) */
|
||||
closeFile(filePath: string): Promise<void>
|
||||
/** Check if a file is already open on a compatible LSP server */
|
||||
isFileOpen(filePath: string): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an LSP server manager instance.
|
||||
*
|
||||
* Manages multiple LSP server instances and routes requests based on file extensions.
|
||||
* Uses factory function pattern with closures for state encapsulation (avoiding classes).
|
||||
*
|
||||
* @returns LSP server manager instance
|
||||
*
|
||||
* @example
|
||||
* const manager = createLSPServerManager()
|
||||
* await manager.initialize()
|
||||
* const result = await manager.sendRequest('/path/to/file.ts', 'textDocument/definition', params)
|
||||
* await manager.shutdown()
|
||||
*/
|
||||
export function createLSPServerManager(): LSPServerManager {
|
||||
// Private state managed via closures
|
||||
const servers: Map<string, LSPServerInstance> = new Map()
|
||||
const extensionMap: Map<string, string[]> = new Map()
|
||||
// Track which files have been opened on which servers (URI -> server name)
|
||||
const openedFiles: Map<string, string> = new Map()
|
||||
|
||||
/**
|
||||
* Initialize the manager by loading all configured LSP servers.
|
||||
*
|
||||
* @throws {Error} If configuration loading fails
|
||||
*/
|
||||
async function initialize(): Promise<void> {
|
||||
let serverConfigs: Record<string, ScopedLspServerConfig>
|
||||
|
||||
try {
|
||||
const result = await getAllLspServers()
|
||||
serverConfigs = result.servers
|
||||
logForDebugging(
|
||||
`[LSP SERVER MANAGER] getAllLspServers returned ${Object.keys(serverConfigs).length} server(s)`,
|
||||
)
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
logError(
|
||||
new Error(`Failed to load LSP server configuration: ${err.message}`),
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
// Build extension → server mapping
|
||||
for (const [serverName, config] of Object.entries(serverConfigs)) {
|
||||
try {
|
||||
// Validate config before using it
|
||||
if (!config.command) {
|
||||
throw new Error(
|
||||
`Server ${serverName} missing required 'command' field`,
|
||||
)
|
||||
}
|
||||
if (
|
||||
!config.extensionToLanguage ||
|
||||
Object.keys(config.extensionToLanguage).length === 0
|
||||
) {
|
||||
throw new Error(
|
||||
`Server ${serverName} missing required 'extensionToLanguage' field`,
|
||||
)
|
||||
}
|
||||
|
||||
// Map file extensions to this server (derive from extensionToLanguage)
|
||||
const fileExtensions = Object.keys(config.extensionToLanguage)
|
||||
for (const ext of fileExtensions) {
|
||||
const normalized = ext.toLowerCase()
|
||||
if (!extensionMap.has(normalized)) {
|
||||
extensionMap.set(normalized, [])
|
||||
}
|
||||
const serverList = extensionMap.get(normalized)
|
||||
if (serverList) {
|
||||
serverList.push(serverName)
|
||||
}
|
||||
}
|
||||
|
||||
// Create server instance
|
||||
const instance = createLSPServerInstance(serverName, config)
|
||||
servers.set(serverName, instance)
|
||||
|
||||
// Register handler for workspace/configuration requests from the server
|
||||
// Some servers (like TypeScript) send these even when we say we don't support them
|
||||
instance.onRequest(
|
||||
'workspace/configuration',
|
||||
(params: { items: Array<{ section?: string }> }) => {
|
||||
logForDebugging(
|
||||
`LSP: Received workspace/configuration request from ${serverName}`,
|
||||
)
|
||||
// Return empty/null config for each requested item
|
||||
// This satisfies the protocol without providing actual configuration
|
||||
return params.items.map(() => null)
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
logError(
|
||||
new Error(
|
||||
`Failed to initialize LSP server ${serverName}: ${err.message}`,
|
||||
),
|
||||
)
|
||||
// Continue with other servers - don't fail entire initialization
|
||||
}
|
||||
}
|
||||
|
||||
logForDebugging(`LSP manager initialized with ${servers.size} servers`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown all running servers and clear state.
|
||||
* Only servers in 'running' state are explicitly stopped;
|
||||
* servers in other states are cleared without shutdown.
|
||||
*
|
||||
* @throws {Error} If one or more servers fail to stop
|
||||
*/
|
||||
async function shutdown(): Promise<void> {
|
||||
const toStop = Array.from(servers.entries()).filter(
|
||||
([, s]) => s.state === 'running' || s.state === 'error',
|
||||
)
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
toStop.map(([, server]) => server.stop()),
|
||||
)
|
||||
|
||||
servers.clear()
|
||||
extensionMap.clear()
|
||||
openedFiles.clear()
|
||||
|
||||
const errors = results
|
||||
.map((r, i) =>
|
||||
r.status === 'rejected'
|
||||
? `${toStop[i]![0]}: ${errorMessage(r.reason)}`
|
||||
: null,
|
||||
)
|
||||
.filter((e): e is string => e !== null)
|
||||
|
||||
if (errors.length > 0) {
|
||||
const err = new Error(
|
||||
`Failed to stop ${errors.length} LSP server(s): ${errors.join('; ')}`,
|
||||
)
|
||||
logError(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the LSP server instance for a given file path.
|
||||
* If multiple servers handle the same extension, returns the first registered server.
|
||||
* Returns undefined if no server handles this file type.
|
||||
*/
|
||||
function getServerForFile(filePath: string): LSPServerInstance | undefined {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
const serverNames = extensionMap.get(ext)
|
||||
|
||||
if (!serverNames || serverNames.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Use first server (can add priority later)
|
||||
const serverName = serverNames[0]
|
||||
if (!serverName) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return servers.get(serverName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the appropriate LSP server is started for the given file.
|
||||
* Returns undefined if no server handles this file type.
|
||||
*
|
||||
* @throws {Error} If server fails to start
|
||||
*/
|
||||
async function ensureServerStarted(
|
||||
filePath: string,
|
||||
): Promise<LSPServerInstance | undefined> {
|
||||
const server = getServerForFile(filePath)
|
||||
if (!server) return undefined
|
||||
|
||||
if (server.state === 'stopped' || server.state === 'error') {
|
||||
try {
|
||||
await server.start()
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
logError(
|
||||
new Error(
|
||||
`Failed to start LSP server for file ${filePath}: ${err.message}`,
|
||||
),
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to the appropriate LSP server for the given file.
|
||||
* Returns undefined if no server handles this file type.
|
||||
*
|
||||
* @throws {Error} If server fails to start or request fails
|
||||
*/
|
||||
async function sendRequest<T>(
|
||||
filePath: string,
|
||||
method: string,
|
||||
params: unknown,
|
||||
): Promise<T | undefined> {
|
||||
const server = await ensureServerStarted(filePath)
|
||||
if (!server) return undefined
|
||||
|
||||
try {
|
||||
return await server.sendRequest<T>(method, params)
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
logError(
|
||||
new Error(
|
||||
`LSP request failed for file ${filePath}, method '${method}': ${err.message}`,
|
||||
),
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Return public interface
|
||||
function getAllServers(): Map<string, LSPServerInstance> {
|
||||
return servers
|
||||
}
|
||||
|
||||
async function openFile(filePath: string, content: string): Promise<void> {
|
||||
const server = await ensureServerStarted(filePath)
|
||||
if (!server) return
|
||||
|
||||
const fileUri = pathToFileURL(path.resolve(filePath)).href
|
||||
|
||||
// Skip if already opened on this server
|
||||
if (openedFiles.get(fileUri) === server.name) {
|
||||
logForDebugging(
|
||||
`LSP: File already open, skipping didOpen for ${filePath}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Get language ID from server's extensionToLanguage mapping
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
const languageId = server.config.extensionToLanguage[ext] || 'plaintext'
|
||||
|
||||
try {
|
||||
await server.sendNotification('textDocument/didOpen', {
|
||||
textDocument: {
|
||||
uri: fileUri,
|
||||
languageId,
|
||||
version: 1,
|
||||
text: content,
|
||||
},
|
||||
})
|
||||
// Track that this file is now open on this server
|
||||
openedFiles.set(fileUri, server.name)
|
||||
logForDebugging(
|
||||
`LSP: Sent didOpen for ${filePath} (languageId: ${languageId})`,
|
||||
)
|
||||
} catch (error) {
|
||||
const err = new Error(
|
||||
`Failed to sync file open ${filePath}: ${errorMessage(error)}`,
|
||||
)
|
||||
logError(err)
|
||||
// Re-throw to propagate error to caller
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function changeFile(filePath: string, content: string): Promise<void> {
|
||||
const server = getServerForFile(filePath)
|
||||
if (!server || server.state !== 'running') {
|
||||
return openFile(filePath, content)
|
||||
}
|
||||
|
||||
const fileUri = pathToFileURL(path.resolve(filePath)).href
|
||||
|
||||
// If file hasn't been opened on this server yet, open it first
|
||||
// LSP servers require didOpen before didChange
|
||||
if (openedFiles.get(fileUri) !== server.name) {
|
||||
return openFile(filePath, content)
|
||||
}
|
||||
|
||||
try {
|
||||
await server.sendNotification('textDocument/didChange', {
|
||||
textDocument: {
|
||||
uri: fileUri,
|
||||
version: 1,
|
||||
},
|
||||
contentChanges: [{ text: content }],
|
||||
})
|
||||
logForDebugging(`LSP: Sent didChange for ${filePath}`)
|
||||
} catch (error) {
|
||||
const err = new Error(
|
||||
`Failed to sync file change ${filePath}: ${errorMessage(error)}`,
|
||||
)
|
||||
logError(err)
|
||||
// Re-throw to propagate error to caller
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a file in LSP servers (sends didSave notification)
|
||||
* Called after file is written to disk to trigger diagnostics
|
||||
*/
|
||||
async function saveFile(filePath: string): Promise<void> {
|
||||
const server = getServerForFile(filePath)
|
||||
if (!server || server.state !== 'running') return
|
||||
|
||||
try {
|
||||
await server.sendNotification('textDocument/didSave', {
|
||||
textDocument: {
|
||||
uri: pathToFileURL(path.resolve(filePath)).href,
|
||||
},
|
||||
})
|
||||
logForDebugging(`LSP: Sent didSave for ${filePath}`)
|
||||
} catch (error) {
|
||||
const err = new Error(
|
||||
`Failed to sync file save ${filePath}: ${errorMessage(error)}`,
|
||||
)
|
||||
logError(err)
|
||||
// Re-throw to propagate error to caller
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a file in LSP servers (sends didClose notification)
|
||||
*
|
||||
* NOTE: Currently available but not yet integrated with compact flow.
|
||||
* TODO: Integrate with compact - call closeFile() when compact removes files from context
|
||||
* This will notify LSP servers that files are no longer in active use.
|
||||
*/
|
||||
async function closeFile(filePath: string): Promise<void> {
|
||||
const server = getServerForFile(filePath)
|
||||
if (!server || server.state !== 'running') return
|
||||
|
||||
const fileUri = pathToFileURL(path.resolve(filePath)).href
|
||||
|
||||
try {
|
||||
await server.sendNotification('textDocument/didClose', {
|
||||
textDocument: {
|
||||
uri: fileUri,
|
||||
},
|
||||
})
|
||||
// Remove from tracking so file can be reopened later
|
||||
openedFiles.delete(fileUri)
|
||||
logForDebugging(`LSP: Sent didClose for ${filePath}`)
|
||||
} catch (error) {
|
||||
const err = new Error(
|
||||
`Failed to sync file close ${filePath}: ${errorMessage(error)}`,
|
||||
)
|
||||
logError(err)
|
||||
// Re-throw to propagate error to caller
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function isFileOpen(filePath: string): boolean {
|
||||
const fileUri = pathToFileURL(path.resolve(filePath)).href
|
||||
return openedFiles.has(fileUri)
|
||||
}
|
||||
|
||||
return {
|
||||
initialize,
|
||||
shutdown,
|
||||
getServerForFile,
|
||||
ensureServerStarted,
|
||||
sendRequest,
|
||||
getAllServers,
|
||||
openFile,
|
||||
changeFile,
|
||||
saveFile,
|
||||
closeFile,
|
||||
isFileOpen,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { PluginError } from '../../types/plugin.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { errorMessage, toError } from '../../utils/errors.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { getPluginLspServers } from '../../utils/plugins/lspPluginIntegration.js'
|
||||
import { loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js'
|
||||
import type { ScopedLspServerConfig } from './types.js'
|
||||
|
||||
/**
|
||||
* Get all configured LSP servers from plugins.
|
||||
* LSP servers are only supported via plugins, not user/project settings.
|
||||
*
|
||||
* @returns Object containing servers configuration keyed by scoped server name
|
||||
*/
|
||||
export async function getAllLspServers(): Promise<{
|
||||
servers: Record<string, ScopedLspServerConfig>
|
||||
}> {
|
||||
const allServers: Record<string, ScopedLspServerConfig> = {}
|
||||
|
||||
try {
|
||||
// Get all enabled plugins
|
||||
const { enabled: plugins } = await loadAllPluginsCacheOnly()
|
||||
|
||||
// Load LSP servers from each plugin in parallel.
|
||||
// Each plugin is independent — results are merged in original order so
|
||||
// Object.assign collision precedence (later plugins win) is preserved.
|
||||
const results = await Promise.all(
|
||||
plugins.map(async plugin => {
|
||||
const errors: PluginError[] = []
|
||||
try {
|
||||
const scopedServers = await getPluginLspServers(plugin, errors)
|
||||
return { plugin, scopedServers, errors }
|
||||
} catch (e) {
|
||||
// Defensive: if one plugin throws, don't lose results from the
|
||||
// others. The previous serial loop implicitly tolerated this.
|
||||
logForDebugging(
|
||||
`Failed to load LSP servers for plugin ${plugin.name}: ${e}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
return { plugin, scopedServers: undefined, errors }
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
for (const { plugin, scopedServers, errors } of results) {
|
||||
const serverCount = scopedServers ? Object.keys(scopedServers).length : 0
|
||||
if (serverCount > 0) {
|
||||
// Merge into all servers (already scoped by getPluginLspServers)
|
||||
Object.assign(allServers, scopedServers)
|
||||
|
||||
logForDebugging(
|
||||
`Loaded ${serverCount} LSP server(s) from plugin: ${plugin.name}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Log any errors encountered
|
||||
if (errors.length > 0) {
|
||||
logForDebugging(
|
||||
`${errors.length} error(s) loading LSP servers from plugin: ${plugin.name}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`Total LSP servers loaded: ${Object.keys(allServers).length}`,
|
||||
)
|
||||
} catch (error) {
|
||||
// Log error for monitoring production issues.
|
||||
// LSP is optional, so we don't throw - but we need visibility
|
||||
// into why plugin loading fails to improve the feature.
|
||||
logError(toError(error))
|
||||
|
||||
logForDebugging(`Error loading LSP servers: ${errorMessage(error)}`)
|
||||
}
|
||||
|
||||
return {
|
||||
servers: allServers,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { isBareMode } from '../../utils/envUtils.js'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import {
|
||||
createLSPServerManager,
|
||||
type LSPServerManager,
|
||||
} from './LSPServerManager.js'
|
||||
import { registerLSPNotificationHandlers } from './passiveFeedback.js'
|
||||
|
||||
/**
|
||||
* Initialization state of the LSP server manager
|
||||
*/
|
||||
type InitializationState = 'not-started' | 'pending' | 'success' | 'failed'
|
||||
|
||||
/**
|
||||
* Global singleton instance of the LSP server manager.
|
||||
* Initialized during Claude Code startup.
|
||||
*/
|
||||
let lspManagerInstance: LSPServerManager | undefined
|
||||
|
||||
/**
|
||||
* Current initialization state
|
||||
*/
|
||||
let initializationState: InitializationState = 'not-started'
|
||||
|
||||
/**
|
||||
* Error from last initialization attempt, if any
|
||||
*/
|
||||
let initializationError: Error | undefined
|
||||
|
||||
/**
|
||||
* Generation counter to prevent stale initialization promises from updating state
|
||||
*/
|
||||
let initializationGeneration = 0
|
||||
|
||||
/**
|
||||
* Promise that resolves when initialization completes (success or failure)
|
||||
*/
|
||||
let initializationPromise: Promise<void> | undefined
|
||||
|
||||
/**
|
||||
* Test-only sync reset. shutdownLspServerManager() is async and tears down
|
||||
* real connections; this only clears the module-scope singleton state so
|
||||
* reinitializeLspServerManager() early-returns on 'not-started' in downstream
|
||||
* tests on the same shard.
|
||||
*/
|
||||
export function _resetLspManagerForTesting(): void {
|
||||
initializationState = 'not-started'
|
||||
initializationError = undefined
|
||||
initializationPromise = undefined
|
||||
initializationGeneration++
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton LSP server manager instance.
|
||||
* Returns undefined if not yet initialized, initialization failed, or still pending.
|
||||
*
|
||||
* Callers should check for undefined and handle gracefully, as initialization happens
|
||||
* asynchronously during Claude Code startup. Use getInitializationStatus() to
|
||||
* distinguish between pending, failed, and not-started states.
|
||||
*/
|
||||
export function getLspServerManager(): LSPServerManager | undefined {
|
||||
// Don't return a broken instance if initialization failed
|
||||
if (initializationState === 'failed') {
|
||||
return undefined
|
||||
}
|
||||
return lspManagerInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current initialization status of the LSP server manager.
|
||||
*
|
||||
* @returns Status object with current state and error (if failed)
|
||||
*/
|
||||
export function getInitializationStatus():
|
||||
| { status: 'not-started' }
|
||||
| { status: 'pending' }
|
||||
| { status: 'success' }
|
||||
| { status: 'failed'; error: Error } {
|
||||
if (initializationState === 'failed') {
|
||||
return {
|
||||
status: 'failed',
|
||||
error: initializationError || new Error('Initialization failed'),
|
||||
}
|
||||
}
|
||||
if (initializationState === 'not-started') {
|
||||
return { status: 'not-started' }
|
||||
}
|
||||
if (initializationState === 'pending') {
|
||||
return { status: 'pending' }
|
||||
}
|
||||
return { status: 'success' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether at least one language server is connected and healthy.
|
||||
* Backs LSPTool.isEnabled().
|
||||
*/
|
||||
export function isLspConnected(): boolean {
|
||||
if (initializationState === 'failed') return false
|
||||
const manager = getLspServerManager()
|
||||
if (!manager) return false
|
||||
const servers = manager.getAllServers()
|
||||
if (servers.size === 0) return false
|
||||
for (const server of servers.values()) {
|
||||
if (server.state !== 'error') return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for LSP server manager initialization to complete.
|
||||
*
|
||||
* Returns immediately if initialization has already completed (success or failure).
|
||||
* If initialization is pending, waits for it to complete.
|
||||
* If initialization hasn't started, returns immediately.
|
||||
*
|
||||
* @returns Promise that resolves when initialization is complete
|
||||
*/
|
||||
export async function waitForInitialization(): Promise<void> {
|
||||
// If already initialized or failed, return immediately
|
||||
if (initializationState === 'success' || initializationState === 'failed') {
|
||||
return
|
||||
}
|
||||
|
||||
// If pending and we have a promise, wait for it
|
||||
if (initializationState === 'pending' && initializationPromise) {
|
||||
await initializationPromise
|
||||
}
|
||||
|
||||
// If not started, return immediately (nothing to wait for)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the LSP server manager singleton.
|
||||
*
|
||||
* This function is called during Claude Code startup. It synchronously creates
|
||||
* the manager instance, then starts async initialization (loading LSP configs)
|
||||
* in the background without blocking the startup process.
|
||||
*
|
||||
* Safe to call multiple times - will only initialize once (idempotent).
|
||||
* However, if initialization previously failed, calling again will retry.
|
||||
*/
|
||||
export function initializeLspServerManager(): void {
|
||||
// --bare / SIMPLE: no LSP. LSP is for editor integration (diagnostics,
|
||||
// hover, go-to-def in the REPL). Scripted -p calls have no use for it.
|
||||
if (isBareMode()) {
|
||||
return
|
||||
}
|
||||
logForDebugging('[LSP MANAGER] initializeLspServerManager() called')
|
||||
|
||||
// Skip if already initialized or currently initializing
|
||||
if (lspManagerInstance !== undefined && initializationState !== 'failed') {
|
||||
logForDebugging(
|
||||
'[LSP MANAGER] Already initialized or initializing, skipping',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Reset state for retry if previous initialization failed
|
||||
if (initializationState === 'failed') {
|
||||
lspManagerInstance = undefined
|
||||
initializationError = undefined
|
||||
}
|
||||
|
||||
// Create the manager instance and mark as pending
|
||||
lspManagerInstance = createLSPServerManager()
|
||||
initializationState = 'pending'
|
||||
logForDebugging('[LSP MANAGER] Created manager instance, state=pending')
|
||||
|
||||
// Increment generation to invalidate any pending initializations
|
||||
const currentGeneration = ++initializationGeneration
|
||||
logForDebugging(
|
||||
`[LSP MANAGER] Starting async initialization (generation ${currentGeneration})`,
|
||||
)
|
||||
|
||||
// Start initialization asynchronously without blocking
|
||||
// Store the promise so callers can await it via waitForInitialization()
|
||||
initializationPromise = lspManagerInstance
|
||||
.initialize()
|
||||
.then(() => {
|
||||
// Only update state if this is still the current initialization
|
||||
if (currentGeneration === initializationGeneration) {
|
||||
initializationState = 'success'
|
||||
logForDebugging('LSP server manager initialized successfully')
|
||||
|
||||
// Register passive notification handlers for diagnostics
|
||||
if (lspManagerInstance) {
|
||||
registerLSPNotificationHandlers(lspManagerInstance)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
// Only update state if this is still the current initialization
|
||||
if (currentGeneration === initializationGeneration) {
|
||||
initializationState = 'failed'
|
||||
initializationError = error as Error
|
||||
// Clear the instance since it's not usable
|
||||
lspManagerInstance = undefined
|
||||
|
||||
logError(error as Error)
|
||||
logForDebugging(
|
||||
`Failed to initialize LSP server manager: ${errorMessage(error)}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Force re-initialization of the LSP server manager, even after a prior
|
||||
* successful init. Called from refreshActivePlugins() after plugin caches
|
||||
* are cleared, so newly-loaded plugin LSP servers are picked up.
|
||||
*
|
||||
* Fixes https://github.com/anthropics/claude-code/issues/15521:
|
||||
* loadAllPlugins() is memoized and can be called very early in startup
|
||||
* (via getCommands prefetch in setup.ts) before marketplaces are reconciled,
|
||||
* caching an empty plugin list. initializeLspServerManager() then reads that
|
||||
* stale memoized result and initializes with 0 servers. Unlike commands/agents/
|
||||
* hooks/MCP, LSP was never re-initialized on plugin refresh.
|
||||
*
|
||||
* Safe to call when no LSP plugins changed: initialize() is just config
|
||||
* parsing (servers are lazy-started on first use). Also safe during pending
|
||||
* init: the generation counter invalidates the in-flight promise.
|
||||
*/
|
||||
export function reinitializeLspServerManager(): void {
|
||||
if (initializationState === 'not-started') {
|
||||
// initializeLspServerManager() was never called (e.g. headless subcommand
|
||||
// path). Don't start it now.
|
||||
return
|
||||
}
|
||||
|
||||
logForDebugging('[LSP MANAGER] reinitializeLspServerManager() called')
|
||||
|
||||
// Best-effort shutdown of any running servers on the old instance so
|
||||
// /reload-plugins doesn't leak child processes. Fire-and-forget: the
|
||||
// primary use case (issue #15521) has 0 servers so this is usually a no-op.
|
||||
if (lspManagerInstance) {
|
||||
void lspManagerInstance.shutdown().catch(err => {
|
||||
logForDebugging(
|
||||
`[LSP MANAGER] old instance shutdown during reinit failed: ${errorMessage(err)}`,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Force the idempotence check in initializeLspServerManager() to fall
|
||||
// through. Generation counter handles invalidating any in-flight init.
|
||||
lspManagerInstance = undefined
|
||||
initializationState = 'not-started'
|
||||
initializationError = undefined
|
||||
|
||||
initializeLspServerManager()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the LSP server manager and clean up resources.
|
||||
*
|
||||
* This should be called during Claude Code shutdown. Stops all running LSP servers
|
||||
* and clears internal state. Safe to call when not initialized (no-op).
|
||||
*
|
||||
* NOTE: Errors during shutdown are logged for monitoring but NOT propagated to the caller.
|
||||
* State is always cleared even if shutdown fails, to prevent resource accumulation.
|
||||
* This is acceptable during application exit when recovery is not possible.
|
||||
*
|
||||
* @returns Promise that resolves when shutdown completes (errors are swallowed)
|
||||
*/
|
||||
export async function shutdownLspServerManager(): Promise<void> {
|
||||
if (lspManagerInstance === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await lspManagerInstance.shutdown()
|
||||
logForDebugging('LSP server manager shut down successfully')
|
||||
} catch (error: unknown) {
|
||||
logError(error as Error)
|
||||
logForDebugging(
|
||||
`Failed to shutdown LSP server manager: ${errorMessage(error)}`,
|
||||
)
|
||||
} finally {
|
||||
// Always clear state even if shutdown failed
|
||||
lspManagerInstance = undefined
|
||||
initializationState = 'not-started'
|
||||
initializationError = undefined
|
||||
initializationPromise = undefined
|
||||
// Increment generation to invalidate any pending initializations
|
||||
initializationGeneration++
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import { fileURLToPath } from 'url'
|
||||
import type { PublishDiagnosticsParams } from 'vscode-languageserver-protocol'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { toError } from '../../utils/errors.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { jsonStringify } from '../../utils/slowOperations.js'
|
||||
import type { DiagnosticFile } from '../diagnosticTracking.js'
|
||||
import { registerPendingLSPDiagnostic } from './LSPDiagnosticRegistry.js'
|
||||
import type { LSPServerManager } from './LSPServerManager.js'
|
||||
|
||||
/**
|
||||
* Map LSP severity to Claude diagnostic severity
|
||||
*
|
||||
* Maps LSP severity numbers to Claude diagnostic severity strings.
|
||||
* Accepts numeric severity values (1=Error, 2=Warning, 3=Information, 4=Hint)
|
||||
* or undefined, defaulting to 'Error' for invalid/missing values.
|
||||
*/
|
||||
function mapLSPSeverity(
|
||||
lspSeverity: number | undefined,
|
||||
): 'Error' | 'Warning' | 'Info' | 'Hint' {
|
||||
// LSP DiagnosticSeverity enum:
|
||||
// 1 = Error, 2 = Warning, 3 = Information, 4 = Hint
|
||||
switch (lspSeverity) {
|
||||
case 1:
|
||||
return 'Error'
|
||||
case 2:
|
||||
return 'Warning'
|
||||
case 3:
|
||||
return 'Info'
|
||||
case 4:
|
||||
return 'Hint'
|
||||
default:
|
||||
return 'Error'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert LSP diagnostics to Claude diagnostic format
|
||||
*
|
||||
* Converts LSP PublishDiagnosticsParams to DiagnosticFile[] format
|
||||
* used by Claude's attachment system.
|
||||
*/
|
||||
export function formatDiagnosticsForAttachment(
|
||||
params: PublishDiagnosticsParams,
|
||||
): DiagnosticFile[] {
|
||||
// Parse URI (may be file:// or plain path) and normalize to file system path
|
||||
let uri: string
|
||||
try {
|
||||
// Handle both file:// URIs and plain paths
|
||||
uri = params.uri.startsWith('file://')
|
||||
? fileURLToPath(params.uri)
|
||||
: params.uri
|
||||
} catch (error) {
|
||||
const err = toError(error)
|
||||
logError(err)
|
||||
logForDebugging(
|
||||
`Failed to convert URI to file path: ${params.uri}. Error: ${err.message}. Using original URI as fallback.`,
|
||||
)
|
||||
// Gracefully fallback to original URI - LSP servers may send malformed URIs
|
||||
uri = params.uri
|
||||
}
|
||||
|
||||
const diagnostics = params.diagnostics.map(
|
||||
(diag: {
|
||||
message: string
|
||||
severity?: number
|
||||
range: {
|
||||
start: { line: number; character: number }
|
||||
end: { line: number; character: number }
|
||||
}
|
||||
source?: string
|
||||
code?: string | number
|
||||
}) => ({
|
||||
message: diag.message,
|
||||
severity: mapLSPSeverity(diag.severity),
|
||||
range: {
|
||||
start: {
|
||||
line: diag.range.start.line,
|
||||
character: diag.range.start.character,
|
||||
},
|
||||
end: {
|
||||
line: diag.range.end.line,
|
||||
character: diag.range.end.character,
|
||||
},
|
||||
},
|
||||
source: diag.source,
|
||||
code:
|
||||
diag.code !== undefined && diag.code !== null
|
||||
? String(diag.code)
|
||||
: undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
uri,
|
||||
diagnostics,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler registration result with tracking data
|
||||
*/
|
||||
export type HandlerRegistrationResult = {
|
||||
/** Total number of servers */
|
||||
totalServers: number
|
||||
/** Number of successful registrations */
|
||||
successCount: number
|
||||
/** Registration errors per server */
|
||||
registrationErrors: Array<{ serverName: string; error: string }>
|
||||
/** Runtime failure tracking (shared across all handler invocations) */
|
||||
diagnosticFailures: Map<string, { count: number; lastError: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Register LSP notification handlers on all servers
|
||||
*
|
||||
* Sets up handlers to listen for textDocument/publishDiagnostics notifications
|
||||
* from all LSP servers and routes them to Claude's diagnostic system.
|
||||
* Uses public getAllServers() API for clean access to server instances.
|
||||
*
|
||||
* @returns Tracking data for registration status and runtime failures
|
||||
*/
|
||||
export function registerLSPNotificationHandlers(
|
||||
manager: LSPServerManager,
|
||||
): HandlerRegistrationResult {
|
||||
// Register handlers on all configured servers to capture diagnostics from any language
|
||||
const servers = manager.getAllServers()
|
||||
|
||||
// Track partial failures - allow successful server registrations even if some fail
|
||||
const registrationErrors: Array<{ serverName: string; error: string }> = []
|
||||
let successCount = 0
|
||||
|
||||
// Track consecutive failures per server to warn users after 3+ failures
|
||||
const diagnosticFailures: Map<string, { count: number; lastError: string }> =
|
||||
new Map()
|
||||
|
||||
for (const [serverName, serverInstance] of servers.entries()) {
|
||||
try {
|
||||
// Validate server instance has onNotification method
|
||||
if (
|
||||
!serverInstance ||
|
||||
typeof serverInstance.onNotification !== 'function'
|
||||
) {
|
||||
const errorMsg = !serverInstance
|
||||
? 'Server instance is null/undefined'
|
||||
: 'Server instance has no onNotification method'
|
||||
|
||||
registrationErrors.push({ serverName, error: errorMsg })
|
||||
|
||||
const err = new Error(`${errorMsg} for ${serverName}`)
|
||||
logError(err)
|
||||
logForDebugging(
|
||||
`Skipping handler registration for ${serverName}: ${errorMsg}`,
|
||||
)
|
||||
continue // Skip this server but track the failure
|
||||
}
|
||||
|
||||
// Errors are isolated to avoid breaking other servers
|
||||
serverInstance.onNotification(
|
||||
'textDocument/publishDiagnostics',
|
||||
(params: unknown) => {
|
||||
logForDebugging(
|
||||
`[PASSIVE DIAGNOSTICS] Handler invoked for ${serverName}! Params type: ${typeof params}`,
|
||||
)
|
||||
try {
|
||||
// Validate params structure before casting
|
||||
if (
|
||||
!params ||
|
||||
typeof params !== 'object' ||
|
||||
!('uri' in params) ||
|
||||
!('diagnostics' in params)
|
||||
) {
|
||||
const err = new Error(
|
||||
`LSP server ${serverName} sent invalid diagnostic params (missing uri or diagnostics)`,
|
||||
)
|
||||
logError(err)
|
||||
logForDebugging(
|
||||
`Invalid diagnostic params from ${serverName}: ${jsonStringify(params)}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const diagnosticParams = params as PublishDiagnosticsParams
|
||||
logForDebugging(
|
||||
`Received diagnostics from ${serverName}: ${diagnosticParams.diagnostics.length} diagnostic(s) for ${diagnosticParams.uri}`,
|
||||
)
|
||||
|
||||
// Convert LSP diagnostics to Claude format (can throw on invalid URIs)
|
||||
const diagnosticFiles =
|
||||
formatDiagnosticsForAttachment(diagnosticParams)
|
||||
|
||||
// Only send notification if there are diagnostics
|
||||
const firstFile = diagnosticFiles[0]
|
||||
if (
|
||||
!firstFile ||
|
||||
diagnosticFiles.length === 0 ||
|
||||
firstFile.diagnostics.length === 0
|
||||
) {
|
||||
logForDebugging(
|
||||
`Skipping empty diagnostics from ${serverName} for ${diagnosticParams.uri}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Register diagnostics for async delivery via attachment system
|
||||
// Follows same pattern as AsyncHookRegistry for consistent async attachment delivery
|
||||
try {
|
||||
registerPendingLSPDiagnostic({
|
||||
serverName,
|
||||
files: diagnosticFiles,
|
||||
})
|
||||
|
||||
logForDebugging(
|
||||
`LSP Diagnostics: Registered ${diagnosticFiles.length} diagnostic file(s) from ${serverName} for async delivery`,
|
||||
)
|
||||
|
||||
// Success - reset failure counter for this server
|
||||
diagnosticFailures.delete(serverName)
|
||||
} catch (error) {
|
||||
const err = toError(error)
|
||||
logError(err)
|
||||
logForDebugging(
|
||||
`Error registering LSP diagnostics from ${serverName}: ` +
|
||||
`URI: ${diagnosticParams.uri}, ` +
|
||||
`Diagnostic count: ${firstFile.diagnostics.length}, ` +
|
||||
`Error: ${err.message}`,
|
||||
)
|
||||
|
||||
// Track consecutive failures and warn after 3+
|
||||
const failures = diagnosticFailures.get(serverName) || {
|
||||
count: 0,
|
||||
lastError: '',
|
||||
}
|
||||
failures.count++
|
||||
failures.lastError = err.message
|
||||
diagnosticFailures.set(serverName, failures)
|
||||
|
||||
if (failures.count >= 3) {
|
||||
logForDebugging(
|
||||
`WARNING: LSP diagnostic handler for ${serverName} has failed ${failures.count} times consecutively. ` +
|
||||
`Last error: ${failures.lastError}. ` +
|
||||
`This may indicate a problem with the LSP server or diagnostic processing. ` +
|
||||
`Check logs for details.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Catch any unexpected errors from the entire handler to prevent breaking the notification loop
|
||||
const err = toError(error)
|
||||
logError(err)
|
||||
logForDebugging(
|
||||
`Unexpected error processing diagnostics from ${serverName}: ${err.message}`,
|
||||
)
|
||||
|
||||
// Track consecutive failures and warn after 3+
|
||||
const failures = diagnosticFailures.get(serverName) || {
|
||||
count: 0,
|
||||
lastError: '',
|
||||
}
|
||||
failures.count++
|
||||
failures.lastError = err.message
|
||||
diagnosticFailures.set(serverName, failures)
|
||||
|
||||
if (failures.count >= 3) {
|
||||
logForDebugging(
|
||||
`WARNING: LSP diagnostic handler for ${serverName} has failed ${failures.count} times consecutively. ` +
|
||||
`Last error: ${failures.lastError}. ` +
|
||||
`This may indicate a problem with the LSP server or diagnostic processing. ` +
|
||||
`Check logs for details.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Don't re-throw - isolate errors to this server only
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
logForDebugging(`Registered diagnostics handler for ${serverName}`)
|
||||
successCount++
|
||||
} catch (error) {
|
||||
const err = toError(error)
|
||||
|
||||
registrationErrors.push({
|
||||
serverName,
|
||||
error: err.message,
|
||||
})
|
||||
|
||||
logError(err)
|
||||
logForDebugging(
|
||||
`Failed to register diagnostics handler for ${serverName}: ` +
|
||||
`Error: ${err.message}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Report overall registration status
|
||||
const totalServers = servers.size
|
||||
if (registrationErrors.length > 0) {
|
||||
const failedServers = registrationErrors
|
||||
.map(e => `${e.serverName} (${e.error})`)
|
||||
.join(', ')
|
||||
// Log aggregate failures for tracking
|
||||
logError(
|
||||
new Error(
|
||||
`Failed to register diagnostics for ${registrationErrors.length} LSP server(s): ${failedServers}`,
|
||||
),
|
||||
)
|
||||
logForDebugging(
|
||||
`LSP notification handler registration: ${successCount}/${totalServers} succeeded. ` +
|
||||
`Failed servers: ${failedServers}. ` +
|
||||
`Diagnostics from failed servers will not be delivered.`,
|
||||
)
|
||||
} else {
|
||||
logForDebugging(
|
||||
`LSP notification handlers registered successfully for all ${totalServers} server(s)`,
|
||||
)
|
||||
}
|
||||
|
||||
// Return tracking data for monitoring and testing
|
||||
return {
|
||||
totalServers,
|
||||
successCount,
|
||||
registrationErrors,
|
||||
diagnosticFailures,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user