Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions .projenrc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,10 @@ const cloudAssemblyApi = configureProject(
cloudAssemblySchema.customizeReference({ versionType: 'exact' }),
],
deps: [
// Position-aware JSON parser used to compute character ranges for template
// resource/property blocks. Maintained by the VS Code team, zero transitive
// deps, already resolved at 3.2.0 in the lockfile; stdlib JSON.parse discards offsets.
'jsonc-parser@3.2.0',
'jsonschema@^1.5.0',
'semver',
],
Expand Down
18 changes: 18 additions & 0 deletions packages/@aws-cdk/cdk-explorer/lib/lsp/positions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { OffsetRange } from '@aws-cdk/cloud-assembly-api';
import { type Position, type Range } from 'vscode-languageserver/node';
import { TextDocument } from 'vscode-languageserver-textdocument';

// Character offsets (from the core range resolver) and LSP positions are two
// coordinate systems over the same text. These convert between them at the LSP
// boundary, using TextDocument so UTF-16 column counting matches the protocol.

/** Convert character offsets into an LSP range (0-based lines, UTF-16 columns). */
export function offsetsToRange(text: string, offsets: OffsetRange): Range {
const doc = TextDocument.create('', 'json', 0, text);
return { start: doc.positionAt(offsets.start), end: doc.positionAt(offsets.end) };
}

/** Convert an LSP position into a 0-based character offset. */
export function offsetAtPosition(text: string, position: Position): number {
return TextDocument.create('', 'json', 0, text).offsetAt(position);
}
30 changes: 30 additions & 0 deletions packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { ConstructIndex } from '@aws-cdk/cloud-assembly-api';
Expand All @@ -11,14 +12,18 @@ import {
TextDocumentSyncKind,
type CodeLens,
type CodeLensParams,
type DefinitionParams,
type DidSaveTextDocumentParams,
type Diagnostic,
type InitializeParams,
type InitializeResult,
type Location,
} from 'vscode-languageserver/node';
/* eslint-disable import/no-relative-packages */
import { codeLensesForFile } from './codelens';
import { mapViolationsToDiagnostics } from './diagnostics';
import { offsetAtPosition } from './positions';
import { sourceTargetAtTemplateOffset } from './template-locator';
import { WATCH_EXCLUDE_DEFAULTS } from '../../../toolkit-lib/lib/actions/watch/private/helpers';
import { createIgnoreMatcher } from '../../../toolkit-lib/lib/util/glob-matcher';
import {
Expand Down Expand Up @@ -52,6 +57,7 @@ export interface LspHandlers {
onInitialized(): void;
onDidSaveTextDocument(params: DidSaveTextDocumentParams): void;
onCodeLens(params: CodeLensParams): CodeLens[];
onDefinition(params: DefinitionParams): Location | undefined;
onShutdown(): void;
}

Expand Down Expand Up @@ -130,6 +136,8 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers
},
// Lens title is computed up-front; no resolve round-trip needed.
codeLensProvider: { resolveProvider: false },
// Go-to-definition from a synthesized template back to construct source.
definitionProvider: true,
},
};
},
Expand Down Expand Up @@ -165,6 +173,27 @@ export function createLspHandlers(options: LspHandlerOptions = {}): LspHandlers
onCodeLens(params) {
return codeLensesForFile(cachedIndex, params.textDocument.uri);
},
onDefinition(params) {
// Only synthesized templates link back to source, and only file: URIs are
// readable. Check the scheme before fileURLToPath, which throws on other
// schemes (untitled:, git:, diff views).
const uri = params.textDocument.uri;
if (!uri.startsWith('file:') || !uri.endsWith('.template.json')) {
return undefined;
}
const filePath = fileURLToPath(uri);
let templateText: string;
try {
templateText = fs.readFileSync(filePath, 'utf-8');
} catch {
return undefined;
}
// Offsets come from current disk text; the owner is looked up in the index
// built at startup. If the template was re-synthesized since, a missing
// match degrades to undefined rather than navigating to the wrong place.
const offset = offsetAtPosition(templateText, params.position);
return sourceTargetAtTemplateOffset(cachedIndex, filePath, templateText, offset);
},
onShutdown() {
shutdownRequested = true;
},
Expand All @@ -191,6 +220,7 @@ export function startServer(options: LspServerOptions): void {
connection.onInitialized(() => handlers.onInitialized());
connection.onDidSaveTextDocument((params) => handlers.onDidSaveTextDocument(params));
connection.onCodeLens((params) => handlers.onCodeLens(params));
connection.onDefinition((params) => handlers.onDefinition(params));
connection.onShutdown(() => handlers.onShutdown());
connection.onExit(() => process.exit(0));

Expand Down
70 changes: 47 additions & 23 deletions packages/@aws-cdk/cdk-explorer/lib/lsp/template-locator.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,27 @@
import * as fs from 'fs';
import { pathToFileURL } from 'url';
import { type Position, type Range } from 'vscode-languageserver/node';
import { resolveLogicalIdAtOffset, resolveResourceRange, type ConstructIndex } from '@aws-cdk/cloud-assembly-api';
import { type Location, type Range } from 'vscode-languageserver/node';
import { offsetsToRange } from './positions';
import type { ConstructNode } from '../core/assembly-reader';

/**
* 0-based position of a resource's logical-ID key in its synthesized template.
* A logical ID only appears as a `"<id>":` key in its own definition (every
* Ref/GetAtt/DependsOn occurrence is a string value, never followed by `:`), so
* anchoring on the key selects the definition without a JSON parse. Assumes
* pretty-printed templates (one key per line). Undefined if not found.
* An editor navigation target: the template file and the range to reveal.
* Structurally an LSP `Location`, but kept as a named type because it is
* serialized into the `openResource` CodeLens command's QuickPick arguments
* (see codelens.ts), where it reads as a domain target rather than a protocol type.
*/
function findLogicalIdPosition(templateText: string, logicalId: string): Position | undefined {
const key = `"${logicalId}"`;
const lines = templateText.split('\n');
for (let line = 0; line < lines.length; line++) {
const trimmed = lines[line].trimStart();
if (trimmed.startsWith(key) && trimmed.slice(key.length).trimStart().startsWith(':')) {
return { line, character: lines[line].length - trimmed.length };
}
}
return undefined;
}

/** An editor navigation target: the template file and the position to reveal. */
export interface ResourceTarget {
readonly uri: string;
readonly range: Range;
}

/**
* Resolves a construct node to its CFN resource location for an LSP "go to";
* undefined when not navigable (no template, unreadable, or id not found).
* Resolves a construct node to a CFN resource location for an LSP "go to": the
* range of the resource's block in its synthesized template.
*
* Returns `undefined` when the node is not navigable: no resolved template, the
* template cannot be read, it does not parse, or the logical id is absent.
*/
export function resourceTarget(node: { templateFile?: string; logicalId: string }): ResourceTarget | undefined {
if (node.templateFile === undefined) {
Expand All @@ -41,12 +33,44 @@ export function resourceTarget(node: { templateFile?: string; logicalId: string
} catch {
return undefined;
}
const position = findLogicalIdPosition(templateText, node.logicalId);
if (position === undefined) {
const block = resolveResourceRange(templateText, node.logicalId);
if (block === undefined) {
return undefined;
}
return {
uri: pathToFileURL(node.templateFile).toString(),
range: offsetsToRange(templateText, block),
};
}

/**
* Reverse navigation: resolve a character offset inside a synthesized template to
* the source location of the construct that produced the enclosing resource.
*
* Finds the resource's logical id at `offset`, looks up the owning construct in
* `index` (matched by both `templateFile` and `logicalId`, since logical ids are
* only unique within a template), and returns its source location as a zero-width
* range. Undefined when the offset is not inside a resource, no construct owns it,
* or the construct has no source location (for example a non-TypeScript app).
*/
export function sourceTargetAtTemplateOffset(
index: ConstructIndex<ConstructNode>,
templateFile: string,
templateText: string,
offset: number,
): Location | undefined {
const logicalId = resolveLogicalIdAtOffset(templateText, offset);
if (logicalId === undefined) {
return undefined;
}
const owner = [...index].find((node) => node.logicalId === logicalId && node.templateFile === templateFile);
if (owner?.sourceLocation === undefined) {
return undefined;
}
// SourceLocation is 1-based; LSP positions are 0-based.
const position = { line: owner.sourceLocation.line - 1, character: owner.sourceLocation.column - 1 };
return {
uri: pathToFileURL(owner.sourceLocation.file).toString(),
range: { start: position, end: position },
};
}
44 changes: 32 additions & 12 deletions packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ const node = (overrides: Partial<ConstructNode> & { path: string }): ConstructNo
...overrides,
});

/** Shape of one resource choice carried in the openResource command arguments. */
interface CommandChoice {
label: string;
description: string;
target: { uri: string; range: { start: unknown; end: unknown } };
}

describe('codeLensesForFile', () => {
test('returns no lenses when tree is empty', () => {
expect(codeLensesForFile(ConstructIndex.fromTree([]), URI)).toEqual([]);
Expand Down Expand Up @@ -115,8 +122,15 @@ describe('codeLensesForFile', () => {
}),
];

expect(codeLensesForFile(ConstructIndex.fromTree(tree), URI)).toHaveLength(1);
expect(codeLensesForFile(ConstructIndex.fromTree(tree), OTHER_URI)).toHaveLength(1);
const index = ConstructIndex.fromTree(tree);
// Each query returns only the resource defined in that file, which proves the
// URI filter selects by file rather than returning everything for any query.
const onThisFile = codeLensesForFile(index, URI);
expect(onThisFile).toHaveLength(1);
expect(onThisFile[0].command?.title).toBe('Creates AWS::S3::Bucket');
const onOtherFile = codeLensesForFile(index, OTHER_URI);
expect(onOtherFile).toHaveLength(1);
expect(onOtherFile[0].command?.title).toBe('Creates AWS::SQS::Queue');
});

test('walks descendants — finds resources nested under wrappers', () => {
Expand Down Expand Up @@ -172,14 +186,15 @@ describe('codeLensesForFile', () => {

const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[0];
expect(lens.command?.command).toBe(OPEN_RESOURCE_COMMAND);
expect(lens.command?.arguments).toEqual([[{
const choices = (lens.command!.arguments as CommandChoice[][])[0];
expect(choices).toHaveLength(1);
expect(choices[0]).toMatchObject({
label: 'AWS::S3::Bucket',
description: 'Stack1/MyBucket',
target: {
uri: pathToFileURL(templateFile).toString(),
range: { start: { line: 2, character: 4 }, end: { line: 2, character: 4 } },
},
}]]);
target: { uri: pathToFileURL(templateFile).toString() },
});
// The target carries a real (non-zero-width) block range; exact offsets are covered in template-locator.test.
expect(choices[0].target.range.start).not.toEqual(choices[0].target.range.end);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
Expand All @@ -200,10 +215,15 @@ describe('codeLensesForFile', () => {
const lens = codeLensesForFile(ConstructIndex.fromTree(tree), URI)[0];
const uri = pathToFileURL(templateFile).toString();
expect(lens.command?.command).toBe(OPEN_RESOURCE_COMMAND);
expect(lens.command?.arguments).toEqual([[
{ label: 'AWS::S3::Bucket', description: 'Stack1/B', target: { uri, range: { start: { line: 2, character: 4 }, end: { line: 2, character: 4 } } } },
{ label: 'AWS::S3::BucketPolicy', description: 'Stack1/B/Policy', target: { uri, range: { start: { line: 5, character: 4 }, end: { line: 5, character: 4 } } } },
]]);
const choices = (lens.command!.arguments as CommandChoice[][])[0];
expect(choices).toMatchObject([
{ label: 'AWS::S3::Bucket', description: 'Stack1/B', target: { uri } },
{ label: 'AWS::S3::BucketPolicy', description: 'Stack1/B/Policy', target: { uri } },
]);
// Each carries a real span, and the two resources resolve to distinct blocks.
expect(choices[0].target.range.start).not.toEqual(choices[0].target.range.end);
expect(choices[1].target.range.start).not.toEqual(choices[1].target.range.end);
expect(choices[0].target.range).not.toEqual(choices[1].target.range);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
Expand Down
65 changes: 64 additions & 1 deletion packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { pathToFileURL } from 'url';
import type { Diagnostic } from 'vscode-languageserver/node';
import { TextDocument } from 'vscode-languageserver-textdocument';
import type { AssemblyReadResult } from '../../lib';
import { createLspHandlers, type LspHandlerOptions, type LspHandlers } from '../../lib/lsp/server';

Expand Down Expand Up @@ -34,7 +38,7 @@ function initializeClient(client: CapturedClient, options?: Record<string, unkno
}

describe('LSP Server', () => {
test('responds to initialize with capabilities', () => {
test('initialize advertises codeLens, definition, and save-sync capabilities', () => {
const client = createTestClient();

const result = client.handlers.onInitialize({
Expand All @@ -52,6 +56,7 @@ describe('LSP Server', () => {
save: { includeText: false },
},
codeLensProvider: { resolveProvider: false },
definitionProvider: true,
},
});
});
Expand Down Expand Up @@ -223,4 +228,62 @@ describe('LSP Server', () => {

expect(client.published).toHaveLength(0);
});

test('onDefinition resolves a template position back to construct source', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'server-def-'));
const templateFile = path.join(dir, 'Stack1.template.json');
const text = JSON.stringify({ Resources: { MyBucket: { Type: 'AWS::S3::Bucket' } } }, undefined, 1);
fs.writeFileSync(templateFile, text);
try {
const client = createTestClient({
readAssembly: () => ({
status: 'success',
data: {
tree: [{
path: 'Stack1/MyBucket/Resource',
id: 'Resource',
logicalId: 'MyBucket',
type: 'AWS::S3::Bucket',
templateFile,
sourceLocation: { file: '/p/lib/stack.ts', line: 5, column: 3 },
children: [],
}],
violations: [],
warnings: [],
},
}),
});
initializeClient(client, { applicationDir: dir });

const uri = pathToFileURL(templateFile).toString();
const position = TextDocument.create(uri, 'json', 0, text).positionAt(text.indexOf('AWS::S3::Bucket'));
const target = client.handlers.onDefinition({ textDocument: { uri }, position });

expect(target?.uri).toBe(pathToFileURL('/p/lib/stack.ts').toString());
expect(target?.range.start).toEqual({ line: 4, character: 2 }); // 1-based (5,3) -> 0-based
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

test('onDefinition returns undefined for a non-template document', () => {
const client = createTestClient();
initializeClient(client, { applicationDir: '/p' });
const target = client.handlers.onDefinition({
textDocument: { uri: pathToFileURL('/p/lib/stack.ts').toString() },
position: { line: 0, character: 0 },
});
expect(target).toBeUndefined();
});

test('onDefinition returns undefined (does not throw) for a non-file URI', () => {
const client = createTestClient();
initializeClient(client, { applicationDir: '/p' });
expect(
client.handlers.onDefinition({
textDocument: { uri: 'untitled:Untitled-1' },
position: { line: 0, character: 0 },
}),
).toBeUndefined();
});
});
Loading
Loading