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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user