Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1f865fd
feat(cdk-explorer): add LspIoHost adapter for the Toolkit
megha-narayanan Jun 16, 2026
332ddb9
feat(cdk-explorer): add cdk-config reader
megha-narayanan Jun 16, 2026
8aa4813
feat(cdk-explorer): add synth-runner with typed outcome
megha-narayanan Jun 16, 2026
0c48235
feat(cdk-explorer): command dispatcher for synthNow and refresh
megha-narayanan Jun 16, 2026
3dc68d2
feat(cdk-explorer): wire executeCommandProvider and onExecuteCommand …
megha-narayanan Jun 16, 2026
2efedca
feat(cdk-explorer): prepend Synth now and Refresh header lenses on CD…
megha-narayanan Jun 16, 2026
fa30f8a
refactor(cdk-explorer): drop Refresh command, add auto-synth-on-save
megha-narayanan Jun 16, 2026
2b5a7f2
feat(cdk-explorer): wire Toolkit, LspIoHost, and synth runner in main.ts
megha-narayanan Jun 16, 2026
ee40944
feat(cdk-explorer): add auto-synth toggle with Enable/Disable CodeLens
megha-narayanan Jun 16, 2026
9ab7df6
fix(cdk-explorer): gate toggle refresh on codeLensRefreshSupport, add…
megha-narayanan Jun 16, 2026
91f1405
docs(cdk-explorer): clarify NonInteractiveIoHost exclusion and lock-c…
megha-narayanan Jun 16, 2026
020a03c
fix(cdk-explorer): catch unexpected guardedSynth rejection; document …
megha-narayanan Jun 16, 2026
8a26f7e
docs(cdk-explorer): fix stale comments and JSDoc after code review
megha-narayanan Jun 16, 2026
bd8a9c0
refactor(cdk-explorer): simplify synth option wiring
megha-narayanan Jun 17, 2026
418a373
refactor(cdk-explorer): name the synth runner factory; clarify suppre…
megha-narayanan Jun 17, 2026
b23b1b4
refactor(cdk-explorer): trim over-explained dispose-failure comment
megha-narayanan Jun 17, 2026
6fae0f5
feat(toolkit-lib): add LockError subclass and ToolkitError.isLockError
megha-narayanan Jun 17, 2026
c56f121
feat(cdk-explorer): log the auto-answered prompt response in LspIoHost
megha-narayanan Jun 17, 2026
8ba9593
feat(toolkit-lib): add ContextLookupsDisabledError; LSP synth runs wi…
megha-narayanan Jun 18, 2026
cdcae1a
feat(cdk-explorer): surface synth failures as LSP diagnostics
megha-narayanan Jun 18, 2026
316aa98
fix(cdk-explorer): read cdk.json app per synth, not once at startup
megha-narayanan Jun 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/@aws-cdk/cdk-explorer/lib/core/cdk-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as fs from 'fs';
import * as path from 'path';

/**
* The subset of `cdk.json` the explorer cares about.
*
* `app` is the command CDK runs to produce a cloud assembly (e.g.
* `npx ts-node bin/app.ts`). We need it to invoke `Toolkit.synth()`
* via `fromCdkApp`.
*/
export interface CdkConfig {
/** The `app` command, or `undefined` if missing/malformed. */
readonly app: string | undefined;
}

/**
* Reads `<projectDir>/cdk.json` and returns the parts the explorer uses.
* Never throws. Treats missing files, malformed JSON, or wrong-typed
* fields as "not configured" so callers can fall back gracefully
*/
export function readCdkConfig(projectDir: string): CdkConfig {
const configPath = path.join(projectDir, 'cdk.json');
if (!fs.existsSync(configPath)) return { app: undefined };

let parsed: unknown;
try {
parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
} catch {
return { app: undefined };
}

if (parsed === null || typeof parsed !== 'object') return { app: undefined };
const app = (parsed as { app?: unknown }).app;
return { app: typeof app === 'string' ? app : undefined };
}
85 changes: 85 additions & 0 deletions packages/@aws-cdk/cdk-explorer/lib/core/synth-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { ToolkitError, type Toolkit } from '@aws-cdk/toolkit-lib';
import { readCdkConfig } from './cdk-config';

/**
* The outcome of a single synth attempt.
*
* `success` means the assembly was written to disk (the watcher will see it).
* `app-failure` means the user's CDK app threw, did not compile, or needs uncached context lookups.
* `lock-conflict` means another process holds `<projectDir>/cdk.out` (a `cdk
* synth` running in a terminal, a `cdk watch` loop, or our own previous synth
* not yet released). Callers should not surface this as a hard error.
* `unavailable` means `cdk.json` is missing or has no `app` key, so there is
* nothing to synth. Read fresh on every call, so adding an `app` later is
* picked up without restarting the LSP.
* `error` is reserved for anything we did not classify, including failures
* during dispose.
*/
export type SynthRunResult =
| { status: 'success' }
| { status: 'app-failure'; message: string; details?: string }
| { status: 'lock-conflict' }
| { status: 'unavailable' }
| { status: 'error'; message: string };

export interface SynthRunnerOptions {
/** A configured Toolkit instance (its IoHost decides where messages go). */
readonly toolkit: Toolkit;
/** Directory containing the user's `cdk.json`; also the synth working dir. */
readonly projectDir: string;
}

/**
* Run a one-shot synth of the user's CDK app. Writes `<projectDir>/cdk.out`
* via `Toolkit.synth(fromCdkApp(...))`, then immediately disposes the cached
* assembly so the read lock is released before the next call. Holding the
* cached assembly between calls would cause the next acquireWrite to throw
* `ConcurrentReadLock` against ourselves.
*
* The `app` command is read from `cdk.json` on every call, not cached, so an
* edited command or a newly added `app` takes effect on the next synth.
*/
export async function runSynth(options: SynthRunnerOptions): Promise<SynthRunResult> {
const app = readCdkConfig(options.projectDir).app;
if (app === undefined) return { status: 'unavailable' };

let cached;
try {
const cx = await options.toolkit.fromCdkApp(app, {
workingDirectory: options.projectDir,
lookups: false,
});
cached = await options.toolkit.synth(cx);
} catch (err) {
return classify(err);
}

try {
await cached.dispose();
} catch (err) {
// Releases the read lock synth() left on the assembly. Failure is rare (an
// fs error deleting the lock file); report it as `error` so the next synth
// does not silently self-conflict on the stale reader.
return { status: 'error', message: (err as Error).message };
}

return { status: 'success' };
}

function classify(err: unknown): SynthRunResult {
if (ToolkitError.isLockError(err)) {
return { status: 'lock-conflict' };
}
if (ToolkitError.isContextLookupsDisabledError(err)) {
return {
status: 'app-failure',
message: 'This app needs context lookups that are not in cdk.context.json. '
+ 'Run `cdk synth` in a terminal (with AWS credentials) to populate it, then retry.',
};
}
if (ToolkitError.isAssemblyError(err)) {
// details = captured subprocess stderr (file:line:col), used for diagnostics.
return { status: 'app-failure', message: err.message, details: (err.cause as Error | undefined)?.message };
}
return { status: 'error', message: (err as Error).message };
}
38 changes: 31 additions & 7 deletions packages/@aws-cdk/cdk-explorer/lib/lsp/codelens.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { pathToFileURL } from 'url';
import type { ConstructIndex } from '@aws-cdk/cloud-assembly-api';
import { type CodeLens, type Command, type Range } from 'vscode-languageserver/node';
import { COMMAND_DISABLE_AUTO_SYNTH, COMMAND_ENABLE_AUTO_SYNTH, COMMAND_SYNTH_NOW } from './commands';
import { resourceTarget, type ResourceTarget } from './template-locator';
import type { ConstructNode } from '../core/assembly-reader';
import type { SourceLocation } from '../core/source-resolver';
Expand All @@ -9,21 +10,44 @@ import type { SourceLocation } from '../core/source-resolver';
export const OPEN_RESOURCE_COMMAND = 'cdkExplorer.openResource';

/**
* Build CodeLens entries for a single source file. For every construct whose
* sourceLocation matches fileUri, group by line and emit one lens per line
* summarising the CFN resources produced there.
* Build CodeLens entries for a single source file. Returns an empty array if
* no CDK resources in the index map to `fileUri`.
*
* When resources are found, two header lenses are prepended at line 0:
* - `autoSynthEnabled = false`: "↻ Synth now" + "▶ Enable auto-synth"
* - `autoSynthEnabled = true`: "⏹ Disable auto-synth" (saves trigger synth)
*
* The remaining lenses are one per source line, each summarising the CFN
* resources produced there (multiple L2 fan-out resources are grouped).
*
* @param autoSynthEnabled - current toggle state; controls which header lenses appear
*/
export function codeLensesForFile(index: ConstructIndex<ConstructNode>, fileUri: string): CodeLens[] {
export function codeLensesForFile(
index: ConstructIndex<ConstructNode>,
fileUri: string,
autoSynthEnabled: boolean,
): CodeLens[] {
const matches = [...index]
.filter((node) => isResourceOnFile(node, fileUri))
.map((node) => ({ line: node.sourceLocation.line, node }));

// Multiple resources can map to one line when an L2 construct fans out
// (e.g. an L2 producing a primary resource + auxiliary resources).
return [...groupBy(matches, (m) => m.line)].map(([line, group]) => ({
const l1Lenses = [...groupBy(matches, (m) => m.line)].map(([line, group]) => ({
range: lineRange(line),
command: commandFor(group.map((m) => m.node)),
}));

if (l1Lenses.length === 0) return [];

const header0: Range = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } };
// When auto-synth is off, show "Synth now" + "Enable auto-synth".
// When auto-synth is on, show only "Disable auto-synth" (saves handle synth).
const headerLenses: CodeLens[] = autoSynthEnabled
? [{ range: header0, command: { title: '⏹ Disable auto-synth', command: COMMAND_DISABLE_AUTO_SYNTH } }]
: [
{ range: header0, command: { title: '↻ Synth now', command: COMMAND_SYNTH_NOW } },
{ range: header0, command: { title: '▶ Enable auto-synth', command: COMMAND_ENABLE_AUTO_SYNTH } },
];
return [...headerLenses, ...l1Lenses];
}

/**
Expand Down
94 changes: 94 additions & 0 deletions packages/@aws-cdk/cdk-explorer/lib/lsp/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { SynthRunResult } from '../core/synth-runner';

/**
* Trigger a one-shot synth of the user's CDK app. Only shown when
* auto-synth is disabled (saves handle synth when it is enabled).
*/
export const COMMAND_SYNTH_NOW = 'cdk.explorer.synthNow';
/** Enable auto-synth-on-save. Replaces "Synth now" in the header lens. */
export const COMMAND_ENABLE_AUTO_SYNTH = 'cdk.explorer.enableAutoSynth';
/** Disable auto-synth-on-save. Restores the "Synth now" header lens. */
export const COMMAND_DISABLE_AUTO_SYNTH = 'cdk.explorer.disableAutoSynth';

/** All commands this LSP advertises via `executeCommandProvider`. */
export const SUPPORTED_COMMANDS = [COMMAND_SYNTH_NOW, COMMAND_ENABLE_AUTO_SYNTH, COMMAND_DISABLE_AUTO_SYNTH] as const;

/**
* UI sinks the dispatcher uses to communicate with the user.
* Implementations bridge to `connection.console` in the LSP layer.
*/
export interface NotifySink {
/** Write a non-error informational message to the Output panel. */
info(message: string): void;
/** Write an error message to the Output panel. */
error(message: string): void;
/**
* Run a long operation with a visible progress indicator. The implementation
* is responsible for ending the indicator regardless of success or failure.
*/
withProgress<T>(message: string, fn: () => Promise<T>): Promise<T>;
}

export interface CommandHandlerOptions {
/** Invokes a single synth. Resolves with the typed outcome; never rejects. */
readonly synth: () => Promise<SynthRunResult>;
/** Called with the new desired state when the user toggles auto-synth. */
readonly toggleAutoSynth: (enabled: boolean) => void;
/** UI sinks for messages and progress. */
readonly notify: NotifySink;
}

const SYNTH_UNAVAILABLE_MESSAGE = "CDK synth unavailable: 'cdk.json' missing or has no 'app' key.";
const LOCK_CONFLICT_MESSAGE = 'Another synth is in progress. Results will refresh shortly.';
const PROGRESS_MESSAGE = 'Synthesizing CDK app...';

/**
* Handle a `workspace/executeCommand` request. The synth command runs under a
* progress indicator and reports outcomes through the notify sinks. Unknown
* commands are silently ignored.
*/
export async function executeCommand(
command: string,
_args: unknown[],
options: CommandHandlerOptions,
): Promise<void> {
switch (command) {
case COMMAND_ENABLE_AUTO_SYNTH:
options.toggleAutoSynth(true);
return;

case COMMAND_DISABLE_AUTO_SYNTH:
options.toggleAutoSynth(false);
return;

case COMMAND_SYNTH_NOW:
{
const result = await options.notify.withProgress(PROGRESS_MESSAGE, () => options.synth());
handleSynthResult(result, options.notify);
}
return;

default:
return;
}
}

function handleSynthResult(result: SynthRunResult, notify: NotifySink): void {
switch (result.status) {
case 'success':
// Silent. The watcher refreshes the editor when `cdk.out` changes.
return;
case 'app-failure':
notify.error(`CDK synth failed: ${result.message}`);
return;
case 'lock-conflict':
notify.info(LOCK_CONFLICT_MESSAGE);
return;
case 'unavailable':
notify.info(SYNTH_UNAVAILABLE_MESSAGE);
return;
case 'error':
notify.error(`CDK synth failed unexpectedly: ${result.message}`);
return;
}
}
46 changes: 46 additions & 0 deletions packages/@aws-cdk/cdk-explorer/lib/lsp/io-host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { IIoHost, IoMessage, IoRequest } from '@aws-cdk/toolkit-lib';
import type { RemoteConsole } from 'vscode-languageserver/node';

/**
* IoHost for the LSP. Routes Toolkit messages into the editor's Output
* channel via the LSP connection's console.
*
* We do NOT reuse `NonInteractiveIoHost` from toolkit-lib even though its
* `requestResponse` is identical: that class writes to `process.stdout` /
* `process.stderr`, which are the JSON-RPC transport for this process.
* Writing Toolkit output there would corrupt the protocol stream.
*
* The LSP cannot prompt the user synchronously through `connection.console`,
* so `requestResponse` returns each message's `defaultResponse`. For synth,
* the only reachable interactive prompt is an MFA token (when the app has
* context lookups, no cached `cdk.context.json`, and an MFA-protected profile).
* Returning the default causes an auth failure, surfaced as `app-failure` with
* a clear message. All other prompts are on deploy/destroy paths we don't call.
*/
export class LspIoHost implements IIoHost {
public constructor(private readonly console: RemoteConsole) {
}

public async notify(msg: IoMessage<unknown>): Promise<void> {
switch (msg.level) {
case 'error':
this.console.error(msg.message);
break;
case 'warn':
this.console.warn(msg.message);
break;
case 'debug':
case 'trace':
// Suppress noisy levels; keeps the Output panel readable.
Comment on lines +32 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't that panel have its own verbosity level filtering?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some clients do, some don't, so I didn't want to rely on it. connection.console emits window/logMessage with a MessageType. New VS Code versions route that into a channel with user-selectable levels, but older versions and several non-VS Code clients write everything to a plain channel with no level filter. trace is also a separate channel, not a logMessage level. So I kept the suppression to keep the Output panel readable regardless of client.

If you'd rather defer to the client, lmk. Happy to go either way.

break;
default:
this.console.info(msg.message);
}
}

public async requestResponse<T>(msg: IoRequest<unknown, T>): Promise<T> {
await this.notify(msg);
this.console.info(`Auto-answered with default response: ${JSON.stringify(msg.defaultResponse)}`);
return msg.defaultResponse;
}
}
12 changes: 12 additions & 0 deletions packages/@aws-cdk/cdk-explorer/lib/lsp/main.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import { Toolkit } from '@aws-cdk/toolkit-lib';
import { LspIoHost } from './io-host';
import { startServer } from './server';
import { runSynth } from '../core/synth-runner';

try {
startServer({
readable: process.stdin,
writable: process.stdout,
// synthRunnerFactory: startServer invokes it once, after the LSP connection
// exists, so the runner it returns can route Toolkit output to the editor's
// Output panel via connection.console. The handler passes the resolved
// project root on each call; the runner reads that project's cdk.json `app`
// per synth, so it is always built and "no app" is reported per call.
synthRunnerFactory: (console) => {
const toolkit = new Toolkit({ ioHost: new LspIoHost(console) });
return (projectDir) => runSynth({ toolkit, projectDir });
},
});
} catch (err) {
const e = err as Error;
Expand Down
Loading
Loading