From e93bc0db3cf8e0335f3375db1cbf0d046b2a50cc Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Fri, 12 Jun 2026 13:33:24 -0400 Subject: [PATCH 1/2] chore(cloud-assembly-api): add jsonc-parser runtime dependency Adds jsonc-parser@3.2.0 as a runtime dep, in preparation for computing character ranges of template resource/property blocks (PR-A). Dedupes to the jsonc-parser already resolved in the lockfile; no new version added. --- .projenrc.ts | 4 ++++ packages/@aws-cdk/cloud-assembly-api/.projen/deps.json | 5 +++++ packages/@aws-cdk/cloud-assembly-api/package.json | 1 + yarn.lock | 1 + 4 files changed, 11 insertions(+) diff --git a/.projenrc.ts b/.projenrc.ts index d600805f4..7c4cca84e 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -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', ], diff --git a/packages/@aws-cdk/cloud-assembly-api/.projen/deps.json b/packages/@aws-cdk/cloud-assembly-api/.projen/deps.json index b13424894..4c9511350 100644 --- a/packages/@aws-cdk/cloud-assembly-api/.projen/deps.json +++ b/packages/@aws-cdk/cloud-assembly-api/.projen/deps.json @@ -110,6 +110,11 @@ "name": "@aws-cdk/cloud-assembly-schema", "type": "peer" }, + { + "name": "jsonc-parser", + "version": "3.2.0", + "type": "runtime" + }, { "name": "jsonschema", "version": "^1.5.0", diff --git a/packages/@aws-cdk/cloud-assembly-api/package.json b/packages/@aws-cdk/cloud-assembly-api/package.json index d139634c0..123469fd6 100644 --- a/packages/@aws-cdk/cloud-assembly-api/package.json +++ b/packages/@aws-cdk/cloud-assembly-api/package.json @@ -60,6 +60,7 @@ "@aws-cdk/cloud-assembly-schema": "^0.0.0" }, "dependencies": { + "jsonc-parser": "3.2.0", "jsonschema": "^1.5.0", "semver": "^7.8.1" }, diff --git a/yarn.lock b/yarn.lock index 4a352da58..73ddf84f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -263,6 +263,7 @@ __metadata: eslint-plugin-prettier: "npm:^4.2.5" jest: "npm:^29.7.0" jest-junit: "npm:^16" + jsonc-parser: "npm:3.2.0" jsonschema: "npm:^1.5.0" license-checker: "npm:^25.0.1" nx: "npm:^22.7.5" From b828ad983a378c1331ab61c1e6625139ba615d67 Mon Sep 17 00:00:00 2001 From: megha-narayanan Date: Fri, 12 Jun 2026 15:50:26 -0400 Subject: [PATCH 2/2] feat(cdk-explorer): two-way template and source navigation via template ranges Compute character-accurate ranges of CloudFormation resource blocks in synthesized templates and use them for navigation in both directions. cloud-assembly-api: - resolveResourceRange(text, logicalId): the character range of a resource's value block, via jsonc-parser. A position-aware parse is used instead of a line scan because real templates contain literal braces and escaped quotes inside string values (for example Fn::Sub placeholders) that defeat naive brace matching. - resolveLogicalIdAtOffset(text, offset): the inverse, mapping a position in a template back to the enclosing resource's logical id. cdk-explorer LSP: - resourceTarget now returns the real resource block range (previously a zero-width cursor at the logical-id key), so the CodeLens "go to" selects the whole block. - onDefinition: go-to-definition from a synthesized template back to the construct's source, keyed by (templateFile, logicalId) since logical ids are only unique within a template. - offset and position conversions extracted to lib/lsp/positions.ts. The jsonc-parser runtime dependency is added in the preceding commit. --- .../cdk-explorer/lib/lsp/positions.ts | 18 ++ .../@aws-cdk/cdk-explorer/lib/lsp/server.ts | 30 +++ .../cdk-explorer/lib/lsp/template-locator.ts | 70 ++++--- .../cdk-explorer/test/lsp/codelens.test.ts | 44 +++-- .../cdk-explorer/test/lsp/server.test.ts | 65 ++++++- .../test/lsp/template-locator.test.ts | 178 +++++++++++++----- .../@aws-cdk/cloud-assembly-api/lib/index.ts | 1 + .../cloud-assembly-api/lib/template-ranges.ts | 67 +++++++ .../test/template-ranges.test.ts | 117 ++++++++++++ 9 files changed, 507 insertions(+), 83 deletions(-) create mode 100644 packages/@aws-cdk/cdk-explorer/lib/lsp/positions.ts create mode 100644 packages/@aws-cdk/cloud-assembly-api/lib/template-ranges.ts create mode 100644 packages/@aws-cdk/cloud-assembly-api/test/template-ranges.test.ts diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/positions.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/positions.ts new file mode 100644 index 000000000..a36151aec --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/positions.ts @@ -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); +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts index 006188ac0..8d081b6f5 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts @@ -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'; @@ -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 { @@ -52,6 +57,7 @@ export interface LspHandlers { onInitialized(): void; onDidSaveTextDocument(params: DidSaveTextDocumentParams): void; onCodeLens(params: CodeLensParams): CodeLens[]; + onDefinition(params: DefinitionParams): Location | undefined; onShutdown(): void; } @@ -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, }, }; }, @@ -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; }, @@ -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)); diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/template-locator.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/template-locator.ts index 10ab1f6ce..d97518ee8 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/template-locator.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/template-locator.ts @@ -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 `"":` 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) { @@ -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, + 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 }, }; } diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts index f7c997a0f..c33e0c597 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/codelens.test.ts @@ -16,6 +16,13 @@ const node = (overrides: Partial & { 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([]); @@ -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', () => { @@ -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 }); } @@ -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 }); } diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts index 9112068bf..51a4e31ea 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/server.test.ts @@ -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'; @@ -34,7 +38,7 @@ function initializeClient(client: CapturedClient, options?: Record { - test('responds to initialize with capabilities', () => { + test('initialize advertises codeLens, definition, and save-sync capabilities', () => { const client = createTestClient(); const result = client.handlers.onInitialize({ @@ -52,6 +56,7 @@ describe('LSP Server', () => { save: { includeText: false }, }, codeLensProvider: { resolveProvider: false }, + definitionProvider: true, }, }); }); @@ -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(); + }); }); diff --git a/packages/@aws-cdk/cdk-explorer/test/lsp/template-locator.test.ts b/packages/@aws-cdk/cdk-explorer/test/lsp/template-locator.test.ts index c664e1463..80a4cd461 100644 --- a/packages/@aws-cdk/cdk-explorer/test/lsp/template-locator.test.ts +++ b/packages/@aws-cdk/cdk-explorer/test/lsp/template-locator.test.ts @@ -2,28 +2,43 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { pathToFileURL } from 'url'; +import { ConstructIndex } from '@aws-cdk/cloud-assembly-api'; +import { type Range } from 'vscode-languageserver/node'; +import { TextDocument } from 'vscode-languageserver-textdocument'; import { readAssembly, type ConstructNode } from '../../lib'; -import { resourceTarget } from '../../lib/lsp/template-locator'; +import { resourceTarget, sourceTargetAtTemplateOffset } from '../../lib/lsp/template-locator'; import { buildFlatAssembly, cleanupFixture } from '../_fixtures/builders'; // A synthesized-style template where MyBucketF68F3FF0 is defined once and also // referenced (Ref + DependsOn) -- the references must not be matched. -const TEMPLATE = [ - '{', - ' "Resources": {', - ' "MyBucketF68F3FF0": {', - ' "Type": "AWS::S3::Bucket"', - ' },', - ' "MyPolicy3A1B2C3D": {', - ' "Type": "AWS::IAM::Policy",', - ' "Properties": {', - ' "Bucket": { "Ref": "MyBucketF68F3FF0" }', - ' },', - ' "DependsOn": [ "MyBucketF68F3FF0" ]', - ' }', - ' }', - '}', -].join('\n'); +const TEMPLATE = JSON.stringify( + { + Resources: { + MyBucketF68F3FF0: { Type: 'AWS::S3::Bucket' }, + MyPolicy3A1B2C3D: { + Type: 'AWS::IAM::Policy', + Properties: { Bucket: { Ref: 'MyBucketF68F3FF0' } }, + DependsOn: ['MyBucketF68F3FF0'], + }, + }, + }, + undefined, + 1, +); + +/** Re-parse the substring that a returned range selects, so assertions need no hand-computed offsets. */ +function sliceParse(text: string, range: Range): unknown { + const doc = TextDocument.create('', 'json', 0, text); + return JSON.parse(text.slice(doc.offsetAt(range.start), doc.offsetAt(range.end))); +} + +/** Write a template to a throwaway file and return its path. */ +function writeTemplate(contents: string): { dir: string; file: string } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'locator-')); + const file = path.join(dir, 'T.template.json'); + fs.writeFileSync(file, contents); + return { dir, file }; +} describe('resourceTarget', () => { let dir: string | undefined; @@ -42,18 +57,21 @@ describe('resourceTarget', () => { return undefined; }; - test('resolves a resource node to its template uri and key position', () => { + test('resolves a resource node to its template uri and block range', () => { dir = buildFlatAssembly({ stacks: [{ id: 'Stack1', resources: [{ id: 'MyBucket', logicalId: 'MyBucketF68F3FF0', cfnType: 'AWS::S3::Bucket' }] }], }); const result = readAssembly(dir); if (result.status !== 'success') throw new Error('expected success'); const node = find(result.data.tree, 'Stack1/MyBucket/Resource')!; + const templateFile = path.join(dir!, 'Stack1.template.json'); - const target = resourceTarget(node); - expect(target?.uri).toBe(pathToFileURL(path.join(dir!, 'Stack1.template.json')).toString()); - // 2-space indented template: the key sits on line 2, opening quote at char 4. - expect(target?.range).toEqual({ start: { line: 2, character: 4 }, end: { line: 2, character: 4 } }); + const target = resourceTarget(node)!; + expect(target.uri).toBe(pathToFileURL(templateFile).toString()); + // The range spans the resource block (not a zero-width cursor) and re-parses to it. + const text = fs.readFileSync(templateFile, 'utf-8'); + expect(target.range.start).not.toEqual(target.range.end); + expect(sliceParse(text, target.range)).toEqual(JSON.parse(text).Resources.MyBucketF68F3FF0); }); test('returns undefined for a node without a resolved templateFile', () => { @@ -71,33 +89,99 @@ describe('resourceTarget', () => { expect(resourceTarget({ templateFile: '/no/such/template.json', logicalId: 'MyBucketF68F3FF0' })).toBeUndefined(); }); - test('resolves to the definition key, not Ref/DependsOn occurrences of the same id', () => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'locator-')); - const file = path.join(dir, 'T.template.json'); - fs.writeFileSync(file, TEMPLATE); - // MyBucketF68F3FF0 is defined on line 2 and also referenced (Ref line 8, - // DependsOn line 10); only the definition is a `"":` key, so line 2 wins. - expect(resourceTarget({ templateFile: file, logicalId: 'MyBucketF68F3FF0' })?.range.start) - .toEqual({ line: 2, character: 4 }); + test('resolves the definition block, not Ref/DependsOn occurrences of the same id', () => { + const written = writeTemplate(TEMPLATE); + dir = written.dir; + // MyBucketF68F3FF0 is defined once and also referenced; the block must be the definition. + const target = resourceTarget({ templateFile: written.file, logicalId: 'MyBucketF68F3FF0' })!; + expect(sliceParse(TEMPLATE, target.range)).toEqual({ Type: 'AWS::S3::Bucket' }); }); test('does not match a logical id that is a prefix of a longer key', () => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'locator-')); - const file = path.join(dir, 'T.template.json'); - fs.writeFileSync(file, [ - '{', - ' "Resources": {', - ' "MyBucketF68F3FF0": {', - ' "Type": "AWS::S3::Bucket"', - ' },', - ' "MyBucket": {', - ' "Type": "AWS::S3::Bucket"', - ' }', - ' }', - '}', - ].join('\n')); - // The closing quote in the match key stops "MyBucket" matching "MyBucketF68F3FF0": - expect(resourceTarget({ templateFile: file, logicalId: 'MyBucket' })?.range.start) - .toEqual({ line: 5, character: 4 }); + const contents = JSON.stringify( + { + Resources: { + MyBucketF68F3FF0: { Type: 'AWS::S3::Bucket' }, + MyBucket: { Type: 'AWS::S3::Bucket', Properties: { BucketName: 'short' } }, + }, + }, + undefined, + 1, + ); + const written = writeTemplate(contents); + dir = written.dir; + // The shorter id must resolve to its own (distinct) block, not the longer-named sibling. + const target = resourceTarget({ templateFile: written.file, logicalId: 'MyBucket' })!; + expect(sliceParse(contents, target.range)).toEqual({ Type: 'AWS::S3::Bucket', Properties: { BucketName: 'short' } }); + }); +}); + +describe('sourceTargetAtTemplateOffset', () => { + let dir: string | undefined; + afterEach(() => { + cleanupFixture(dir); + dir = undefined; + }); + + const indexWith = (overrides: Partial & { path: string }) => + ConstructIndex.fromTree([{ id: overrides.path.split('/').pop()!, children: [], ...overrides }]); + + test('resolves a template offset back to the construct source location', () => { + const contents = JSON.stringify( + { Resources: { MyBucket: { Type: 'AWS::S3::Bucket', Properties: { BucketName: 'b' } } } }, + undefined, + 1, + ); + const written = writeTemplate(contents); + dir = written.dir; + const index = indexWith({ + path: 'Stack1/MyBucket/Resource', + logicalId: 'MyBucket', + type: 'AWS::S3::Bucket', + templateFile: written.file, + sourceLocation: { file: '/p/lib/stack.ts', line: 5, column: 3 }, + }); + + const offset = contents.indexOf('AWS::S3::Bucket'); // inside the MyBucket block + const target = sourceTargetAtTemplateOffset(index, written.file, contents, offset)!; + expect(target.uri).toBe(pathToFileURL('/p/lib/stack.ts').toString()); + // 1-based source location (5, 3) maps to a 0-based LSP position (4, 2). + expect(target.range).toEqual({ start: { line: 4, character: 2 }, end: { line: 4, character: 2 } }); + }); + + test('returns undefined for an offset outside any resource block', () => { + const contents = JSON.stringify({ Resources: { MyBucket: { Type: 'AWS::S3::Bucket' } } }, undefined, 1); + const index = indexWith({ + path: 'Stack1/MyBucket/Resource', + logicalId: 'MyBucket', + templateFile: '/x/T.template.json', + sourceLocation: { file: '/p/lib/stack.ts', line: 5, column: 3 }, + }); + expect(sourceTargetAtTemplateOffset(index, '/x/T.template.json', contents, 0)).toBeUndefined(); + }); + + test('returns undefined when the owning construct has no source location', () => { + const contents = JSON.stringify({ Resources: { MyBucket: { Type: 'AWS::S3::Bucket' } } }, undefined, 1); + const offset = contents.indexOf('AWS::S3::Bucket'); + const index = indexWith({ path: 'Stack1/MyBucket/Resource', logicalId: 'MyBucket', templateFile: '/x/T.template.json' }); + expect(sourceTargetAtTemplateOffset(index, '/x/T.template.json', contents, offset)).toBeUndefined(); + }); + + test('matches on templateFile, not logical id alone (cross-template collision)', () => { + const contents = JSON.stringify({ Resources: { MyBucket: { Type: 'AWS::S3::Bucket' } } }, undefined, 1); + const written = writeTemplate(contents); + dir = written.dir; + // Two constructs share the logical id 'MyBucket' across different templates; + // logical ids are only unique within a template, so the lookup must also key + // on templateFile. + const index = ConstructIndex.fromTree([ + { path: 'StackA/MyBucket/Resource', id: 'Resource', logicalId: 'MyBucket', templateFile: written.file, sourceLocation: { file: '/p/a.ts', line: 2, column: 1 }, children: [] }, + { path: 'StackB/MyBucket/Resource', id: 'Resource', logicalId: 'MyBucket', templateFile: '/other/Stack2.template.json', sourceLocation: { file: '/p/b.ts', line: 9, column: 1 }, children: [] }, + ]); + + const target = sourceTargetAtTemplateOffset(index, written.file, contents, contents.indexOf('AWS::S3::Bucket'))!; + // Resolves to template A's construct (a.ts), not B's, despite the shared id. + expect(target.uri).toBe(pathToFileURL('/p/a.ts').toString()); + expect(target.range.start).toEqual({ line: 1, character: 0 }); }); }); diff --git a/packages/@aws-cdk/cloud-assembly-api/lib/index.ts b/packages/@aws-cdk/cloud-assembly-api/lib/index.ts index 736566961..6466dcc30 100644 --- a/packages/@aws-cdk/cloud-assembly-api/lib/index.ts +++ b/packages/@aws-cdk/cloud-assembly-api/lib/index.ts @@ -20,3 +20,4 @@ export * from './placeholders'; export * from './environment'; export * from './bootstrap'; export * from './construct-tree'; +export * from './template-ranges'; diff --git a/packages/@aws-cdk/cloud-assembly-api/lib/template-ranges.ts b/packages/@aws-cdk/cloud-assembly-api/lib/template-ranges.ts new file mode 100644 index 000000000..928a32d53 --- /dev/null +++ b/packages/@aws-cdk/cloud-assembly-api/lib/template-ranges.ts @@ -0,0 +1,67 @@ +import { findNodeAtLocation, findNodeAtOffset, getNodePath, parseTree, type Node } from 'jsonc-parser'; + +/** + * A character-offset range into a template's text, as a half-open interval + * `[start, end)`. Framework-neutral on purpose: consumers map offsets to their + * own position model (for example the LSP via `TextDocument.positionAt`), which + * keeps this package free of editor types and of the UTF-16 column subtlety. + */ +export interface OffsetRange { + /** Start offset, a 0-based character index, inclusive. */ + readonly start: number; + /** End offset, a 0-based character index, exclusive. */ + readonly end: number; +} + +/** + * Resolves the character range of a resource's value block inside a synthesized + * CloudFormation template. The range covers the value node `{ ... }` under + * `Resources/`, so `JSON.parse(text.slice(start, end))` returns the + * resource object. + * + * Returns `undefined` when the text cannot be parsed into a JSON tree, or when + * `logicalId` is not a resource under `Resources`. Uses a position-aware parse + * rather than a line scan because real templates contain literal braces and + * escaped quotes inside string values (for example `Fn::Sub` placeholders), + * which defeats naive brace matching. + */ +export function resolveResourceRange(templateText: string, logicalId: string): OffsetRange | undefined { + const root = parseTree(templateText); + if (root === undefined) { + return undefined; + } + const blockNode = findNodeAtLocation(root, ['Resources', logicalId]); + return blockNode === undefined ? undefined : rangeOf(blockNode); +} + +/** + * The inverse of `resolveResourceRange`: given a character offset into a + * template's text, returns the logical id of the resource whose block contains + * that offset, for linking a position in the template back to its construct. + * + * Returns `undefined` when the text cannot be parsed, or when the offset is not + * inside any `Resources/` block (for example whitespace, the + * top-level `Resources` key, or another top-level section). + */ +export function resolveLogicalIdAtOffset(templateText: string, offset: number): string | undefined { + const root = parseTree(templateText); + if (root === undefined) { + return undefined; + } + const node = findNodeAtOffset(root, offset); + if (node === undefined) { + return undefined; + } + // The path of any node inside a resource is ['Resources', , ...]. + // path[1] is always a key here; the typeof narrows getNodePath's string|number union. + const path = getNodePath(node); + if (path.length >= 2 && path[0] === 'Resources' && typeof path[1] === 'string') { + return path[1]; + } + return undefined; +} + +/** The character range covered by a parsed node. */ +function rangeOf(node: Node): OffsetRange { + return { start: node.offset, end: node.offset + node.length }; +} diff --git a/packages/@aws-cdk/cloud-assembly-api/test/template-ranges.test.ts b/packages/@aws-cdk/cloud-assembly-api/test/template-ranges.test.ts new file mode 100644 index 000000000..e333242c8 --- /dev/null +++ b/packages/@aws-cdk/cloud-assembly-api/test/template-ranges.test.ts @@ -0,0 +1,117 @@ +import { resolveLogicalIdAtOffset, resolveResourceRange } from '../lib'; + +// A template object is the single source of truth: the text under test is its +// 1-space serialization (exactly how synth writes `*.template.json`), and the +// expected values are read straight off the object, so input and expectations +// can never drift. The shapes pack the cases that defeat naive approaches: +// +// - Policy carries an `Fn::Sub` (`${...}` braces), an `InlineJson` value with +// escaped quotes and a `}`, and a `Description` with a lone unmatched `{` — +// all of which break brace-counting but not a real parse. +// - MyBucket / MyBucketF68F3FF0 are a prefix-collision pair. +// - MyBucket also appears as a value (Fn::Sub, DependsOn), never as another key. +const TEMPLATE = { + Resources: { + MyBucket: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: 'plain-bucket' }, + }, + MyBucketF68F3FF0: { + Type: 'AWS::S3::Bucket', + Properties: { VersioningConfiguration: { Status: 'Enabled' } }, + }, + Policy: { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyDocument: { + Statement: [{ Action: 's3:*', Resource: { 'Fn::Sub': 'arn:${AWS::Partition}:s3:::${MyBucket}/*' } }], + }, + InlineJson: '{"key":"va}lue"}', + Description: 'trailing brace } then a lone open { inside a string', + }, + DependsOn: ['MyBucket'], + }, + Topic: { Type: 'AWS::SNS::Topic' }, + }, +} as const; + +// 1-space indent mirrors `JSON.stringify(template, undefined, 1)` in synth. +const TEMPLATE_TEXT = JSON.stringify(TEMPLATE, undefined, 1); +const RESOURCES: Record = TEMPLATE.Resources; +const LOGICAL_IDS = Object.keys(RESOURCES); + +/** Slice the serialized text by a computed range and re-parse it. */ +const sliceParse = (range: { start: number; end: number }) => + JSON.parse(TEMPLATE_TEXT.slice(range.start, range.end)); + +describe('resolveResourceRange', () => { + // The authoritative check: a range is correct iff the substring it selects + // re-parses to the same value the template's own JSON.parse produced. No + // hand-computed offsets, and it runs over every resource. + test.each(LOGICAL_IDS)('the resolved range for %s re-parses to that exact resource', (logicalId) => { + const block = resolveResourceRange(TEMPLATE_TEXT, logicalId)!; + expect(sliceParse(block)).toEqual(RESOURCES[logicalId]); + }); + + test('resolves a block whose property values contain brace/quote hazards', () => { + // Policy holds an Fn::Sub (${...}), an InlineJson value with escaped quotes + // and a }, and a Description with a lone {, all of which defeat brace counting. + const block = resolveResourceRange(TEMPLATE_TEXT, 'Policy')!; + expect(sliceParse(block)).toEqual(RESOURCES.Policy); + }); + + test('a logical id resolves to its own block despite a prefix-named sibling', () => { + const short = resolveResourceRange(TEMPLATE_TEXT, 'MyBucket')!; + const long = resolveResourceRange(TEMPLATE_TEXT, 'MyBucketF68F3FF0')!; + expect(sliceParse(short)).toEqual(RESOURCES.MyBucket); + expect(sliceParse(long)).toEqual(RESOURCES.MyBucketF68F3FF0); + expect(short).not.toEqual(long); + }); + + test('resolves the definition block even when the id also appears as a value', () => { + // MyBucket appears under Policy's Fn::Sub and DependsOn, never as another key. + const block = resolveResourceRange(TEMPLATE_TEXT, 'MyBucket')!; + expect((sliceParse(block) as { Type: string }).Type).toBe('AWS::S3::Bucket'); + }); + + test('returns a range for lenient (trailing-comma) JSON rather than failing', () => { + // jsonc-parser is tolerant, so resolution does not require strict JSON; it + // still locates the block. (The slice is not guaranteed to be strict JSON — + // relevant once L3 reads half-written templates on save.) + const lenient = '{\n "Resources": {\n "B": {\n "Type": "AWS::S3::Bucket",\n }\n }\n}'; + const range = resolveResourceRange(lenient, 'B')!; + // Prove it located B specifically (not just "a range"). The lenient slice + // can't be strict-JSON-parsed, so match the text instead. + expect(lenient.slice(range.start, range.end)).toContain('AWS::S3::Bucket'); + }); + + test('returns undefined for an unknown logical id', () => { + expect(resolveResourceRange(TEMPLATE_TEXT, 'DoesNotExist')).toBeUndefined(); + }); + + test('returns undefined when the text cannot be parsed into a tree', () => { + expect(resolveResourceRange('not json at all', 'MyBucket')).toBeUndefined(); + }); +}); + +describe('resolveLogicalIdAtOffset', () => { + // Inverse round-trip: an offset inside each resource's block resolves back to it. + test.each(LOGICAL_IDS)('an offset inside %s resolves to that logical id', (logicalId) => { + const block = resolveResourceRange(TEMPLATE_TEXT, logicalId)!; + const mid = Math.floor((block.start + block.end) / 2); + expect(resolveLogicalIdAtOffset(TEMPLATE_TEXT, mid)).toBe(logicalId); + }); + + test('an offset in a nested property value resolves to its owning resource', () => { + expect(resolveLogicalIdAtOffset(TEMPLATE_TEXT, TEMPLATE_TEXT.indexOf('lone open'))).toBe('Policy'); + }); + + test('returns undefined for an offset outside any resource block', () => { + // Offset 0 is the opening brace of the whole document, before Resources. + expect(resolveLogicalIdAtOffset(TEMPLATE_TEXT, 0)).toBeUndefined(); + }); + + test('returns undefined when the text cannot be parsed into a tree', () => { + expect(resolveLogicalIdAtOffset('not json at all', 3)).toBeUndefined(); + }); +});