diff --git a/.projenrc.ts b/.projenrc.ts index d600805f4..61cdd08bf 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -580,8 +580,10 @@ const cloudAssemblyApi = configureProject( srcdir: 'lib', devDeps: [ cloudAssemblySchema.customizeReference({ versionType: 'exact' }), + '@types/json-source-map@^0.6.0', ], deps: [ + 'json-source-map@^0.6.1', 'jsonschema@^1.5.0', 'semver', ], 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/.projen/deps.json b/packages/@aws-cdk/cloud-assembly-api/.projen/deps.json index b13424894..3dc0e5f7b 100644 --- a/packages/@aws-cdk/cloud-assembly-api/.projen/deps.json +++ b/packages/@aws-cdk/cloud-assembly-api/.projen/deps.json @@ -17,6 +17,11 @@ "name": "@types/jest", "type": "build" }, + { + "name": "@types/json-source-map", + "version": "^0.6.0", + "type": "build" + }, { "name": "@types/node", "version": "^20", @@ -110,6 +115,11 @@ "name": "@aws-cdk/cloud-assembly-schema", "type": "peer" }, + { + "name": "json-source-map", + "version": "^0.6.1", + "type": "runtime" + }, { "name": "jsonschema", "version": "^1.5.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..73793b754 --- /dev/null +++ b/packages/@aws-cdk/cloud-assembly-api/lib/template-ranges.ts @@ -0,0 +1,76 @@ +import { parse, type Pointers } from 'json-source-map'; + +/** + * 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 `{ ... }` under + * `Resources/`, so `JSON.parse(text.slice(start, end))` returns the + * resource object. + * + * Returns `undefined` when the text is not valid JSON, or when there is no such + * resource. + */ +export function resolveResourceRange(templateText: string, logicalId: string): OffsetRange | undefined { + const pointers = parsePointers(templateText); + const mapping = pointers?.[`/Resources/${escapePointerSegment(logicalId)}`]; + if (mapping === undefined) { + return undefined; + } + return { start: mapping.value.pos, end: mapping.valueEnd.pos }; +} + +/** + * 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 is not valid JSON, or when the offset is not + * inside any `Resources/` block (for example whitespace, the + * `Resources` key, or another top-level section). + */ +export function resolveLogicalIdAtOffset(templateText: string, offset: number): string | undefined { + const pointers = parsePointers(templateText); + if (pointers === undefined) { + return undefined; + } + for (const [pointer, mapping] of Object.entries(pointers)) { + // Match only top-level resources (`/Resources/`), not nested property + // pointers like `/Resources//Properties/...`. + const match = /^\/Resources\/([^/]+)$/.exec(pointer); + if (match && offset >= mapping.value.pos && offset < mapping.valueEnd.pos) { + return unescapePointerSegment(match[1]); + } + } + return undefined; +} + +/** Parse the template into its JSON-pointer map, or `undefined` if it is not valid JSON. */ +function parsePointers(templateText: string): Pointers | undefined { + try { + return parse(templateText).pointers; + } catch { + return undefined; + } +} + +/** Escape a single path segment for use in a JSON pointer. */ +function escapePointerSegment(segment: string): string { + return segment.replace(/~/g, '~0').replace(/\//g, '~1'); +} + +/** Reverse of `escapePointerSegment`. */ +function unescapePointerSegment(segment: string): string { + return segment.replace(/~1/g, '/').replace(/~0/g, '~'); +} diff --git a/packages/@aws-cdk/cloud-assembly-api/package.json b/packages/@aws-cdk/cloud-assembly-api/package.json index d139634c0..de7edd47c 100644 --- a/packages/@aws-cdk/cloud-assembly-api/package.json +++ b/packages/@aws-cdk/cloud-assembly-api/package.json @@ -35,6 +35,7 @@ "@cdklabs/eslint-plugin": "^2.0.8", "@stylistic/eslint-plugin": "^3", "@types/jest": "^29.5.14", + "@types/json-source-map": "^0.6.0", "@types/node": "^20", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", @@ -60,6 +61,7 @@ "@aws-cdk/cloud-assembly-schema": "^0.0.0" }, "dependencies": { + "json-source-map": "^0.6.1", "jsonschema": "^1.5.0", "semver": "^7.8.1" }, 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..c3ee2b95e --- /dev/null +++ b/packages/@aws-cdk/cloud-assembly-api/test/template-ranges.test.ts @@ -0,0 +1,113 @@ +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 undefined for invalid JSON (for example a trailing comma)', () => { + // json-source-map is a strict parser, so a malformed template yields no + // range rather than a wrong one. Synthesized templates are always strict JSON. + const invalid = '{\n "Resources": {\n "B": {\n "Type": "AWS::S3::Bucket",\n }\n }\n}'; + expect(resolveResourceRange(invalid, 'B')).toBeUndefined(); + }); + + 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(); + }); +}); diff --git a/packages/@aws-cdk/cloud-assembly-api/tsconfig.dev.json b/packages/@aws-cdk/cloud-assembly-api/tsconfig.dev.json index 3b7a2facb..77acfb5f3 100644 --- a/packages/@aws-cdk/cloud-assembly-api/tsconfig.dev.json +++ b/packages/@aws-cdk/cloud-assembly-api/tsconfig.dev.json @@ -26,6 +26,7 @@ "target": "ES2020", "types": [ "jest", + "json-source-map", "node" ], "incremental": true, diff --git a/packages/@aws-cdk/cloud-assembly-api/tsconfig.json b/packages/@aws-cdk/cloud-assembly-api/tsconfig.json index 4bb6c381c..6c246cfb6 100644 --- a/packages/@aws-cdk/cloud-assembly-api/tsconfig.json +++ b/packages/@aws-cdk/cloud-assembly-api/tsconfig.json @@ -28,6 +28,7 @@ "target": "ES2020", "types": [ "jest", + "json-source-map", "node" ], "incremental": true, diff --git a/packages/@aws-cdk/integ-runner/THIRD_PARTY_LICENSES b/packages/@aws-cdk/integ-runner/THIRD_PARTY_LICENSES index f6831ff4e..dce99b9e7 100644 --- a/packages/@aws-cdk/integ-runner/THIRD_PARTY_LICENSES +++ b/packages/@aws-cdk/integ-runner/THIRD_PARTY_LICENSES @@ -29923,6 +29923,32 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ** isarray@1.0.0 - https://www.npmjs.com/package/isarray/v/1.0.0 | MIT +---------------- + +** json-source-map@0.6.1 - https://www.npmjs.com/package/json-source-map/v/0.6.1 | MIT +MIT License + +Copyright (c) 2017 Evgeny Poberezkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + ---------------- ** jsonfile@6.2.0 - https://www.npmjs.com/package/jsonfile/v/6.2.0 | MIT diff --git a/packages/aws-cdk/THIRD_PARTY_LICENSES b/packages/aws-cdk/THIRD_PARTY_LICENSES index e3d58cd49..01d790734 100644 --- a/packages/aws-cdk/THIRD_PARTY_LICENSES +++ b/packages/aws-cdk/THIRD_PARTY_LICENSES @@ -30073,6 +30073,32 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ** isarray@1.0.0 - https://www.npmjs.com/package/isarray/v/1.0.0 | MIT +---------------- + +** json-source-map@0.6.1 - https://www.npmjs.com/package/json-source-map/v/0.6.1 | MIT +MIT License + +Copyright (c) 2017 Evgeny Poberezkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + ---------------- ** jsonfile@6.2.0 - https://www.npmjs.com/package/jsonfile/v/6.2.0 | MIT diff --git a/packages/cdk-assets/THIRD_PARTY_LICENSES b/packages/cdk-assets/THIRD_PARTY_LICENSES index 149623f1e..5438cc4cd 100644 --- a/packages/cdk-assets/THIRD_PARTY_LICENSES +++ b/packages/cdk-assets/THIRD_PARTY_LICENSES @@ -22174,6 +22174,32 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ** isarray@1.0.0 - https://www.npmjs.com/package/isarray/v/1.0.0 | MIT +---------------- + +** json-source-map@0.6.1 - https://www.npmjs.com/package/json-source-map/v/0.6.1 | MIT +MIT License + +Copyright (c) 2017 Evgeny Poberezkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + ---------------- ** jsonschema@1.5.0 - https://www.npmjs.com/package/jsonschema/v/1.5.0 | MIT diff --git a/yarn.lock b/yarn.lock index 4a352da58..aa8f8c6c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -249,6 +249,7 @@ __metadata: "@cdklabs/eslint-plugin": "npm:^2.0.8" "@stylistic/eslint-plugin": "npm:^3" "@types/jest": "npm:^29.5.14" + "@types/json-source-map": "npm:^0.6.0" "@types/node": "npm:^20" "@typescript-eslint/eslint-plugin": "npm:^8" "@typescript-eslint/parser": "npm:^8" @@ -263,6 +264,7 @@ __metadata: eslint-plugin-prettier: "npm:^4.2.5" jest: "npm:^29.7.0" jest-junit: "npm:^16" + json-source-map: "npm:^0.6.1" jsonschema: "npm:^1.5.0" license-checker: "npm:^25.0.1" nx: "npm:^22.7.5" @@ -6819,6 +6821,13 @@ __metadata: languageName: node linkType: hard +"@types/json-source-map@npm:^0.6.0": + version: 0.6.0 + resolution: "@types/json-source-map@npm:0.6.0" + checksum: 10c0/b7db2f52735acc70b70bf49b1e945f4749ac5c9c8429c44ad23c751193f89d030400ed966c416540647745414cd450f35795395bf0be9eae94816bbe03ad0a2f + languageName: node + linkType: hard + "@types/json5@npm:^0.0.29": version: 0.0.29 resolution: "@types/json5@npm:0.0.29" @@ -13657,6 +13666,13 @@ __metadata: languageName: node linkType: hard +"json-source-map@npm:^0.6.1": + version: 0.6.1 + resolution: "json-source-map@npm:0.6.1" + checksum: 10c0/9fe819f7dfc1407caf8e36246f18376eb511b969954d457b251dfb506df74544cefc0c368f64474b512956eeb37807e03045332a9712ddd14b836d54debe03c3 + languageName: node + linkType: hard + "json-stable-stringify-without-jsonify@npm:^1.0.1": version: 1.0.1 resolution: "json-stable-stringify-without-jsonify@npm:1.0.1"