feat(lsp): added deferred diagnostics and workspace-scoped LSP actions

- Added deferred diagnostics support in LSP writethrough: `onDeferredDiagnostics` callback and `deferredSignal` in `WritethroughOptions` allow callers to receive diagnostics that arrive after the main 5-second timeout.
- Added language detection for `.pm` (Perl modules), `.astro` (Astro framework), and special filenames `containerfile` and `justfile`.
- Added workspace-scoped diagnostics and reload actions via `*` file parameter; `diagnostics` action now supports `*` for workspace-wide diagnostics across all configured servers.
- Reduced LSP diagnostics timeout from 10 seconds to 5 seconds for faster feedback; slow diagnostics now fetch in background via deferred mechanism.
This commit is contained in:
can1357
2026-04-08 04:18:15 +02:00
parent 1e18e6a9e1
commit a5fa614f1a
3 changed files with 129 additions and 18 deletions
+7
View File
@@ -1,8 +1,12 @@
# Changelog
## [Unreleased]
### Added
- Deferred diagnostics support in LSP writethrough: `onDeferredDiagnostics` callback and `deferredSignal` in `WritethroughOptions` allow callers to receive diagnostics that arrive after the main 5-second timeout
- Language detection for `.pm` (Perl modules), `.astro` (Astro framework), and special filenames `containerfile` and `justfile`
- Workspace-scoped diagnostics and reload actions via `*` file parameter; `diagnostics` action now supports `*` for workspace-wide diagnostics across all configured servers
- Socket-mode DAP adapter support for debuggers like dlv that communicate via network sockets instead of stdio; Linux uses unix domain sockets, macOS/other platforms use TCP with client-addr dialing
- Improved extensionless binary debugging: native debuggers (gdb, lldb-dap) and adapters with root markers are now preferred over unrelated adapters like debugpy
- Debug tool with DAP (Debug Adapter Protocol) support for launching and attaching debuggers, setting breakpoints, stepping through execution, inspecting threads/stack/variables, and evaluating expressions
@@ -20,6 +24,9 @@
### Changed
- LSP diagnostics timeout reduced from 10 seconds to 5 seconds for faster feedback; slow diagnostics now fetch in background via deferred mechanism
- Diagnostics action error messaging clarified: requires `file` parameter or `*` for workspace scope; improved guidance in error responses
- Workspace symbols and reload actions now accept `*` to operate across all configured servers instead of requiring a file path
- DAP session initialization now subscribes to stop events before launching/attaching to avoid missing stopOnEntry events
- Stack frame fetching moved outside the event dispatch loop to prevent deadlocks and improve responsiveness
- Evaluate requests now default to the top stopped frame when frameId is not explicitly provided
+116 -17
View File
@@ -692,8 +692,25 @@ export interface WritethroughOptions {
enableFormat?: boolean;
/** Whether to get LSP diagnostics after writing */
enableDiagnostics?: boolean;
/** Called when diagnostics arrive after the main timeout. */
onDeferredDiagnostics?: (diagnostics: FileDiagnosticsResult) => void;
/** Signal to cancel a pending deferred diagnostics fetch. */
deferredSignal?: AbortSignal;
}
/** Internal resolved form of {@link WritethroughOptions} that the writethrough machinery operates on. */
type ResolvedWritethroughOptions = {
enableFormat: boolean;
enableDiagnostics: boolean;
};
/** Per-file deferred LSP diagnostics wiring for {@link WritethroughCallback}. */
export type WritethroughDeferredHandle = {
onDeferredDiagnostics: (diagnostics: FileDiagnosticsResult) => void;
signal: AbortSignal;
finalize: (diagnostics: FileDiagnosticsResult | undefined) => void;
};
/** Callback type for the LSP writethrough */
export type WritethroughCallback = (
dst: string,
@@ -701,6 +718,7 @@ export type WritethroughCallback = (
signal?: AbortSignal,
file?: BunFile,
batch?: LspWritethroughBatchRequest,
getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined,
) => Promise<FileDiagnosticsResult | undefined>;
/** No-op writethrough callback */
@@ -709,6 +727,8 @@ export async function writethroughNoop(
content: string,
_signal?: AbortSignal,
file?: BunFile,
_batch?: LspWritethroughBatchRequest,
_getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined,
): Promise<FileDiagnosticsResult | undefined> {
if (file) {
await file.write(content);
@@ -731,12 +751,12 @@ interface LspWritethroughBatchRequest {
interface LspWritethroughBatchState {
entries: Map<string, PendingWritethrough>;
options: Required<WritethroughOptions>;
options: ResolvedWritethroughOptions;
}
const writethroughBatches = new Map<string, LspWritethroughBatchState>();
function getOrCreateWritethroughBatch(id: string, options: Required<WritethroughOptions>): LspWritethroughBatchState {
function getOrCreateWritethroughBatch(id: string, options: ResolvedWritethroughOptions): LspWritethroughBatchState {
const existing = writethroughBatches.get(id);
if (existing) {
existing.options.enableFormat ||= options.enableFormat;
@@ -787,7 +807,7 @@ function summarizeDiagnosticMessages(messages: string[]): { summary: string; err
function mergeDiagnostics(
results: Array<FileDiagnosticsResult | undefined>,
options: Required<WritethroughOptions>,
options: ResolvedWritethroughOptions,
): FileDiagnosticsResult | undefined {
const messages: string[] = [];
const servers = new Set<string>();
@@ -841,13 +861,41 @@ function mergeDiagnostics(
};
}
async function scheduleDeferredDiagnosticsFetch(args: {
dst: string;
cwd: string;
servers: Array<[string, ServerConfig]>;
minVersions: ServerVersionMap | undefined;
expectedDocumentVersions: ServerVersionMap | undefined;
signal: AbortSignal;
callback: (diagnostics: FileDiagnosticsResult) => void;
}): Promise<void> {
try {
const deferredTimeout = AbortSignal.timeout(25_000);
const combined = AbortSignal.any([args.signal, deferredTimeout]);
const diagnostics = await getDiagnosticsForFile(args.dst, args.cwd, args.servers, {
signal: combined,
minVersions: args.minVersions,
expectedDocumentVersions: args.expectedDocumentVersions,
});
if (args.signal.aborted || diagnostics === undefined) return;
args.callback(diagnostics);
} catch {
// Cancelled or LSP gave up; silently discard.
}
}
async function runLspWritethrough(
dst: string,
content: string,
cwd: string,
options: Required<WritethroughOptions>,
options: ResolvedWritethroughOptions,
signal?: AbortSignal,
file?: BunFile,
deferred?: {
onDeferredDiagnostics: (diagnostics: FileDiagnosticsResult) => void;
signal: AbortSignal;
},
): Promise<FileDiagnosticsResult | undefined> {
const { enableFormat, enableDiagnostics } = options;
const config = getConfig(cwd);
@@ -870,7 +918,7 @@ async function runLspWritethrough(
let diagnostics: FileDiagnosticsResult | undefined;
let timedOut = false;
try {
const timeoutSignal = AbortSignal.timeout(10_000);
const timeoutSignal = AbortSignal.timeout(5_000);
timeoutSignal.addEventListener(
"abort",
() => {
@@ -927,6 +975,18 @@ async function runLspWritethrough(
if (timedOut) {
formatter = undefined;
diagnostics = undefined;
// Schedule background diagnostic fetch if caller wants deferred results
if (deferred && !deferred.signal.aborted && enableDiagnostics) {
void scheduleDeferredDiagnosticsFetch({
dst,
cwd,
servers,
minVersions,
expectedDocumentVersions,
signal: deferred.signal,
callback: deferred.onDeferredDiagnostics,
});
}
}
await getWritePromise();
}
@@ -947,22 +1007,32 @@ async function runLspWritethrough(
async function flushWritethroughBatch(
batch: PendingWritethrough[],
cwd: string,
options: Required<WritethroughOptions>,
options: ResolvedWritethroughOptions,
signal?: AbortSignal,
getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined,
): Promise<FileDiagnosticsResult | undefined> {
if (batch.length === 0) {
return undefined;
}
const results: Array<FileDiagnosticsResult | undefined> = [];
for (const entry of batch) {
results.push(await runLspWritethrough(entry.dst, entry.content, cwd, options, signal, entry.file));
const bundle = getDeferred?.(entry.dst);
const deferredInner =
bundle &&
({
onDeferredDiagnostics: bundle.onDeferredDiagnostics,
signal: bundle.signal,
} as const);
const diag = await runLspWritethrough(entry.dst, entry.content, cwd, options, signal, entry.file, deferredInner);
bundle?.finalize(diag);
results.push(diag);
}
return mergeDiagnostics(results, options);
}
/** Create a writethrough callback for LSP aware write operations */
export function createLspWritethrough(cwd: string, options?: WritethroughOptions): WritethroughCallback {
const resolvedOptions: Required<WritethroughOptions> = {
const resolvedOptions: ResolvedWritethroughOptions = {
enableFormat: options?.enableFormat ?? false,
enableDiagnostics: options?.enableDiagnostics ?? false,
};
@@ -975,9 +1045,19 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions
signal?: AbortSignal,
file?: BunFile,
batch?: LspWritethroughBatchRequest,
getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined,
) => {
if (!batch) {
return runLspWritethrough(dst, content, cwd, resolvedOptions, signal, file);
const bundle = getDeferred?.(dst);
const deferredInner =
bundle &&
({
onDeferredDiagnostics: bundle.onDeferredDiagnostics,
signal: bundle.signal,
} as const);
const diagnostics = await runLspWritethrough(dst, content, cwd, resolvedOptions, signal, file, deferredInner);
bundle?.finalize(diagnostics);
return diagnostics;
}
const state = getOrCreateWritethroughBatch(batch.id, resolvedOptions);
@@ -989,7 +1069,7 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions
}
writethroughBatches.delete(batch.id);
return flushWritethroughBatch(Array.from(state.entries.values()), cwd, state.options, signal);
return flushWritethroughBatch(Array.from(state.entries.values()), cwd, state.options, signal, getDeferred);
};
}
@@ -1054,8 +1134,8 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
// Diagnostics can be batch or single-file - queries all applicable servers
if (action === "diagnostics") {
if (!file) {
// No file specified - run workspace diagnostics
if (file === "*") {
// `*` => run workspace diagnostics across all configured servers
const result = await runWorkspaceDiagnostics(this.session.cwd, signal);
return {
content: [
@@ -1068,6 +1148,18 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
};
}
if (!file) {
return {
content: [
{
type: "text",
text: "Error: file parameter required. Use `*` for workspace-wide diagnostics or a path/glob for specific files.",
},
],
details: { action, success: false, request: params },
};
}
let targets: string[];
let truncatedGlobTargets = false;
if (hasGlobPattern(file)) {
@@ -1186,17 +1278,24 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
};
}
const requiresFile = !file && action !== "symbols" && action !== "reload";
// `*` means workspace scope for symbols/reload; other actions need a concrete file.
const isWorkspace = file === "*";
const requiresFile = !file && action !== "reload";
if (requiresFile) {
return {
content: [{ type: "text", text: "Error: file parameter required for this action" }],
content: [
{
type: "text",
text: "Error: file parameter required. Use `*` for workspace scope where supported.",
},
],
details: { action, success: false },
};
}
const resolvedFile = file ? resolveToCwd(file, this.session.cwd) : null;
if (action === "symbols" && !resolvedFile) {
const resolvedFile = file && !isWorkspace ? resolveToCwd(file, this.session.cwd) : null;
if (action === "symbols" && (isWorkspace || !resolvedFile)) {
const normalizedQuery = query?.trim();
if (!normalizedQuery) {
return {
@@ -1268,7 +1367,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
};
}
if (action === "reload" && !resolvedFile) {
if (action === "reload" && (isWorkspace || !resolvedFile)) {
const servers = getLspServers(config);
if (servers.length === 0) {
return {
+6 -1
View File
@@ -52,6 +52,7 @@ const LANGUAGE_MAP: Record<string, string> = {
".zsh": "shellscript",
".fish": "fish",
".pl": "perl",
".pm": "perl",
".php": "php",
// JVM languages
@@ -76,6 +77,7 @@ const LANGUAGE_MAP: Record<string, string> = {
".less": "less",
".vue": "vue",
".svelte": "svelte",
".astro": "astro",
// Data formats
".json": "json",
@@ -142,12 +144,15 @@ export function detectLanguageId(filePath: string): string {
const basename = path.basename(filePath).toLowerCase();
// Handle special filenames
if (basename === "dockerfile" || basename.startsWith("dockerfile.")) {
if (basename === "dockerfile" || basename.startsWith("dockerfile.") || basename === "containerfile") {
return "dockerfile";
}
if (basename === "makefile" || basename === "gnumakefile") {
return "makefile";
}
if (basename === "justfile") {
return "just";
}
if (basename === "cmakelists.txt" || ext === ".cmake") {
return "cmake";
}