Skip to content
Merged
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
215 changes: 185 additions & 30 deletions src/resourceState/ResourceStateImporter.ts

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions src/resourceState/ResourceStateTypes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CodeAction, CodeActionParams } from 'vscode-languageserver';
import { CompletionItem, TextDocumentIdentifier } from 'vscode-languageserver';
import { RequestType } from 'vscode-languageserver-protocol';
import { ResourceStackManagementResult } from './StackManagementInfoProvider';

Expand Down Expand Up @@ -27,12 +27,14 @@ export enum ResourceStatePurpose {
CLONE = 'Clone',
}

export interface ResourceStateParams extends CodeActionParams {
export interface ResourceStateParams {
textDocument: TextDocumentIdentifier;
resourceSelections?: ResourceSelection[];
purpose: ResourceStatePurpose;
}

export interface ResourceStateResult extends CodeAction {
export interface ResourceStateResult {
completionItem?: CompletionItem;
successfulImports: Record<ResourceType, ResourceIdentifier[]>;
failedImports: Record<ResourceType, ResourceIdentifier[]>;
warning?: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import type { ResourceSchema } from '../ResourceSchema';
import { PlaceholderConstants } from './PlaceholderConstants';
import type { ResourceTemplateTransformer } from './ResourceTemplateTransformer';

/**
* Transformer that adds tabstop placeholders for required write-only properties.
* Transformer that adds placeholder constants for required write-only properties.
* Only adds placeholders at the required property level, not for nested write-only children.
* Replaces empty objects with tabstop placeholders using LSP snippet syntax.
* Uses sequential tabstops (${1}, ${2}, etc.). The $0 final cursor position is handled by the client.
* Uses placeholder constants that will be replaced with tab stops later.
*/
export class AddWriteOnlyRequiredPropertiesTransformer implements ResourceTemplateTransformer {
public transform(resourceProperties: Record<string, unknown>, schema: ResourceSchema): void {
public transform(resourceProperties: Record<string, unknown>, schema: ResourceSchema, logicalId?: string): void {
const requiredProps = schema.required ?? [];
const writeOnlyPaths = schema.writeOnlyProperties ?? [];

if (requiredProps.length === 0 || writeOnlyPaths.length === 0) {
if (requiredProps.length === 0 || writeOnlyPaths.length === 0 || !logicalId) {
return;
}

Expand All @@ -28,10 +28,12 @@ export class AddWriteOnlyRequiredPropertiesTransformer implements ResourceTempla
}
}

let tabstopIndex = 1;
for (const prop of requiredWriteOnlyProps) {
if (!(prop in resourceProperties) || this.isEmpty(resourceProperties[prop])) {
resourceProperties[prop] = `\${${tabstopIndex++}:update required write only property}`;
resourceProperties[prop] = PlaceholderConstants.createPlaceholder(
PlaceholderConstants.WRITE_ONLY_REQUIRED,
logicalId,
);
}
}
}
Expand Down
70 changes: 70 additions & 0 deletions src/schema/transformers/PlaceholderConstants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Constants for placeholder text used during resource transformation.
* These will be replaced with actual snippet tab stops at the end of processing.
*/
export const PlaceholderConstants = {
/** Common prefix for all placeholders */
PREFIX: '__PLACEHOLDER__',

/** Placeholder purposes */
WRITE_ONLY_REQUIRED: 'WRITE_ONLY_REQUIRED',
CLONE_INPUT_REQUIRED: 'CLONE_INPUT_REQUIRED',

/** Generate placeholder text with logical ID */
createPlaceholder(purpose: string, logicalId: string): string {
return `${this.PREFIX}${purpose}__${logicalId}`;
},

/** Check if text contains any placeholder */
hasPlaceholders(text: string): boolean {
return text.includes(this.PREFIX);
},
} as const;

/**
* Utility to replace placeholder constants with snippet tab stops.
*/
export const PlaceholderReplacer = {
/**
* Replace all placeholder constants with sequential snippet tab stops.
* @param text The text containing placeholder constants
* @returns Text with tab stops (${1:...}, ${2:...}, etc.)
*/
replaceWithTabStops(text: string): string {
let tabStopCounter = 1;
let result = text;

// Find all placeholders in the text
const placeholderRegex = /__PLACEHOLDER__(\w+)__(\w+)/g;
const placeholders: Array<{ match: string; purpose: string; logicalId: string }> = [];

let match;
while ((match = placeholderRegex.exec(text)) !== null) {
placeholders.push({
match: match[0],
purpose: match[1],
logicalId: match[2],
});
}

// Replace each placeholder sequentially using local counter
for (const placeholder of placeholders) {
const purposeText =
placeholder.purpose === PlaceholderConstants.WRITE_ONLY_REQUIRED
? 'write only required property'
: 'enter new identifier';

const tabStopText = `\${${tabStopCounter++}:${purposeText} for ${placeholder.logicalId}}`;
result = result.replace(placeholder.match, tabStopText);
}

return result;
},

/**
* Check if text contains any placeholder constants.
*/
hasPlaceholders(text: string): boolean {
return PlaceholderConstants.hasPlaceholders(text);
},
} as const;
62 changes: 56 additions & 6 deletions src/schema/transformers/ReplacePrimaryIdentifierTransformer.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,67 @@
import { ResourceSchema } from '../ResourceSchema';
import { PlaceholderConstants } from './PlaceholderConstants';
import { ResourceTemplateTransformer } from './ResourceTemplateTransformer';

export class ReplacePrimaryIdentifierTransformer implements ResourceTemplateTransformer {
private static readonly CLONE_PLACEHOLDER = '<CLONE INPUT REQUIRED>';

transform(resourceProperties: Record<string, unknown>, schema: ResourceSchema): void {
transform(resourceProperties: Record<string, unknown>, schema: ResourceSchema, logicalId?: string): void {
if (!schema.primaryIdentifier || schema.primaryIdentifier.length === 0) {
return;
}

for (const identifierPath of schema.primaryIdentifier) {
this.replacePrimaryIdentifierProperty(resourceProperties, identifierPath);
if (this.isPrimaryIdentifierRequired(identifierPath, schema)) {
if (logicalId) {
this.replacePrimaryIdentifierProperty(resourceProperties, identifierPath, logicalId);
}
} else {
this.removePrimaryIdentifierProperty(resourceProperties, identifierPath);
}
}
}

private isPrimaryIdentifierRequired(identifierPath: string, schema: ResourceSchema): boolean {
if (!schema.required || schema.required.length === 0) {
return false;
}

const pathParts = identifierPath.split('/').filter((part) => part !== '' && part !== 'properties');
if (pathParts.length === 0) {
return false;
}

const propertyName = pathParts[pathParts.length - 1];
return schema.required.includes(propertyName);
}

private removePrimaryIdentifierProperty(properties: Record<string, unknown>, propertyPath: string): void {
const pathParts = propertyPath.split('/').filter((part) => part !== '' && part !== 'properties');

if (pathParts.length === 0) {
return;
}

let current = properties;

// Navigate to the parent of the target property
for (let i = 0; i < pathParts.length - 1; i++) {
const part = pathParts[i];
if (current[part] && typeof current[part] === 'object') {
current = current[part] as Record<string, unknown>;
} else {
return; // Path doesn't exist
}
}

// Remove the final property
const finalProperty = pathParts[pathParts.length - 1];
delete current[finalProperty];
}

private replacePrimaryIdentifierProperty(properties: Record<string, unknown>, propertyPath: string): void {
private replacePrimaryIdentifierProperty(
properties: Record<string, unknown>,
propertyPath: string,
logicalId: string,
): void {
// Handle nested property paths like "/properties/BucketName"
const pathParts = propertyPath.split('/').filter((part) => part !== '' && part !== 'properties');

Expand All @@ -37,7 +84,10 @@ export class ReplacePrimaryIdentifierTransformer implements ResourceTemplateTran
// Replace the final property with placeholder
const finalProperty = pathParts[pathParts.length - 1];
if (finalProperty in current) {
current[finalProperty] = ReplacePrimaryIdentifierTransformer.CLONE_PLACEHOLDER;
current[finalProperty] = PlaceholderConstants.createPlaceholder(
PlaceholderConstants.CLONE_INPUT_REQUIRED,
logicalId,
);
}
}
}
2 changes: 1 addition & 1 deletion src/schema/transformers/ResourceTemplateTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ import { ResourceSchema } from '../ResourceSchema';
* Interface for resource template transformers
*/
export interface ResourceTemplateTransformer {
transform(resourceProperties: Record<string, unknown>, schema: ResourceSchema): void;
transform(resourceProperties: Record<string, unknown>, schema: ResourceSchema, logicalId?: string): void;
}
34 changes: 34 additions & 0 deletions tst/unit/resourceState/MockResourceState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,40 @@ export const MockResourceStates = {
Description: 'Database connection URL',
}),
} as ResourceState,

'AWS::Synthetics::Canary': {
typeName: 'AWS::Synthetics::Canary',
identifier: 'my-test-canary',
createdTimestamp: baseTimestamp,
properties: JSON.stringify({
Name: 'my-test-canary',
Code: {
Handler: 'index.handler',
Script: 'exports.handler = async () => {};',
},
ArtifactS3Location: 's3://my-bucket/canary-artifacts',
ExecutionRoleArn: 'arn:aws:iam::123456789012:role/canary-role',
Schedule: {
Expression: 'rate(5 minutes)',
},
RuntimeVersion: 'syn-nodejs-puppeteer-3.9',
}),
} as ResourceState,

'AWS::SecurityLake::SubscriberNotification': {
typeName: 'AWS::SecurityLake::SubscriberNotification',
identifier: 'arn:aws:securitylake:us-east-1:123456789012:subscriber/test-subscriber',
createdTimestamp: baseTimestamp,
properties: JSON.stringify({
SubscriberArn: 'arn:aws:securitylake:us-east-1:123456789012:subscriber/test-subscriber',
NotificationConfiguration: {
HttpsNotificationConfiguration: {
TargetRoleArn: 'arn:aws:iam::123456789012:role/notification-role',
Endpoint: 'https://example.com/webhook',
},
},
}),
} as ResourceState,
};

export function createMockResourceState(resourceType: string): ResourceState {
Expand Down
Loading
Loading