diff --git a/.github/workflows/alpha-release.yml b/.github/workflows/alpha-release.yml new file mode 100644 index 00000000..f0d324d0 --- /dev/null +++ b/.github/workflows/alpha-release.yml @@ -0,0 +1,46 @@ +name: Release Alpha +run-name: Release Alpha ${{ github.actor }} ${{ github.event_name }} + +on: + schedule: + - cron: '0 8 * * 1,3' # Monday, Wednesday 8am + workflow_dispatch: + +jobs: + create-alpha-tag: + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + tag: ${{ steps.version-and-tag.outputs.tag }} + steps: + - uses: actions/checkout@v5 + with: + ref: 'main' + fetch-depth: 0 + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Create alpha tag + id: version-and-tag + run: | + PKG_VERSION=$(jq -r .version package.json) + DATE=$(date +%Y%m%d%H%M) + TAG="v${PKG_VERSION}-${DATE}-alpha" + + git tag "$TAG" + git push origin "$TAG" + echo "Created tag from branch=$(git rev-parse --abbrev-ref HEAD), commit=$(git rev-parse HEAD), tag=$(git describe --tags --exact-match)" + + echo "tag=$TAG" >> $GITHUB_OUTPUT + + call-release: + needs: [create-alpha-tag] + uses: ./.github/workflows/release.yml + permissions: + contents: write + with: + tag: ${{ needs.create-alpha-tag.outputs.tag }} diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index 23938bb6..cc92f73f 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -32,7 +32,6 @@ jobs: with: node-version: ${{ needs.get-configs.outputs.node-version }} cache: 'npm' - architecture: ${{ needs.get-configs.outputs.arch }} - name: Configure Git run: | diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 3d160f0b..e40da219 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -10,12 +10,6 @@ on: type: string default: 'ubuntu-latest' - arch: - description: 'The architecture to target (e.g., "x64").' - required: false - type: string - default: 'x64' - ref: description: 'The Git ref to checkout.' required: true @@ -29,12 +23,6 @@ on: type: string default: 'ubuntu-latest' - arch: - description: 'The architecture to target (e.g., "x64").' - required: false - type: string - default: 'x64' - ref: description: 'The Git ref to checkout.' required: true @@ -57,12 +45,11 @@ jobs: shell: bash run: echo "Building from branch=$(git rev-parse --abbrev-ref HEAD), commit=$(git rev-parse HEAD), tag=$(git describe --tags --exact-match)" - - name: Setup Node.js ${{ needs.get-configs.outputs.node-version }} (${{ runner.os }}-${{ inputs.arch }}) + - name: Setup Node.js ${{ needs.get-configs.outputs.node-version }} (${{ runner.os }}) uses: actions/setup-node@v4 with: node-version: ${{ needs.get-configs.outputs.node-version }} cache: 'npm' - architecture: ${{ inputs.arch }} - name: Install Dependencies run: npm ci @@ -88,7 +75,7 @@ jobs: shell: bash run: echo "Building from branch=$(git rev-parse --abbrev-ref HEAD), commit=$(git rev-parse HEAD), tag=$(git describe --tags --exact-match)" - - name: Setup Go ${{ needs.get-configs.outputs.go-version }} (${{ runner.os }}-${{ runner.arch }}) + - name: Setup Go ${{ needs.get-configs.outputs.go-version }} (${{ runner.os }}) uses: actions/setup-go@v4 with: go-version: ${{ needs.get-configs.outputs.go-version }} @@ -96,8 +83,12 @@ jobs: - name: Build shell: bash - run: GOPROXY=direct go build -C ./cfn-init ./... + env: + GOPROXY: direct + run: go build -C ./cfn-init -v ./... - name: Test shell: bash - run: GOPROXY=direct go test -C ./cfn-init -v -cover ./... + env: + GOPROXY: direct + run: go test -C ./cfn-init -v -cover ./... diff --git a/.github/workflows/build-matrix.yml b/.github/workflows/build-matrix.yml deleted file mode 100644 index 8375d8f0..00000000 --- a/.github/workflows/build-matrix.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Build Matrix - -on: - workflow_call: - outputs: - build-matrix: - description: "The build matrix JSON" - value: ${{ jobs.define-matrix.outputs.build-matrix }} - -jobs: - define-matrix: - runs-on: ubuntu-latest - outputs: - build-matrix: ${{ steps.set-matrix.outputs.matrix }} - steps: - - name: Set Matrix JSON - id: set-matrix - run: | - JSON_STRING=$(cat <> $GITHUB_OUTPUT - echo "$JSON_STRING" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 56741913..23122e51 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -32,25 +32,6 @@ jobs: - name: Lint and Test run: npm run lint && npm run test - - name: Code Coverage - uses: irongut/CodeCoverageSummary@v1.3.0 - with: - filename: coverage/**/cobertura-coverage.xml - badge: false - fail_below_min: false - format: markdown - hide_branch_rate: false - hide_complexity: false - indicators: true - output: both - thresholds: '80 85' - - - name: Add Coverage PR Comment - uses: marocchino/sticky-pull-request-comment@v2 - with: - recreate: true - path: code-coverage-results.md - pr-build-test-go: needs: [get-configs] runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d35dcfa..8cc09c45 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,9 +20,6 @@ jobs: get-configs: uses: ./.github/workflows/configs.yml - get-build-matrix: - uses: ./.github/workflows/build-matrix.yml - version-and-tag: runs-on: ubuntu-latest outputs: @@ -50,33 +47,43 @@ jobs: if [[ "$TAG" == "v$VERSION" ]]; then echo "tag=$TAG" >> $GITHUB_OUTPUT echo "is-prerelease=false" >> $GITHUB_OUTPUT - elif [[ "$TAG" =~ ^v$VERSION-(alpha|beta)$ ]]; then + elif [[ "$TAG" =~ ^v$VERSION-[0-9]{12}-alpha$ ]]; then + echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "is-prerelease=true" >> $GITHUB_OUTPUT + elif [[ "$TAG" =~ ^v$VERSION-beta$ ]]; then echo "tag=$TAG" >> $GITHUB_OUTPUT echo "is-prerelease=true" >> $GITHUB_OUTPUT else - echo "Error: Tag ($TAG) must be v$VERSION or v$VERSION-" + echo "Error: Tag ($TAG) must be v$VERSION, v$VERSION-yyyymmddhhmm-alpha, or v$VERSION-beta" exit 1 fi build-and-test: - needs: [get-configs, get-build-matrix, version-and-tag] + needs: [get-configs, version-and-tag] uses: ./.github/workflows/build-and-test.yml strategy: fail-fast: true - matrix: ${{ fromJson(needs.get-build-matrix.outputs.build-matrix) }} + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] with: ref: ${{ needs.version-and-tag.outputs.tag }} - runs-on: ${{ matrix.runner }} - arch: ${{ matrix.arch }} + runs-on: ${{ matrix.os }} bundle-and-release: - needs: [get-configs, get-build-matrix, version-and-tag, build-and-test] + needs: [get-configs, version-and-tag, build-and-test] permissions: contents: write strategy: fail-fast: true - matrix: ${{ fromJson(needs.get-build-matrix.outputs.build-matrix) }} - runs-on: ${{ matrix.runner }} + matrix: + include: + - { os: "ubuntu-latest", arch: "x64", platform: "linux" } + - { os: "ubuntu-latest", arch: "arm64", platform: "linux" } + - { os: "ubuntu-latest", arch: "arm", platform: "linux" } + - { os: "macos-latest", arch: "x64", platform: "darwin" } + - { os: "macos-latest", arch: "arm64", platform: "darwin" } + - { os: "windows-latest", arch: "x64", platform: "win32" } + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5 with: @@ -87,14 +94,13 @@ jobs: shell: bash run: echo "Bundling from branch=$(git rev-parse --abbrev-ref HEAD), commit=$(git rev-parse HEAD), tag=$(git describe --tags --exact-match)" - - name: Setup Node.js ${{ needs.get-configs.outputs.node-version }} (${{ matrix.runner }}-${{ matrix.arch }}) + - name: Setup Node.js ${{ needs.get-configs.outputs.node-version }} (${{ runner.os }}) uses: actions/setup-node@v4 with: node-version: ${{ needs.get-configs.outputs.node-version }} cache: 'npm' - architecture: ${{ matrix.arch }} - - name: Setup Go ${{ needs.get-configs.outputs.go-version }} (${{ matrix.runner }}-${{ matrix.arch }}) + - name: Setup Go ${{ needs.get-configs.outputs.go-version }} (${{ runner.os }}) uses: actions/setup-go@v4 with: go-version: ${{ needs.get-configs.outputs.go-version }} @@ -103,21 +109,29 @@ jobs: - name: Install Dependencies run: npm ci - - name: Bundle + - name: Bundle (${{ matrix.platform }}-${{ matrix.arch }}) shell: bash run: | TAG=${{ needs.version-and-tag.outputs.tag }} if [[ "$TAG" =~ -alpha$ ]]; then - npm run bundle:alpha + npm run bundle:alpha -- --env platform=${{ matrix.platform }} --env arch=${{ matrix.arch }} elif [[ "$TAG" =~ -beta$ ]]; then - npm run bundle:beta + npm run bundle:beta -- --env platform=${{ matrix.platform }} --env arch=${{ matrix.arch }} else - npm run bundle:prod + npm run bundle:prod -- --env platform=${{ matrix.platform }} --env arch=${{ matrix.arch }} fi - # - name: Bundle Go - # shell: bash - # run: npm run build:go:prod + - name: Bundle Go + shell: bash + env: + GOPROXY: direct + run: | + if [[ "${{ runner.os }}" == "Windows" ]]; then + go build -C ./cfn-init -v -o ../bundle/production/bin/cfn-init.exe + else + go build -C ./cfn-init -v -o ../bundle/production/bin/cfn-init + fi + cp ./cfn-init/THIRD-PARTY-LICENSES.txt ./bundle/production/bin/ - name: Set release asset name id: set-asset-name @@ -125,16 +139,16 @@ jobs: run: | APP_NAME=${{ needs.get-configs.outputs.app-name }} VERSION=$(node -p "require('./package.json').version") - OS=${{ runner.os }} + PLATFORM=${{ matrix.platform }} ARCH=${{ matrix.arch }} TAG=${{ needs.version-and-tag.outputs.tag }} if [[ "$TAG" =~ -alpha$ ]]; then - FILE_NAME="${APP_NAME}-${VERSION}-alpha-${OS}-${ARCH}.zip" + FILE_NAME="${APP_NAME}-${VERSION}-alpha-${PLATFORM}-${ARCH}.zip" elif [[ "$TAG" =~ -beta$ ]]; then - FILE_NAME="${APP_NAME}-${VERSION}-beta-${OS}-${ARCH}.zip" + FILE_NAME="${APP_NAME}-${VERSION}-beta-${PLATFORM}-${ARCH}.zip" else - FILE_NAME="${APP_NAME}-${VERSION}-${OS}-${ARCH}.zip" + FILE_NAME="${APP_NAME}-${VERSION}-${PLATFORM}-${ARCH}.zip" fi ASSET_NAME=$(echo "$FILE_NAME" | tr '[:upper:]' '[:lower:]') @@ -148,7 +162,6 @@ jobs: run: | echo "Creating zip: ${{ env.ASSET_NAME }}" (cd ./bundle/production && zip -r ../../${{ env.ASSET_NAME }} .) - ls -la - name: Create Zip (Windows) if: runner.os == 'Windows' @@ -157,7 +170,6 @@ jobs: run: | echo "Creating zip: ${{ env.ASSET_NAME }}" Compress-Archive -Path ./bundle/production/* -DestinationPath ./${{ env.ASSET_NAME }} - dir - name: Create GitHub Release uses: softprops/action-gh-release@v2 diff --git a/package.json b/package.json index 651163a0..06117226 100644 --- a/package.json +++ b/package.json @@ -29,11 +29,11 @@ "test:leaks": "NODE_ENV=test vitest run --pool=forks --logHeapUsage", "lint": "eslint src tst", "lint:fix": "npm run lint -- --fix", - "build:go:dev": "GOPROXY=direct go build -C cfn-init -o ../bundle/development/bin/cfn-init ./cmd", - "build:go:prod": "GOPROXY=direct go build -C cfn-init -o ../bundle/production/bin/cfn-init ./cmd", + "build:go:dev": "GOPROXY=direct go build -C cfn-init -v -o ../bundle/development/bin/cfn-init", + "build:go:prod": "GOPROXY=direct go build -C cfn-init -v -o ../bundle/production/bin/cfn-init", "test:go": "GOPROXY=direct go test -C cfn-init ./...", "bundle": "rm -rf out && webpack --env mode=development", - "bundle:dev": "rm -rf out && webpack --env mode=production --env env=alpha", + "bundle:alpha": "rm -rf out && webpack --env mode=production --env env=alpha", "bundle:beta": "rm -rf out && webpack --env mode=production --env env=beta", "bundle:prod": "rm -rf out && webpack --env mode=production --env env=prod", "benchmark": "node --max-old-space-size=16384 --expose-gc -r ts-node/register tools/benchmark.ts", @@ -128,5 +128,41 @@ }, "overrides": { "tree-sitter": "0.22.4" + }, + "externalDependencies": [ + "@opentelemetry/api", + "@opentelemetry/auto-instrumentations-node", + "@opentelemetry/instrumentation-pino", + "@opentelemetry/resources", + "@opentelemetry/sdk-logs", + "@opentelemetry/sdk-metrics", + "@opentelemetry/sdk-node", + "@opentelemetry/sdk-trace-base", + "@opentelemetry/sdk-trace-node", + "@tree-sitter-grammars/tree-sitter-yaml", + "cbor-x", + "lmdb", + "pino", + "pino-opentelemetry-transport", + "pino-pretty", + "pyodide", + "tree-sitter", + "tree-sitter-json", + "vscode-languageserver-types" + ], + "nativePrebuilds": { + "@lmdb/lmdb-darwin-arm64": "3.4.2", + "@lmdb/lmdb-darwin-x64": "3.4.2", + "@lmdb/lmdb-linux-arm": "3.4.2", + "@lmdb/lmdb-linux-arm64": "3.4.2", + "@lmdb/lmdb-linux-x64": "3.4.2", + "@lmdb/lmdb-win32-arm64": "3.4.2", + "@lmdb/lmdb-win32-x64": "3.4.2", + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" } } diff --git a/src/autocomplete/IntrinsicFunctionArgumentCompletionProvider.ts b/src/autocomplete/IntrinsicFunctionArgumentCompletionProvider.ts index d487901c..f56c5074 100644 --- a/src/autocomplete/IntrinsicFunctionArgumentCompletionProvider.ts +++ b/src/autocomplete/IntrinsicFunctionArgumentCompletionProvider.ts @@ -452,6 +452,7 @@ export class IntrinsicFunctionArgumentCompletionProvider implements CompletionPr return this.getTopLevelKeyCompletions(mappingsEntities, args, context); } case 3: { + log.debug('In case three'); return this.getSecondLevelKeyCompletions(mappingsEntities, args, context); } default: { @@ -515,14 +516,22 @@ export class IntrinsicFunctionArgumentCompletionProvider implements CompletionPr context: Context, ): CompletionItem[] | undefined { // Validate arguments structure for second-level keys - if (!Array.isArray(args) || args.length < 2 || typeof args[0] !== 'string' || typeof args[1] !== 'string') { + if (!Array.isArray(args) || args.length < 2 || typeof args[0] !== 'string') { log.debug('Invalid arguments for second-level key completions'); return undefined; } + // Second argument valid if it is a string i.e. 'us-east-1' or object '{Ref: AWS::Region}' + const validSecondArg = typeof args[1] === 'string' || this.isRefObject(args[1]); + + if (!validSecondArg) { + log.debug('Invalid top level key for second-level key completions'); + return undefined; + } + try { const mappingName = args[0]; - const topLevelKey = args[1]; + const topLevelKey = args[1] as string | { Ref: unknown }; const mappingEntity = this.getMappingEntity(mappingsEntities, mappingName); if (!mappingEntity) { @@ -530,19 +539,32 @@ export class IntrinsicFunctionArgumentCompletionProvider implements CompletionPr return undefined; } - const secondLevelKeys = mappingEntity.getSecondLevelKeys(topLevelKey); - if (secondLevelKeys.length === 0) { - log.debug(`No second-level keys found for mapping: ${mappingName}, top-level key: ${topLevelKey}`); + let secondLevelKeys: string[] = []; + let dynamicSecondLevelKeys: string[] = []; + + if (typeof topLevelKey === 'string') { + secondLevelKeys = mappingEntity.getSecondLevelKeys(topLevelKey); + } else { + // For dynamic references, get all possible keys + dynamicSecondLevelKeys = mappingEntity.getSecondLevelKeysDynamic(mappingEntity); + } + + if (typeof topLevelKey === 'string') { + if (secondLevelKeys.length === 0) { + log.debug(`No second-level keys found for mapping: ${mappingName}, top-level key: ${topLevelKey}`); + return undefined; + } + } else if (typeof topLevelKey === 'object' && dynamicSecondLevelKeys.length === 0) { + log.debug(`No second-level keys found for mapping: ${mappingName}`); return undefined; } - const items = secondLevelKeys.map((key) => - createCompletionItem(key, CompletionItemKind.EnumMember, { context }), - ); + const keysToUse = typeof topLevelKey === 'string' ? secondLevelKeys : dynamicSecondLevelKeys; + const items = keysToUse.map((key) => createCompletionItem(key, CompletionItemKind.EnumMember, { context })); return context.text.length > 0 ? this.fuzzySearch(items, context.text) : items; } catch (error) { - log.error({ error }, 'Error creating second-level key completions'); + log.debug({ error }, 'Error creating second-level key completions'); return undefined; } } @@ -565,7 +587,7 @@ export class IntrinsicFunctionArgumentCompletionProvider implements CompletionPr // String format const dotIndex = args.indexOf('.'); if (dotIndex === -1) { - //resource name + // resource name return 1; } @@ -702,4 +724,8 @@ export class IntrinsicFunctionArgumentCompletionProvider implements CompletionPr return undefined; } + + private isRefObject(value: unknown): value is { Ref: unknown } { + return typeof value === 'object' && value !== null && 'Ref' in value; + } } diff --git a/src/autocomplete/ResourceEntityCompletionProvider.ts b/src/autocomplete/ResourceEntityCompletionProvider.ts index 1d7d61d8..52419718 100644 --- a/src/autocomplete/ResourceEntityCompletionProvider.ts +++ b/src/autocomplete/ResourceEntityCompletionProvider.ts @@ -62,7 +62,7 @@ export class ResourceEntityCompletionProvider implements CompletionProvider, Con ): ExtendedCompletionItem { const snippet = this.generateRequiredPropertiesSnippet(schema, context.documentType); - const completionItem: ExtendedCompletionItem = createCompletionItem('Properties', CompletionItemKind.Snippet, { + const completionItem: ExtendedCompletionItem = createCompletionItem('Properties', CompletionItemKind.File, { insertText: snippet, data: { type: 'object' }, }); diff --git a/src/autocomplete/TopLevelSectionCompletionProvider.ts b/src/autocomplete/TopLevelSectionCompletionProvider.ts index 75d2612a..88caea68 100644 --- a/src/autocomplete/TopLevelSectionCompletionProvider.ts +++ b/src/autocomplete/TopLevelSectionCompletionProvider.ts @@ -144,7 +144,7 @@ ${CompletionFormatter.getIndentPlaceholder(1)}\${1:ConditionName}: $2`, snippet = applySnippetIndentation(snippet, this.editorSettings, context.documentType); - const completionItem: ExtendedCompletionItem = createCompletionItem(section, CompletionItemKind.Snippet, { + const completionItem: ExtendedCompletionItem = createCompletionItem(section, CompletionItemKind.File, { insertText: snippet, data: { type: 'object' }, }); diff --git a/src/context/Context.ts b/src/context/Context.ts index d2b9e8cc..164a8d7d 100644 --- a/src/context/Context.ts +++ b/src/context/Context.ts @@ -181,30 +181,31 @@ export class Context { return false; } - // Case 1: propertyPath.length === 3 (e.g., ['Resources', 'MyResource', 'Type']) - // We're at a resource attribute level - if (this.propertyPath.length === 3 && this.entitySection === this.text) { - return true; + // Case 1: If we are over 3 we know for sure we are beyond the entity level + if (this.propertyPath.length > 3) { + return false; } - // Case 2: propertyPath.length === 2 (e.g., ['Resources', 'MyResource']) - // We need to distinguish between: - // - Cursor in middle of resource name (should return false) - // - Cursor after resource name, ready for attributes (should return true) - if (this.propertyPath.length === 2) { - // If the current text matches the logical ID (resource name), - // it means the cursor is positioned within the resource name itself - // In this case, we should NOT provide entity key completions - if (this.text === this.logicalId) { + // Case 2: Two situations exist that we need to account for: + // isKey and isValue can be True when at the first key inside a value + // when we are at level 2 this means we are at Entity/LogicalId as the first key + // when we are at level 3 this means we are at Entity/LogicalId/Properties as the first key + if (this.isKey() && this.isValue()) { + if (this.propertyPath.length === 2) { + return true; + } else if (this.propertyPath.length === 3) { return false; } + } - // If entitySection is undefined and text is not the resource name, - // it means we're positioned after the resource name, ready for attributes - return this.entitySection !== this.text; + // Case 3 propertyPath.length === 2 (e.g., ['Resources', 'MyResource']) + // We need to see if the cursor is in the resource logical id + if (this.propertyPath.length === 2 && this.text === this.logicalId) { + return false; } - return false; + // Catch all at this point to say that the isKey is the most important thing + return this.isKey(); } public getMappingKeys(): string[] { diff --git a/src/context/semantic/Entity.ts b/src/context/semantic/Entity.ts index a36c39aa..5a0db7e1 100644 --- a/src/context/semantic/Entity.ts +++ b/src/context/semantic/Entity.ts @@ -147,6 +147,17 @@ export class Mapping extends Entity { return []; } + public getSecondLevelKeysDynamic(mappingEntity: Mapping): string[] { + const allKeys = new Set(); + const topLevelKeys = mappingEntity.getTopLevelKeys(); + + for (const tlKey of topLevelKeys) { + const keys = mappingEntity.getSecondLevelKeys(tlKey); + for (const key of keys) allKeys.add(key); + } + return [...allKeys]; + } + public getValue(topLevelKey: string, secondLevelKey: string): MappingValueType | undefined { return this.value[topLevelKey]?.[secondLevelKey]; } diff --git a/src/protocol/LspConnection.ts b/src/protocol/LspConnection.ts index 38ffbd12..ff159698 100644 --- a/src/protocol/LspConnection.ts +++ b/src/protocol/LspConnection.ts @@ -71,6 +71,8 @@ export class LspConnection { this.stackHandlers = new LspStackHandlers(this.connection); this.resourceHandlers = new LspResourceHandlers(this.connection); + this.communication.console.info(`${ExtensionName} launched from ${__dirname}`); + this.connection.onInitialize((params: InitializeParams): InitializeResult => { this.communication.console.info(`Initializing ${ExtensionName}...`); this.initializeParams = params; diff --git a/src/templates/TemplateWorkflowOperations.ts b/src/templates/TemplateWorkflowOperations.ts index 49f4daf4..d0646103 100644 --- a/src/templates/TemplateWorkflowOperations.ts +++ b/src/templates/TemplateWorkflowOperations.ts @@ -15,7 +15,7 @@ import { changeSetNamePrefix, } from './TemplateWorkflowType'; -const LOGGER = LoggerFactory.getLogger('TemplateWorkflowOperations'); +const logger = LoggerFactory.getLogger('TemplateWorkflowOperations'); export async function processChangeSet( cfnService: CfnService, @@ -66,17 +66,22 @@ export async function waitForValidation( reason: result.reason ? String(result.reason) : undefined, }; } else { + logger.warn( + { reason: result.reason ? String(result.reason) : 'Unknown validation failure' }, + 'Validation failed', + ); return { status: TemplateStatus.VALIDATION_FAILED, result: WorkflowResult.FAILED, - reason: result.reason ? String(result.reason) : undefined, + reason: result.reason ? String(result.reason) : undefined, // TODO: Return reason as part of LSP Response }; } } catch (error) { + logger.error({ error: extractErrorMessage(error) }, 'Validation failed with error'); return { status: TemplateStatus.VALIDATION_FAILED, result: WorkflowResult.FAILED, - reason: error instanceof Error ? error.message : 'Validation failed', + reason: extractErrorMessage(error), }; } } @@ -103,18 +108,22 @@ export async function waitForDeployment( reason: result.reason ? String(result.reason) : undefined, }; } else { + logger.warn( + { reason: result.reason ? String(result.reason) : 'Unknown deployment failure' }, + 'Deployment failed', + ); return { status: TemplateStatus.DEPLOYMENT_FAILED, result: WorkflowResult.FAILED, - reason: result.reason ? String(result.reason) : undefined, + reason: result.reason ? String(result.reason) : undefined, // TODO: Return reason as part of LSP Response }; } } catch (error) { - LOGGER.info({ error: extractErrorMessage(error) }, 'Validation failed with error'); + logger.error({ error: extractErrorMessage(error) }, 'Deployment failed with error'); return { status: TemplateStatus.DEPLOYMENT_FAILED, result: WorkflowResult.FAILED, - reason: String(error), + reason: extractErrorMessage(error), }; } } @@ -137,7 +146,7 @@ export async function deleteStackAndChangeSet( initialDelayMs: 1000, operationName: `Delete change set ${workflow.changeSetName}`, }, - LOGGER, + logger, ); // Delete stack @@ -152,10 +161,10 @@ export async function deleteStackAndChangeSet( initialDelayMs: 1000, operationName: `Delete stack ${workflow.stackName}`, }, - LOGGER, + logger, ); } catch (error) { - LOGGER.warn( + logger.warn( { error, workflowId, changeSetName: workflow.changeSetName }, 'Failed to cleanup workflow resources', ); @@ -179,10 +188,10 @@ export async function deleteChangeSet( initialDelayMs: 1000, operationName: `Delete change set ${workflow.changeSetName}`, }, - LOGGER, + logger, ); } catch (error) { - LOGGER.warn( + logger.warn( { error, workflowId, changeSetName: workflow.changeSetName }, 'Failed to cleanup workflow resources', ); diff --git a/tst/e2e/autocomplete/Autocomplete.test.ts b/tst/e2e/autocomplete/Autocomplete.test.ts index 19973bb0..a422ede2 100644 --- a/tst/e2e/autocomplete/Autocomplete.test.ts +++ b/tst/e2e/autocomplete/Autocomplete.test.ts @@ -311,19 +311,20 @@ Conditions: position: { line: 103, character: 25 }, expectation: CompletionExpectationBuilder.create() .expectContainsItems(['AWS::Region']) - .todo() - // todo: intrinsic functions are being suggested incorrectly! - //[ - // "!RefAll", - // "!GetAZs", - // "!Ref", - // "!Equals", - // "!Base64", - // "!GetAtt", - // "!Transform", - // "!EachMemberEquals", - // "!EachMemberIn", - // ] + .todo( + `intrinsic functions are being suggested incorrectly! + [ + "!RefAll", + "!GetAZs", + "!Ref", + "!Equals", + "!Base64", + "!GetAtt", + "!Transform", + "!EachMemberEquals", + "!EachMemberIn", + ]`, + ) .build(), }, }, @@ -393,7 +394,7 @@ Rules: position: { line: 114, character: 45 }, expectation: CompletionExpectationBuilder.create() .expectContainsItems(['AWS::Region']) - .todo() // todo: no suggestion of pseudo-parameter after AWS:: is typed; needs the R + .todo(`no suggestion of pseudo-parameter after AWS:: is typed; needs the R`) .build(), }, }, @@ -406,9 +407,10 @@ Rules: position: { line: 114, character: 54 }, expectation: CompletionExpectationBuilder.create() .expectContainsItems(['InstanceType']) - // todo: second level of Mapping not being suggested when using !Ref for first level key - // works using 'us-east-1' - .todo() + .todo( + `second level of Mapping not being suggested when using !Ref for first level key + works using 'us-east-1'`, + ) .build(), }, }, @@ -864,7 +866,7 @@ Resources: 'VPC', ]) .expectExcludesItems(['AutoScalingGroup']) - .todo() // todo: support autocomplete for Fn::GetAtt + .todo(`support autocomplete for Fn::GetAtt`) .build(), }, }, @@ -982,7 +984,7 @@ Resources: 'IsProductionOrStaging', ]) .expectExcludesItems(['ComplexCondition', 'HasMultipleAZs']) - .todo() // todo: not working even when testing not on last line of YAML + .todo(`not working even when testing not on last line of YAML`) .build(), }, }, @@ -1002,10 +1004,12 @@ Resources: position: { line: 286, character: 40 }, expectation: CompletionExpectationBuilder.create() .expectContainsItems(['Snapshot']) - // todo: feature to suggest Resource attribute values - // some values (Snapshot) are based on resource type; see docs below - // https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-attribute-deletionpolicy.html#aws-attribute-deletionpolicy-options - .todo() + .todo( + `feature to suggest Resource attribute values + some values (Snapshot) are based on resource type; see docs below + https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-attribute-deletionpolicy.html#aws-attribute-deletionpolicy-options + `, + ) .build(), }, }, @@ -1168,7 +1172,6 @@ Resources: description: 'Suggest Keys inside but filter out existing keys', verification: { position: { line: 384, character: 14 }, - // this works in a real template but not in e2e testing expectation: CompletionExpectationBuilder.create().expectItems(['Value']).build(), }, }, @@ -1381,9 +1384,13 @@ O`, description: 'suggest Mapping second level key in deeply nested intrinsic function', verification: { position: { line: 471, character: 61 }, - // todo: fix bug in FindInMap completion where using intrinsic in second arg breaks - // suggestion for third arg - expectation: CompletionExpectationBuilder.create().expectItems(['AMI']).todo().build(), + expectation: CompletionExpectationBuilder.create() + .expectItems(['AMI']) + .todo( + `fix bug in FindInMap completion where using intrinsic in second arg breaks + suggestion for third arg`, + ) + .build(), }, }, { @@ -1400,8 +1407,12 @@ O`, description: 'suggest substitution variable in second arg of Fn::Sub based on first arg', verification: { position: { line: 474, character: 14 }, - // todo: feature to suggest variables authored in Fn::Sub first arg while typing second arg - expectation: CompletionExpectationBuilder.create().expectItems(['Third']).todo().build(), + expectation: CompletionExpectationBuilder.create() + .expectItems(['Third']) + .todo( + `feature to suggest variables authored in Fn::Sub first arg while typing second arg`, + ) + .build(), }, }, ], @@ -1412,26 +1423,1674 @@ O`, describe('JSON', () => { it('Completion while authoring', () => { - const template = new TemplateBuilder(DocumentType.JSON, '{}'); + const template = new TemplateBuilder(DocumentType.JSON); const scenario: TemplateScenario = { name: 'Comprehensive template', steps: [ { - action: 'replace', - range: { start: { line: 0, character: 1 }, end: { line: 0, character: 1 } }, - content: - ` + action: 'type', + content: `{ "AWSTemplateFormatVersion": "2010-09-09", - "Des` + '"', + "a" +}`, + position: { line: 0, character: 0 }, + description: 'Suggest top level section', + verification: { + position: { line: 2, character: 3 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['Parameters', 'Mappings', 'Transform', 'Metadata']) + .expectExcludesItems(['AWSTemplateFormatVersion']) + .build(), + }, + }, + { + action: 'replace', + content: `Des`, + range: { start: { line: 2, character: 3 }, end: { line: 2, character: 4 } }, + description: 'Suggest top level sections', verification: { position: { line: 2, character: 6 }, expectation: CompletionExpectationBuilder.create() .expectItems(['Description', 'Resources', 'Rules', 'Resources']) - .expectExcludesItems(['AWSTemplateFormatVersion']) .build(), }, }, + { + action: 'replace', + content: `Description": "Comprehensive CloudFormation template showcasing ALL complex syntax - GOOD STATE", + "Transform": [ + "AWS::Serverless-2016-10-31", + "AWS::Include" + ], + "Me +}`, + range: { start: { line: 2, character: 3 }, end: { line: 7, character: 7 } }, + description: 'Suggest top level section, only the ones that have not been already authored', + verification: { + position: { line: 7, character: 5 }, + expectation: CompletionExpectationBuilder.create() + .expectItems([ + 'Metadata', + 'Parameters', + 'Resources', + 'Mappings', + 'Resources', + 'Parameters', + ]) + .expectExcludesItems(['Transform', 'Description', 'AWSTemplateFormatVersion']) + .build(), + }, + }, + { + action: 'type', + content: `tadata": { + "AWS::CloudFormation::Interface": { + "ParameterGroups": [ + { + "Label": { + "Default": "Network Configuration" + }, + "Parameters": [ + "VpcCidr", + "EnvironmentName" + ] + } + ], + "ParameterLabels": { + "VpcCidr": { + "Default": "VPC CIDR Block" + } + } + }, + "CustomMetadata": { + "Version": "1.0.0", + "ComplexObject": { + "NestedArray": [ + "Item1", + { + "SubObject": { + "Key1": "Value1", + "Key2": "Value2" + } + } + ] + } + } + }, + "P`, + position: { line: 7, character: 5 }, + verification: { + position: { line: 41, character: 4 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['Parameters']) + .expectExcludesItems(['Description', 'Metadata', 'Transform']) + .build(), + }, + }, + { + action: 'type', + content: `arameters": { + "EnvironmentName": { + "Type": "S + } + }`, + position: { line: 41, character: 4 }, + description: 'Parameter types', + verification: { + position: { line: 43, character: 16 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['String', 'List']) + .build(), + }, + }, + { + action: 'type', + content: `tring", + "Default": "production", + "AllowedValues": ["development", "staging", "production"], + "ConstraintDescription": "Must be development, staging, or production" + }, + "VpcCidr": { + "Type": "String", + "Default": "10.0.0.0/16", + "AllowedPattern": "^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\\\\/(1[6-9]|2[0-8]))$" + }, + "SubnetCidrs": { + "Type": "C`, + position: { line: 43, character: 16 }, + description: 'Parameter types', + verification: { + position: { line: 54, character: 16 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['CommaDelimitedList']) + .build(), + }, + }, + { + action: 'type', + content: `ommaDelimitedList", + "D"`, + position: { line: 54, character: 16 }, + description: 'Parameter fields', + verification: { + position: { line: 55, character: 8 }, + expectation: CompletionExpectationBuilder.create() + .expectExcludesItems(['Type']) + .expectContainsItems(['Default']) + .build(), + }, + }, + { + action: 'delete', + range: { start: { line: 55, character: 8 }, end: { line: 55, character: 9 } }, + description: 'remove closing "', + }, + { + action: 'type', + content: `efault": "10.0.1.0/24,10.0.2.0/24" + }, + "DatabasePassword": { + "Type": "String", + "NoEcho": true, + "MinLength": 8, + "M"`, + position: { line: 55, character: 8 }, + description: 'Parameter fields', + verification: { + position: { line: 61, character: 8 }, + expectation: CompletionExpectationBuilder.create() + .expectExcludesItems(['Type', 'NoEcho', 'MinLength']) + .expectContainsItems(['MaxLength']) + .build(), + }, + }, + { + action: 'delete', + range: { start: { line: 61, character: 8 }, end: { line: 61, character: 9 } }, + description: 'remove closing "', + }, + { + action: 'type', + content: `axLength": 128 + }, + "InstanceCount": { + "Type": "Number", + "Default": 2, + "MinValue": 1, + "MaxValue": 10 + }, + "AvailabilityZones": { + "Type": "List', + 'List', + 'List', + 'List', + 'List', + 'List', + 'List', + 'List', + ]) + .build(), + }, + }, + { + action: 'type', + content: `vailabilityZone::Name>", + "Description": "List of AZs"`, + position: { line: 70, character: 31 }, + description: 'Description of parameter', + }, + { + action: 'type', + content: `}, + "SSMParameter": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/myapp/config/database-url" + }, + "BooleanParameter": { + "Type": "String", + "Default": "true", + "AllowedValues": ["true", "false"] + } + }, + "Mappings": { + "RegionMap": { + "us-east-1": { + "AMI": "ami-0abcdef1234567890", + "InstanceType": "t3.micro" + }, + "us-west-2": { + "AMI": "ami-0fedcba0987654321", + "InstanceType": "t3.small" + } + }, + "EnvironmentMap": { + "development": { + "DatabaseSize": "db.t3.micro", + "LogLevel": "DEBUG" + }, + "production": { + "DatabaseSize": "db.t3.large", + "LogLevel": "WARN" + } + } + }, + "Conditions": { + "IsProduction": {"Fn::E"`, + position: { line: 72, character: 4 }, + description: 'Intrinsic function name', + verification: { + position: { line: 106, character: 26 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['Fn::Equals', 'Fn::EachMemberEquals', 'Fn::EachMemberIn']) + .build(), + }, + }, + { + action: 'delete', + range: { start: { line: 106, character: 27 }, end: { line: 106, character: 29 } }, + description: 'remove }', + }, + { + action: 'type', + content: `quals": [{"Ref": "E"}]},`, + position: { line: 106, character: 27 }, + description: 'Ref parameter inside Fn::Equals', + verification: { + position: { line: 106, character: 45 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['EnvironmentName']) + .expectExcludesItems(['Vpc']) + .build(), + }, + }, + { + action: 'replace', + content: `EnvironmentName"}, "production"`, + range: { start: { line: 106, character: 45 }, end: { line: 106, character: 48 } }, + description: 'Complete reference parameter and add production value', + }, + { + action: 'type', + content: ` + "IsNotProduction": {"Fn::Not": [{"Condition": "Is"}]},`, + position: { line: 106, character: 79 }, + description: 'Condition excluding self', + verification: { + position: { line: 107, character: 52 }, + expectation: CompletionExpectationBuilder.create() + .expectExcludesItems(['IsNotProduction']) + .expectContainsItems(['IsProduction']) + .build(), + }, + }, + { + action: 'replace', + content: `IsProduction`, + range: { start: { line: 107, character: 51 }, end: { line: 107, character: 53 } }, + description: 'Complete condition reference', + }, + { + action: 'type', + content: ` + "IsDevelopment": {"Fn::Equals": [{"Ref": "EnvironmentName"}, "development"]}, + "IsProductionOrStaging": {"Fn::Or": [ + {"Condition": "Is"}`, + position: { line: 107, character: 68 }, + description: 'Add more conditions', + verification: { + position: { line: 110, character: 22 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['IsProduction', 'IsNotProduction', 'IsDevelopment']) + .expectExcludesItems(['IsProductionOrStaging']) + .build(), + }, + }, + { + action: 'replace', + content: `IsProduction`, + range: { start: { line: 110, character: 21 }, end: { line: 110, character: 23 } }, + description: 'Complete condition reference', + }, + { + action: 'type', + content: `, + {"Fn::Equals": [{"Ref": "EnvironmentName"}, "staging"]} + ]}, + "ComplexCondition": {"Fn::And": [ + {"Condition": "IsProductionOrStaging"}, + {"Fn::Not": [{"Condition": "IsDevelopment"}]}, + {"Fn::Equals": [{"Ref": "AWS::"} + ]}`, + position: { line: 110, character: 35 }, + description: 'Pseudo-parameter', + verification: { + position: { line: 116, character: 35 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['AWS::Region']) + .build(), + }, + }, + { + action: 'replace', + content: `AWS::Region"}, "us-east-1"]}`, + range: { start: { line: 116, character: 31 }, end: { line: 116, character: 38 } }, + description: 'Complete region reference', + }, + { + action: 'type', + content: `, + "HasMultipleAZs": {"Fn::Not": [{"Fn::Equals": [{"Fn::Select": [1, {"Ref": "A"}]}]}]}`, + position: { line: 117, character: 6 }, + description: 'Complete region reference and add HasMultipleAZs condition', + verification: { + position: { line: 118, character: 78 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['AvailabilityZones']) + .build(), + }, + }, + { + action: 'replace', + content: `AvailabilityZones`, + range: { start: { line: 118, character: 79 }, end: { line: 118, character: 80 } }, + description: 'Complete reference parameter', + }, + { + action: 'type', + content: `, false`, + position: { line: 118, character: 100 }, + description: 'Complete conditional arguments', + }, + { + action: 'type', + content: `}, + "Rules": { + "ValidateRegionAndEnvironment": { + "RuleCondition": {"Fn::Equals": [{"Ref": "AWS::Region"}, "us-east-1"]}, + "Assertions": [ + { + "Assert": {"Fn::Contains": [ + ["t3.micro", "t3.small", "t3.medium"], + {"Fi"} + ]} + } + ] + } + }`, + position: { line: 119, character: 2 }, + description: 'FindInMap intrinsic function name', + verification: { + position: { line: 127, character: 15 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['Fn::FindInMap']) + .todo('Completion provider not recognizing this as inIntrinsicFunction') + .build(), + }, + }, + { + action: 'delete', + range: { start: { line: 132, character: 3 }, end: { line: 132, character: 4 } }, + description: 'Remove trailing }', + }, + { + action: 'replace', + content: `"Fn::FindInMap": ["RegionMap", {"Ref": "AWS::Region"}, "I"]`, + range: { start: { line: 127, character: 13 }, end: { line: 127, character: 17 } }, + description: 'Complete FindInMap function with mapping name, region, and start of key', + verification: { + position: { line: 127, character: 69 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['InstanceType']) + .todo('Dynamic topLevelKey making secondLevelKey invalid') + .build(), + }, + }, + { + action: 'replace', + content: `InstanceType`, + range: { start: { line: 127, character: 69 }, end: { line: 127, character: 70 } }, + description: 'Complete instanceType parameter', + }, + { + action: 'type', + content: ` + ]}, + "AssertDescription": "Instance type must be valid for region" + }, + { + "Assert": {"Fn::And": [ + {"Fn::Not": [{"Fn::Equals": [{"Ref": "D"}]}]}`, + position: { line: 127, character: 84 }, + description: 'Authored Parameter', + verification: { + position: { line: 133, character: 50 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['DatabasePassword']) + .build(), + }, + }, + { + action: 'delete', + range: { start: { line: 133, character: 51 }, end: { line: 133, character: 57 } }, + description: 'Remove ]}', + }, + { + action: 'type', + content: `atabasePassword"}, ""]}]}, + {"Fn::N`, + position: { line: 133, character: 51 }, + description: 'Intrinsic function name', + verification: { + position: { line: 134, character: 18 }, + expectation: CompletionExpectationBuilder.create().expectContainsItems(['Fn::Not']).build(), + }, + }, + { + action: 'type', + content: `ot": [{"Fn::E"}`, + position: { line: 134, character: 19 }, + description: 'Intrinsic function name', + verification: { + position: { line: 134, character: 31 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['Fn::Equals']) + .build(), + }, + }, + { + action: 'delete', + range: { start: { line: 134, character: 32 }, end: { line: 134, character: 34 } }, + description: 'Remove "}', + }, + { + action: 'type', + content: `quals": [{"Ref": "V"}]}]}`, + position: { line: 134, character: 34 }, + description: 'Intrinsic function name', + verification: { + position: { line: 134, character: 52 }, + expectation: CompletionExpectationBuilder.create().expectContainsItems(['VpcCidr']).build(), + }, + }, + { + action: 'replace', + content: `pcCidr"}, ""]}]}`, + range: { start: { line: 134, character: 51 }, end: { line: 134, character: 59 } }, + description: 'Complete intrinsic function name and conditional argument', + }, + { + action: 'type', + content: `, + "ValidateParameterCombinations": { + "Assertions": [ + { + "Assert": {"Fn::Or": [ + {"Fn::Equals": [{"Ref": "EnvironmentName"}, "development"]}, + {"Fn::And": [ + {"Fn::Not": [{"Fn::Equals": [{"Ref": "EnvironmentName"}, "development"]}]}, + {"Fn::Not": [{"Fn::Equals": [{"Fn::Select": [1, {"Ref": "A"}]}, ""]}]} + ]} + ]}, + "AssertDescription": "Non-development environments must specify multiple availability zones" + } + ] + }`, + position: { line: 138, character: 5 }, + description: 'Authored Parameter', + verification: { + position: { line: 146, character: 71 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['AvailabilityZones']) + .build(), + }, + }, + { + action: 'replace', + content: `AvailabilityZones`, + range: { start: { line: 146, character: 71 }, end: { line: 146, character: 72 } }, + description: 'Replace A with AvailabilityZones', + }, + { + action: 'type', + content: ` + }, + "Resources": { + "VPC": { + "Type": "AWS:: + }`, + position: { line: 152, character: 5 }, + description: 'Add Resources section with VPC resource type', + verification: { + position: { line: 156, character: 18 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['AWS::EC2::VPC']) + .build(), + }, + }, + { + action: 'type', + content: `EC2::VPC", + "Properties":`, + position: { line: 156, character: 20 }, + description: 'Complete VPC resource type and add Properties', + verification: { + position: { line: 157, character: 20 }, + expectation: CompletionExpectationBuilder.create().expectItems([]).build(), + }, + }, + { + action: 'type', + content: ` { + "" + }`, + position: { line: 157, character: 20 }, + description: 'Add newline and start property name', + verification: { + position: { line: 158, character: 8 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['CidrBlock']) + .build(), + }, + }, + { + action: `type`, + content: `Ci`, + position: { line: 158, character: 9 }, + description: 'Complete property name', + verification: { + position: { line: 158, character: 10 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['CidrBlock']) + .build(), + }, + }, + { + action: 'delete', + range: { start: { line: 158, character: 11 }, end: { line: 158, character: 12 } }, + description: 'Remove closing quote to continue property name', + }, + { + action: 'type', + content: `drBlock": {"Ref": "Vpc"}`, + position: { line: 158, character: 11 }, + description: 'Complete CidrBlock property with Ref to parameter', + verification: { + position: { line: 158, character: 32 }, + expectation: CompletionExpectationBuilder.create().expectContainsItems(['VpcCidr']).build(), + }, + }, + { + action: 'delete', + range: { start: { line: 158, character: 33 }, end: { line: 158, character: 35 } }, + description: 'Remove closing }', + }, + { + action: 'type', + content: `Cidr"}, + "Enable"`, + position: { line: 158, character: 33 }, + description: 'Add Enable properties to test DNS-related autocomplete', + verification: { + position: { line: 159, character: 15 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['EnableDnsHostnames', 'EnableDnsSupport']) + .expectExcludesItems(['CidrBlock']) + .build(), + }, + }, + { + action: 'replace', + content: `DnsHostnames": true, + "EnableDnsSupport": true, + "Tags": [ + { + "" + } + ]`, + range: { start: { line: 159, character: 15 }, end: { line: 159, character: 16 } }, + description: 'Should provide keys when at possible value or key location inside array', + verification: { + position: { line: 163, character: 12 }, + expectation: CompletionExpectationBuilder.create().expectItems(['Key', 'Value']).build(), + }, + }, + { + action: 'delete', + range: { start: { line: 163, character: 13 }, end: { line: 163, character: 14 } }, + description: 'Remove closing quote to continue property name', + }, + { + action: 'type', + content: `Key": "Name", + "Value": {"Fn::Sub": "\${E}"}`, + position: { line: 163, character: 13 }, + description: 'Sub using authored parameter', + verification: { + position: { line: 164, character: 36 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['EnvironmentName']) + .build(), + }, + }, + { + action: 'delete', + range: { start: { line: 164, character: 37 }, end: { line: 164, character: 40 } }, + description: 'Remove }"', + }, + { + action: 'type', + content: `nvironmentName}-vpc"} + }, + { + "K"`, + position: { line: 164, character: 39 }, + description: 'Nested resource property', + verification: { + position: { line: 167, character: 13 }, + expectation: CompletionExpectationBuilder.create().expectContainsItems(['Key']).build(), + }, + }, + { + action: 'delete', + range: { start: { line: 167, character: 14 }, end: { line: 167, character: 15 } }, + description: 'Remove closing quote to continue property name', + }, + { + action: 'type', + content: `ey": "Environment", + "Value": {"Ref": "EnvironmentName"}`, + position: { line: 167, character: 14 }, + description: 'Resource field expect ones already defined', + }, + + { + action: 'type', + content: `, + "M"`, + position: { line: 171, character: 7 }, + description: 'Add Metadata section', + verification: { + position: { line: 172, character: 5 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['Metadata']) + .expectExcludesItems(['Type', 'Properties']) + .build(), + }, + }, + { + action: 'delete', + range: { start: { line: 172, character: 7 }, end: { line: 172, character: 8 } }, + description: 'Remove closing quote to continue property name', + }, + { + action: 'type', + content: `etadata": { + "Purpose": "Main VPC", + "CreatedBy": "CloudFormation" + } + }, + "PublicSubnet": { + "Type": "AWS::EC2::S`, + position: { line: 172, character: 6 }, + description: 'Resource type', + verification: { + position: { line: 178, character: 25 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['AWS::EC2::SecurityGroup', 'AWS::EC2::Subnet']) + .build(), + }, + }, + { + action: 'replace', + content: `ubnet"`, + range: { start: { line: 178, character: 26 }, end: { line: 178, character: 27 } }, + description: '', + }, + { + action: 'type', + content: `, + "Properties": { + "VpcId": {"Ref": "VPC"}, + "CidrBlock": {"Fn::Select": [0, {"Ref": "SubnetCidrs"}]}, + "AvailabilityZone": {"Fn::Select": [0, {"Ref": "AvailabilityZones"}]}, + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": {"Fn::Sub": [ + "\${EnvName}-public-subnet-\${AZ}", + { + "EnvName": {"Ref": "EnvironmentName"}, + "AZ": {"Fn::Select": [0, {"Ref": "AvailabilityZones"}]} + } + ]} + }, + { + "Key": "Type", + "Value": "Public" + } + ] + }, + "Condition": "Is`, + position: { line: 178, character: 33 }, + description: 'Condition usage within Resource', + verification: { + position: { line: 201, character: 21 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems([ + 'IsProduction', + 'IsNotProduction', + 'IsDevelopment', + 'IsProductionOrStaging', + ]) + .build(), + }, + }, + { + action: 'type', + content: `ProductionOrStaging"`, + position: { line: 201, character: 22 }, + description: 'Complete condition', + }, + { + action: 'type', + content: `, + "WebSecurityGroup": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "Security group with complex rules", + "VpcId": {"Ref": "VPC"}, + "SecurityGroupIngress": [ + { + "Ip" + } + ] + } + }`, + position: { line: 202, character: 5 }, + description: 'Suggest properties from $ref #/definitions/* under items from resource schema', + verification: { + position: { line: 210, character: 14 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['IpProtocol']) + .build(), + }, + }, + { + action: 'delete', + range: { start: { line: 210, character: 15 }, end: { line: 210, character: 16 } }, + description: 'Remove closing quote to continue property name', + }, + { + action: 'type', + content: `Protocol": "tcp", + "FromPort": 80, + "ToPort": 80, + "CidrIp": "0.0.0.0/0", + "Description": "HTTP access" + }, + { + "IpProtocol": "tcp", + "FromPort": 443, + "ToPort": 443, + "CidrIp": "0.0.0.0/0", + "Description": "HTTPS access" + }, + { + "IpProtocol": "tcp", + "FromPort": 22, + "ToPort": 22, + "SourceSecurityGroupId": {"Ref": "BastionSecurityGroup"}, + "Description": "SSH from bastion" + } + ], + "SecurityGroupEgress": [ + { + "IpProtocol": "-1", + "CidrIp": "0.0.0.0/0" + } + ], + "Tags": [ + { + "Key": "Name", + "Value": {"Fn::Sub": "\${EnvironmentName}-web-sg"}`, + position: { line: 210, character: 15 }, + description: 'Complete properties', + }, + { + action: 'type', + content: `, + "BastionSecurityGroup": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "Bastion security group", + "Vpc" + } + }`, + position: { line: 244, character: 5 }, + description: 'Resource property suggestion and omission of already authored property', + verification: { + position: { line: 249, character: 13 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['VpcId']) + .expectExcludesItems(['GroupDescription']) + .build(), + }, + }, + { + action: 'replace', + content: `Id": {"Ref": "V"}`, + range: { start: { line: 249, character: 12 }, end: { line: 249, character: 13 } }, + description: 'Suggest resource logical ids when using Fn::Ref in Resource section', + verification: { + position: { line: 249, character: 28 }, + expectation: CompletionExpectationBuilder.create().expectContainsItems(['VPC']).build(), + }, + }, + { + action: `type`, + content: `PC`, + position: { line: 249, character: 27 }, + description: 'Complete resource logical id', + }, + { + action: 'type', + content: `, + "SecurityGroupIngress": [ + { + "IpProtocol": "tcp", + "FromPort": 22, + "ToPort": 22, + "CidrIp": "0.0.0.0/0" + } + ]`, + position: { line: 249, character: 31 }, + description: 'Complete resource property', + }, + { + action: 'type', + content: `, + "LaunchTemplate": { + "Type": "AWS::EC2::LaunchTemplate", + "Properties": { + "LaunchTemplateName": {"Fn::Sub": "\${EnvironmentName}-template"}, + "LaunchTemplateData": { + "ImageId": {"Fn::FindInMap": ["RegionMap", {"Ref": "AWS::Region"}, "AMI"]}, + "InstanceType": {"Fn::FindInMap": ["RegionMap", {"Ref": "AWS::Region"}, "InstanceType"]}, + "SecurityGroupIds": [ + {"Ref": "WebSecurityGroup"} + ], + "TagSpecifications": [ + { + "ResourceType": "instance", + "Tags": [ + { + "Key": "Name", + "Value": {"Fn::Sub": "\${EnvironmentName}-instance"} + }, + { + "Key": "Environment", + "Value": {"Ref": "EnvironmentName"} + } + ] + } + ] + } + }, + "Me" + }`, + position: { line: 259, character: 5 }, + description: 'Suggest resource entity fields expect ones already defined', + verification: { + position: { line: 287, character: 8 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['Metadata']) + .expectExcludesItems(['Type', 'Properties']) + .build(), + }, + }, + { + action: 'delete', + range: { start: { line: 287, character: 9 }, end: { line: 287, character: 10 } }, + description: 'Remove closing quote to continue property name', + }, + { + action: 'type', + content: `tadata": { + "AWS::CloudFormation::Designer": { + "id": "launch-template-id" + } + }`, + position: { line: 287, character: 9 }, + description: 'Add Metadata section', + }, + { + action: 'type', + content: `, + "AutoScalingGroup": { + "Type": "AWS::AutoScaling::AutoScalingGroup", + "Properties": { + "AutoScalingGroupName": {"Fn::Sub": "\${EnvironmentName}-asg"}, + "LaunchTemplate": { + "" + } + } + }`, + position: { line: 292, character: 5 }, + description: 'Suggest sub properties when first key in nested object', + verification: { + position: { line: 298, character: 10 }, + expectation: CompletionExpectationBuilder.create().expectItems(['Version']).build(), + }, + }, + { + action: 'replace', + content: `LaunchTemplateId": {"Ref": "LaunchTemplate"}, + ""`, + range: { start: { line: 298, character: 11 }, end: { line: 298, character: 12 } }, + description: 'Suggest other keys when one key is already present', + verification: { + position: { line: 299, character: 10 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['Version']) + .expectExcludesItems(['LaunchTemplateId']) + .build(), + }, + }, + { + action: 'replace', + content: `Version": {"Fn::GetAtt": ["L"]}`, + range: { start: { line: 299, character: 11 }, end: { line: 299, character: 12 } }, + description: + 'Suggest resource logical id when using Fn::GetAtt. Omit the resource being authored', + verification: { + position: { line: 299, character: 38 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['LaunchTemplate', 'PublicSubnet']) + .expectExcludesItems(['AutoScalingGroup']) + .build(), + }, + }, + { + action: 'type', + content: `aunchTemplate", "LatestVersionNumber`, + position: { line: 299, character: 39 }, + description: 'Complete resource logical id', + }, + { + action: 'type', + content: `, + "DesiredCapacity": {"Ref": "InstanceCount"}, + "VPCZoneIdentifier": [ + {"Fn::If": ["HasMultipleAZs", {"Ref": "PublicSubnet"}, {"Ref": "PublicSubnet"}]} + ], + "HealthCheckType": "ELB", + "HealthCheckGracePeriod": 300, + "Tags": [ + { + "Key": "Name", + "Value": {"Fn::Sub": "\${EnvironmentName}-asg"}, + "PropagateAtLaunch": false + }, + { + "Key": "Environment", + "Value": {"Ref": "EnvironmentName"}, + "PropagateAtLaunch": true + } + ]`, + position: { line: 300, character: 9 }, + description: 'Suggest Resource entity field UpdatePolicy', + }, + { + action: 'type', + content: `, + "Up"`, + position: { line: 319, character: 7 }, + description: 'Suggest UpdatePolicy entity field', + verification: { + position: { line: 320, character: 8 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['UpdatePolicy']) + .build(), + }, + }, + { + action: 'replace', + content: `datePolicy": { + "AutoScalingRollingUpdate": { + "MinInstancesInService": 1, + "MaxBatchSize": 2, + "PauseTime": "PT5M", + "WaitOnResourceSignals": true + } + }, + "C"`, + range: { start: { line: 320, character: 9 }, end: { line: 320, character: 10 } }, + description: 'Suggest Resource entity field CreationPolicy', + verification: { + position: { line: 328, character: 7 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['CreationPolicy', 'Condition']) + .build(), + }, + }, + { + action: 'replace', + content: `reationPolicy": { + "ResourceSignal": { + "Count": {"Ref": "InstanceCount"}, + "Timeout": "PT10M" + } + } + }, + "Database": { + "Type": "AWS::RDS::DBInstance", + "Properties": { + "DBInstanceIdentifier": {"Fn::Sub": "\${EnvironmentName}-database"}, + "DBInstanceClass": {"Fn::FindInMap": ["EnvironmentMap", {"Ref": "EnvironmentName"}, "DatabaseSize"]}, + "Engine": "mysql", + "EngineVersion": "8.0", + "AllocatedStorage": {"Fn::If": ["Is"]} + }`, + range: { start: { line: 328, character: 8 }, end: { line: 328, character: 9 } }, + description: 'Suggest condition in first argument of Fn::If', + verification: { + position: { line: 342, character: 42 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems([ + 'IsProduction', + 'IsNotProduction', + 'IsDevelopment', + 'IsProductionOrStaging', + ]) + .expectExcludesItems(['ComplexCondition', 'HasMultipleAZs']) + .build(), + }, + }, + { + action: 'replace', + content: `Production", 100, 20]}, + "StorageType": "gp2", + "StorageEncrypted": {"Fn::If": ["IsProduction", true, false]}, + "MasterUsername": "admin", + "MasterUserPassword": {"Ref": "D"}`, + range: { start: { line: 342, character: 43 }, end: { line: 342, character: 46 } }, + description: 'Ref a parameter while defining a resource property', + verification: { + position: { line: 346, character: 39 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['DatabasePassword']) + .build(), + }, + }, + { + action: 'replace', + content: `atabasePassword"}, + "VPCSecurityGroups": [ + {"Ref": "DatabaseSecurityGroup"} + ], + "BackupRetentionPeriod": {"Fn::If": ["IsProduction", 7, 1]}, + "MultiAZ": {"Condition": "Is"}`, + range: { start: { line: 346, character: 40 }, end: { line: 346, character: 42 } }, + description: 'Suggest condition after Condition while defining resource property', + verification: { + position: { line: 351, character: 36 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems([ + 'IsProduction', + 'IsNotProduction', + 'IsDevelopment', + 'IsProductionOrStaging', + ]) + .expectExcludesItems(['ComplexCondition', 'HasMultipleAZs']) + .todo('Returns nothing for both YAML and JSON') + .build(), + }, + }, + { + action: 'replace', + content: `Production"}, + "PubliclyAccessible": false, + "Tags": [ + { + "Key": "Name", + "Value": {"Fn::Sub": "\${EnvironmentName}-database"} + }, + { + "Key": "Environment", + "Value": {"Ref": "EnvironmentName"} + } + ] + }, + "DeletionPolicy": {"Fn::If": ["IsProduction", "S"]}`, + range: { start: { line: 351, character: 36 }, end: { line: 351, character: 38 } }, + description: 'Suggest DeletionPolicy option Snapshot for resources that support snapshot', + verification: { + position: { line: 364, character: 53 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['Snapshot']) + .todo('Returns nothing for both JSON and YAML') + .build(), + }, + }, + { + action: 'delete', + range: { start: { line: 364, character: 54 }, end: { line: 364, character: 57 } }, + description: 'Remove "]}', + }, + { + action: 'type', + content: `napshot", "Delete"]}, + "UpdateReplacePolicy": {"Fn::If": ["IsProduction", "Snapshot", "Delete"]} + }, + "DatabaseSecurityGroup": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "Database security group", + "VpcId": {"Ref": "VPC"}, + "SecurityGroupIngress": [ + { + "IpProtocol": "tcp", + "FromPort": 3306, + "ToPort": 3306, + "SourceSecurityGroupId": {"Ref": "WebSecurityGroup"} + } + ]`, + position: { line: 364, character: 54 }, + description: 'Complete deletion policy, add new security group.', + }, + { + action: 'type', + content: `, + "LambdaFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "FunctionName": {"Fn::Sub": "\${EnvironmentName}-function"}, + "Runtime": "python3.9", + "Handler": "index.lambda_handler", + "Role": {"Fn::GetAtt": ["LambdaRole", "Arn"]}, + "Code": { + "ZipFile": {"Fn::Sub": "import json\\nimport boto3\\n\\ndef lambda_handler(event, context):\\n return {\\n 'statusCode': 200,\\n 'body': json.dumps({\\n 'environment': '\${EnvironmentName}',\\n 'region': '\${AWS::Region}',\\n 'database': '\${Database}'\\n })\\n }"} + }, + "Environment": { + "Variables": { + "ENVIRONMENT": {"Ref": "EnvironmentName"}, + "DATABASE_ENDPOINT": {"Fn::GetAtt": ["Database", ""]} + } + } + } + }`, + position: { line: 381, character: 5 }, + description: 'Suggest readonly properties of Resource as Fn::GetAtt value', + verification: { + position: { line: 395, character: 61 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['Endpoint.Address']) + .build(), + }, + }, + { + action: 'delete', + range: { start: { line: 395, character: 62 }, end: { line: 395, character: 65 } }, + description: 'Remove "]}', + }, + { + action: 'type', + content: `Endpoint.Address"]}, + "LOG_LEVEL": {"Fn::FindInMap": ["EnvironmentMap", {"Ref": "EnvironmentName"}, "LogLevel"]}, + "VPC_ID": {"Ref": "VPC"}`, + position: { line: 395, character: 62 }, + description: 'Complete property of resource, add new variables', + }, + { + action: 'type', + content: `, + "VpcConfig": { + "SecurityGroupIds": [ + {"Ref": "WebSecurityGroup"} + ], + "SubnetIds": [ + {"Ref": "PublicSubnet"} + ] + }, + "Tags": [ + { + "Key": "Name", + "Value": {"Fn::Sub": "\${EnvironmentName}-lambda"} + } + ]`, + position: { line: 398, character: 11 }, + description: 'Complete tags, add new resource', + }, + { + action: 'type', + content: `, + "Condition": "IsProductionOrStaging" + }, + "LambdaRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "RoleName": {"Fn::Sub": "\${EnvironmentName}-lambda-role"}, + "AssumeRolePolicyDocument": { + "Ver" + } + } + }`, + position: { line: 413, character: 11 }, + description: 'Complete IAM role, add new resource', + verification: { + position: { line: 421, character: 14 }, + expectation: CompletionExpectationBuilder.create().expectItems([]).build(), + }, + }, + { + action: 'replace', + content: `sion": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" + ], + "Policies": [ + { + "PolicyName": "DatabaseAccess", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "rds:DescribeDBInstances", + "rds:DescribeDBClusters" + ], + "Resource": {"Fn::Sub": "arn:aws:rds:\${AWS::R"} + } + ] + } + } + ]`, + range: { start: { line: 421, character: 14 }, end: { line: 424, character: 7 } }, + description: + 'suggest pseudo-parameter inside Fn::Sub while authoring deeply nested resource property', + verification: { + position: { line: 448, character: 62 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['AWS::Region']) + .build(), + }, + }, + { + action: 'type', + content: `}`, + position: { line: 456, character: 2 }, + }, + { + action: 'type', + content: `egion}:\\$\{AWS::AccountId}:db:\\$\{Database}`, + position: { line: 448, character: 63 }, + description: 'Complete Fn::Sub, add new resource', + }, + { + action: 'type', + content: `, + "DatabaseAlarm": { + "Type": "AWS::CloudWatch::Alarm", + "Properties": { + "AlarmName": {"Fn::Sub": "\${EnvironmentName}-database-cpu"}, + "AlarmDescription": "Database CPU utilization alarm", + "MetricName": "CPUUtilization", + "Namespace": "AWS/RDS", + "Statistic": "Average", + "Period": 300, + "EvaluationPeriods": 2, + "Threshold": 80, + "ComparisonOperator": "GreaterThanThreshold", + "Dimensions": [ + {"Fn::If": [ + "IsProduction", + { + "" + } + ]} + ] + } + }`, + position: { line: 455, character: 5 }, + description: 'Suggest Keys inside If', + verification: { + position: { line: 472, character: 14 }, + expectation: CompletionExpectationBuilder.create() + .expectItems(['Value', 'Name']) + .todo( + 'Feature testing (only with ctrl+space) returning cached suggestions. Returns nothing in e2e', + ) + .build(), + }, + }, + { + action: 'replace', + content: `"Name": "DBInstanceIdentifier", + "Value": {"Ref": "Database"} + }, + { + "Name": "DBInstanceIdentifier", + ""`, + range: { start: { line: 472, character: 14 }, end: { line: 472, character: 16 } }, + description: 'Suggest Keys inside but filter out existing keys', + verification: { + position: { line: 477, character: 14 }, + expectation: CompletionExpectationBuilder.create() + .expectItems(['Value']) + .todo('Works in functional testing for both comprehensive.json and current doc') + .build(), + }, + }, + { + action: 'replace', + content: `"Value": {"Ref": "Database"}`, + range: { start: { line: 477, character: 14 }, end: { line: 477, character: 16 } }, + description: 'Suggest Values inside but filter out existing values', + }, + { + action: 'type', + position: { line: 480, character: 9 }, + content: `, + "AlarmActions": [ + {"Ref": "SNSTopic"} + ], + "TreatMissingData": "notBreaching" + }, + "Condition": "IsProduction" + }, + "SNSTopic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": {"Fn::Sub": "\${EnvironmentName}-alerts"}, + "DisplayName": {"Fn::Sub": "\${EnvironmentName} Environment Alerts"}, + "KmsMasterKeyId": "alias/aws/sns", + "Tags": [ + { + "Key": "Name", + "Value": {"Fn::Sub": "\${EnvironmentName}-alerts"} + }, + { + "Key": "Environment", + "Value": {"Ref": "EnvironmentName"} + } + ] + } + }, + "Bucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "my-bucket", + "":`, + description: 'Do not resuggest only BucketName', + verification: { + position: { line: 510, character: 8 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['BucketEncryption']) + .expectExcludesItems(['BucketName']) + .todo('Works in functional testing for both comprehensive.json and current doc') + .build(), + }, + }, + { + action: 'replace', + range: { start: { line: 510, character: 8 }, end: { line: 510, character: 11 } }, + content: `"VersioningConfiguration": { + "Status": + }`, + description: 'Suggest enum values when at null location', + verification: { + position: { line: 511, character: 19 }, + expectation: CompletionExpectationBuilder.create() + .expectItems(['Enabled', 'Suspended']) + .todo('Returns nothing') + .build(), + }, + }, + { + action: 'type', + content: ` + `, + position: { line: 511, character: 19 }, + description: 'Suggest enum values when in nested value position', + verification: { + position: { line: 512, character: 12 }, + expectation: CompletionExpectationBuilder.create() + .expectItems(['Enabled', 'Suspended']) + .todo('Returns nothing') + .build(), + }, + }, + { + action: 'type', + content: `"Enabled"`, + position: { line: 511, character: 19 }, + }, + { + action: 'type', + content: `, + "O"`, + position: { line: 516, character: 4 }, + description: 'Suggest Outputs top-level-section and omit authored sections', + verification: { + position: { line: 517, character: 3 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['Outputs']) + .expectExcludesItems(['Conditions', 'Resources', 'Transform', 'Description']) + .build(), + }, + }, + { + action: 'replace', + content: `"Outputs": { + "VPCId": { + "Description": "ID of the VPC", + "V" + } + }`, + range: { start: { line: 517, character: 2 }, end: { line: 517, character: 5 } }, + description: 'Suggest Output entity field Value', + verification: { + position: { line: 520, character: 7 }, + expectation: CompletionExpectationBuilder.create() + .expectItems(['Value']) + .todo('Works in functional testing for both comprehensive.json and current doc') + .build(), + }, + }, + { + action: 'replace', + content: `"Value": {"Ref": "VPC"}, + "Export": { + "Name": {"Fn::Sub": "\${EnvironmentName}-VPC-ID"} + } + }, + "VPCCidr": { + "Description": "CIDR block of the VPC", + "Value": {"Fn::GetAtt": ["VPC", "CidrBlock"]}, + "Export": { + "Name": {"Fn::Sub": "\${EnvironmentName}-VPC-CIDR"} + } + }, + "DatabaseEndpoint": { + "Description": "RDS database endpoint", + "Value": {"Fn::GetAtt": ["Database", "Endpoint.Address"]}, + "Export": { + "Name": {"Fn::Sub": "\${EnvironmentName}-Database-Endpoint"} + }, + "Condition": "Is"`, + range: { start: { line: 520, character: 6 }, end: { line: 520, character: 9 } }, + description: 'Condition usage within Outputs', + verification: { + position: { line: 538, character: 21 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems([ + 'IsProduction', + 'IsNotProduction', + 'IsDevelopment', + 'IsProductionOrStaging', + ]) + .expectExcludesItems(['ComplexCondition', 'HasMultipleAZs']) + .build(), + }, + }, + { + action: 'type', + content: `ProductionOrStaging`, + position: { line: 538, character: 22 }, + }, + { + action: 'type', + content: `, + "ComplexOutput": { + "Description": "Complex output demonstrating multiple functions", + "Value": {"Fn::Sub": [ + "Environment: \${Environment}\\nVPC: \${VpcId} (\${VpcCidr})\\nSubnets: \${SubnetList}\\nDatabase: \${DbEndpoint}:\${DbPort}\\nInstance Count: \${InstanceCount}\\nRegion AZs: \${AvailabilityZones}\\nMax Scaling: \${MaxScaling}", + { + "Environment": {"Ref": "E"} + } + ]} + }`, + position: { line: 539, character: 5 }, + description: 'Ref parameter inside Fn::Sub', + verification: { + position: { line: 545, character: 35 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['EnvironmentName']) + .build(), + }, + }, + { + action: 'replace', + content: `nvironmentName"}, + "VpcId": {"Ref": "VPC"}, + "VpcCidr": {"Fn::GetAtt": ["VPC", ""]}`, + range: { start: { line: 545, character: 35 }, end: { line: 545, character: 37 } }, + description: 'GetAtt attribute returns all attributes', + verification: { + position: { line: 547, character: 45 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['DefaultNetworkAcl', 'CidrBlockAssociations']) + .build(), + }, + }, + { + action: 'replace', + content: `CidrBlock"]}, + "SubnetList": {"Fn::Join": [", ", {"Ref": "SubnetCidrs"}]}, + "DbEndpoint": {"Fn::GetAtt": ["Database", "Endpoint.Address"]}, + "DbPort": {"Fn::GetAtt": ["Database", "Endpoint."]}`, + range: { start: { line: 547, character: 45 }, end: { line: 547, character: 48 } }, + description: 'Ref parameter inside Fn::Sub', + verification: { + position: { line: 549, character: 54 }, + expectation: CompletionExpectationBuilder.create() + .expectContainsItems(['Endpoint.Address', 'Endpoint.Port']) + .build(), + }, + }, + { + action: 'replace', + range: { start: { line: 550, character: 58 }, end: { line: 550, character: 61 } }, + content: `Port"]}, + "InstanceCount": {"Ref": "InstanceCount"}, + "AvailabilityZones": {"Fn::Join": [", ", {"Ref": "AvailabilityZones"}]}`, + description: 'Complete GetAtt Ref', + }, + { + action: 'type', + content: `, + "ConditionalOutput": { + "Description": "Output that only exists in production", + "Value": {"Fn::If": [ + "IsProduction", + {"Fn::Sub": [ + "Production environment in \${Region} with \${Count} instances", + { + "Region": {"Ref": "AWS::Region"}, + "Count": {"Ref": "InstanceCount"} + } + ]}, + {"Ref": "AWS::NoValue"} + ]}, + "Condition": "IsProduction" + }, + "NestedFunctionOutput": { + "Description": "Output with deeply nested functions", + "Value": {"Fn::Select": [ + 0, + {"Fn::Split": [ + ",", + {"Fn::Sub": [ + "\${First},\${Second},\${Third}", + { + "First": {"Fn::FindInMap": ["RegionMap", {"Ref": "AWS::Region"}, "A"]} + } + ]} + ]} + ]} + }`, + position: { line: 555, character: 5 }, + description: 'suggest Mapping second level key in deeply nested intrinsic function', + verification: { + position: { line: 580, character: 79 }, + expectation: CompletionExpectationBuilder.create() + .expectItems(['AMI']) + .todo('Works in functional testing for both comprehensive.json and current doc') + .build(), + }, + }, + { + action: 'delete', + range: { start: { line: 580, character: 81 }, end: { line: 580, character: 84 } }, + description: 'Remove "]}', + }, + + { + action: 'type', + content: `MI"]}, + "Second": {"Fn::GetAtt": ["Database", "Endpoint.Address"]}, + "Th`, + position: { line: 580, character: 81 }, + description: 'suggest substitution variable in second arg of Fn::Sub based on first arg', + verification: { + position: { line: 582, character: 17 }, + expectation: CompletionExpectationBuilder.create() + .expectItems(['Third']) + .todo('Returns nothing for both JSON and YAML') + .build(), + }, + }, + { + action: 'type', + content: `ird": "placeholder"`, + position: { line: 582, character: 18 }, + description: 'Complete the JSON template', + verification: { + position: { line: 576, character: 35 }, + expectation: CompletionExpectationBuilder.create().expectItems([]).build(), + }, + }, ], }; template.executeScenario(scenario); diff --git a/tst/e2e/autocomplete/YamlBlankLinesCompletion.test.ts b/tst/e2e/autocomplete/YamlBlankLinesCompletion.test.ts deleted file mode 100644 index a22a10b7..00000000 --- a/tst/e2e/autocomplete/YamlBlankLinesCompletion.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { describe, it } from 'vitest'; -import { DocumentType } from '../../../src/document/Document'; -import { CompletionExpectationBuilder, TemplateBuilder, TemplateScenario } from '../../utils/TemplateBuilder'; - -describe('YAML End of File Properties', () => { - const content = `Resources: - TestPolicy: - Type: AWS::SSM::Parameter - Properties: - T`; - - describe('No empty lines start', () => { - const template = new TemplateBuilder(DocumentType.YAML); - - it('Property key Type', () => { - const scenario: TemplateScenario = { - name: 'No empty lines Type property key', - steps: [ - { - action: 'initialize', - content: content, - verification: { - position: { line: 4, character: 7 }, - expectation: CompletionExpectationBuilder.create() - .expectContainsItems([ - 'Type', - 'Tier', - 'Tags', - 'DataType', - 'Description', - 'AllowedPattern', - ]) - .build(), - }, - }, - ], - }; - template.executeScenario(scenario); - }); - - it('Property key Value', () => { - const scenario: TemplateScenario = { - name: 'No empty lines Value property key', - steps: [ - { - action: 'initialize', - content: `${content}ype: String\n V`, - verification: { - position: { line: 5, character: 7 }, - expectation: CompletionExpectationBuilder.create().expectContainsItems(['Value']).build(), - }, - }, - ], - }; - template.executeScenario(scenario); - }); - }); - - describe('Empty lines start', () => { - const template = new TemplateBuilder(DocumentType.YAML); - - it('Property key Type', () => { - const scenario: TemplateScenario = { - name: 'Empty lines Type property key', - steps: [ - { - action: 'initialize', - content: `\n${content}`, - verification: { - position: { line: 5, character: 7 }, - expectation: CompletionExpectationBuilder.create() - .expectContainsItems([ - 'Type', - 'Tier', - 'Tags', - 'DataType', - 'Description', - 'AllowedPattern', - ]) - .build(), - }, - }, - ], - }; - template.executeScenario(scenario); - }); - - it('Property key Value', () => { - const scenario: TemplateScenario = { - name: 'Empty lines Value property key', - steps: [ - { - action: 'initialize', - content: `\n${content}ype: String\n V`, - verification: { - position: { line: 6, character: 7 }, - expectation: CompletionExpectationBuilder.create().expectContainsItems(['Value']).build(), - }, - }, - ], - }; - template.executeScenario(scenario); - }); - }); - - describe('Blank lines in middle of document', () => { - const template = new TemplateBuilder(DocumentType.YAML); - - it('Blank line after Properties before Tags', () => { - const scenario: TemplateScenario = { - name: 'Blank line after Properties in middle of document', - steps: [ - { - action: 'initialize', - content: `Parameters: - Type: - Type: String -Resources: - Bucket: - Type: AWS::S3::Bucket - Properties: - - Tags: - - Key: test - Parameter: - Type: AWS::SSM::Parameter - Properties: - Type: String - Value: test`, - verification: { - position: { line: 7, character: 6 }, - expectation: CompletionExpectationBuilder.create() - .expectContainsItems([ - 'BucketName', - 'VersioningConfiguration', - 'PublicAccessBlockConfiguration', - ]) - .build(), - }, - }, - ], - }; - template.executeScenario(scenario); - }); - - it('Blank line after Tags array item before next resource', () => { - const scenario: TemplateScenario = { - name: 'Blank line after Tags array item in middle of document', - steps: [ - { - action: 'initialize', - content: `Parameters: - Type: - Type: String -Resources: - Bucket: - Type: AWS::S3::Bucket - Properties: - Tags: - - Key: test - - Parameter: - Type: AWS::SSM::Parameter - Properties: - Type: String - Value: test`, - verification: { - position: { line: 9, character: 6 }, - expectation: CompletionExpectationBuilder.create() - .expectContainsItems([ - 'BucketName', - 'VersioningConfiguration', - 'PublicAccessBlockConfiguration', - ]) - .build(), - }, - }, - ], - }; - template.executeScenario(scenario); - }); - }); - - describe('Comments with empty lines', () => { - const template = new TemplateBuilder(DocumentType.YAML); - - it('Empty line after comment should provide property completions', () => { - const scenario: TemplateScenario = { - name: 'Empty line after comment', - steps: [ - { - action: 'initialize', - content: `Resources: - TestPolicy: - Type: AWS::SSM::Parameter - Properties: - # This is a comment - T`, - verification: { - position: { line: 5, character: 7 }, - expectation: CompletionExpectationBuilder.create() - .expectContainsItems([ - 'Type', - 'Tier', - 'Tags', - 'DataType', - 'Description', - 'AllowedPattern', - ]) - .build(), - }, - }, - ], - }; - template.executeScenario(scenario); - }); - - it('Empty line after inline comment should provide remaining property completions', () => { - const scenario: TemplateScenario = { - name: 'Empty line after inline comment', - steps: [ - { - action: 'initialize', - content: `Resources: - TestPolicy: - Type: AWS::SSM::Parameter - Properties: - Type: String # inline comment - V`, - verification: { - position: { line: 5, character: 7 }, - expectation: CompletionExpectationBuilder.create() - .expectContainsItems([ - 'Value', // This should be the primary completion - ]) - .build(), - }, - }, - ], - }; - template.executeScenario(scenario); - }); - - it('Empty line in array after comment should provide key-value completions', () => { - const scenario: TemplateScenario = { - name: 'Empty line in array after comment', - steps: [ - { - action: 'initialize', - content: `Resources: - Bucket: - Type: AWS::S3::Bucket - Properties: - Tags: - # Comment before array item - - - `, - verification: { - position: { line: 7, character: 10 }, - expectation: CompletionExpectationBuilder.create() - .expectContainsItems(['Key', 'Value']) - .build(), - }, - }, - ], - }; - template.executeScenario(scenario); - }); - }); -}); diff --git a/tst/unit/autocomplete/EntityFieldCompletionProvider.test.ts b/tst/unit/autocomplete/EntityFieldCompletionProvider.test.ts index 8c385464..f2539277 100644 --- a/tst/unit/autocomplete/EntityFieldCompletionProvider.test.ts +++ b/tst/unit/autocomplete/EntityFieldCompletionProvider.test.ts @@ -14,7 +14,11 @@ describe('EntityFieldCompletionProvider', () => { describe('Parameter', () => { test('should suggest Default, Description, ConstraintDescription with e as partial string', () => { - const mockContext = createParameterContext('MyParameter', { text: 'e', data: { Type: 'String' } }); + const mockContext = createParameterContext('MyParameter', { + text: 'e', + data: { Type: 'String' }, + propertyPath: ['Parameters', 'MyParameter', 'e'], + }); const result = parameterFieldCompletionProvider.getCompletions(mockContext, mockParams); expect(result).toBeDefined(); expect(result?.length).equal(9); @@ -23,7 +27,11 @@ describe('EntityFieldCompletionProvider', () => { }); test('should be robust against typos and suggest Type when partial string is yp', () => { - const mockContext = createParameterContext('MyParameter', { text: 'yp', data: { Type: undefined } }); + const mockContext = createParameterContext('MyParameter', { + text: 'yp', + data: { Type: undefined }, + propertyPath: ['Parameters', 'MyParameter', 'yp'], + }); const result = parameterFieldCompletionProvider.getCompletions(mockContext, mockParams); expect(result).toBeDefined(); expect(result?.length).equal(1); @@ -37,6 +45,7 @@ describe('EntityFieldCompletionProvider', () => { Type: 'string', Description: 'some description', }, + propertyPath: ['Parameters', 'MyParameter', 'e'], }); const result = parameterFieldCompletionProvider.getCompletions(mockContext, mockParams); expect(result).toBeDefined(); @@ -47,7 +56,11 @@ describe('EntityFieldCompletionProvider', () => { }); test('should suggest all available fields starting with Type (required) when nothing typed yet', () => { - const mockContext = createParameterContext('MyParameter', { text: '', data: { Type: undefined } }); + const mockContext = createParameterContext('MyParameter', { + text: '', + data: { Type: undefined }, + propertyPath: ['Parameters', 'MyParameter', ''], + }); const result = parameterFieldCompletionProvider.getCompletions(mockContext, mockParams); expect(result).toBeDefined(); // All Parameter fields should be suggested when none are defined @@ -80,6 +93,7 @@ describe('EntityFieldCompletionProvider', () => { Description: 'some description', Default: 'default value', }, + propertyPath: ['Parameters', 'MyParameter', ''], }); const result = parameterFieldCompletionProvider.getCompletions(mockContext, mockParams); expect(result).toBeDefined(); @@ -103,7 +117,10 @@ describe('EntityFieldCompletionProvider', () => { describe('Output', () => { test('should suggest export and description with e as partial string', () => { - const mockContext = createOutputContext('MyOutput', { text: 'e' }); + const mockContext = createOutputContext('MyOutput', { + text: 'e', + propertyPath: ['Outputs', 'MyOutput', 'e'], + }); const result = outputFieldCompletionProvider.getCompletions(mockContext, mockParams); expect(result).toBeDefined(); expect(result?.length).equal(2); @@ -112,7 +129,10 @@ describe('EntityFieldCompletionProvider', () => { }); test('should be robust against typos and suggest Export when partial string is xpo', () => { - const mockContext = createOutputContext('MyOutput', { text: 'xpo' }); + const mockContext = createOutputContext('MyOutput', { + text: 'xpo', + propertyPath: ['Outputs', 'MyOutput', 'xpo'], + }); const result = outputFieldCompletionProvider.getCompletions(mockContext, mockParams); expect(result).toBeDefined(); expect(result?.length).equal(1); @@ -125,6 +145,7 @@ describe('EntityFieldCompletionProvider', () => { data: { Description: 'some description', }, + propertyPath: ['Outputs', 'MyOutput', 'e'], }); const result = outputFieldCompletionProvider.getCompletions(mockContext, mockParams); expect(result).toBeDefined(); diff --git a/tst/unit/autocomplete/ResourceEntityCompletionProvider.test.ts b/tst/unit/autocomplete/ResourceEntityCompletionProvider.test.ts index 2b595e3f..dd23629b 100644 --- a/tst/unit/autocomplete/ResourceEntityCompletionProvider.test.ts +++ b/tst/unit/autocomplete/ResourceEntityCompletionProvider.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test, beforeEach } from 'vitest'; import { CompletionParams, CompletionItemKind, CompletionItem, InsertTextFormat } from 'vscode-languageserver'; import { ResourceEntityCompletionProvider } from '../../../src/autocomplete/ResourceEntityCompletionProvider'; import { ResourceAttribute } from '../../../src/context/ContextType'; +import { YamlNodeTypes } from '../../../src/context/syntaxtree/utils/TreeSitterTypes'; import { CombinedSchemas } from '../../../src/schema/CombinedSchemas'; import { ResourceSchema } from '../../../src/schema/ResourceSchema'; import { ExtensionName } from '../../../src/utils/ExtensionConfig'; @@ -23,7 +24,11 @@ describe('ResourceEntityCompletionProvider', () => { }); test('should return resource heading completions when inside Resources section but not inside a resource type', () => { - const mockContext = createResourceContext('MyResource', { text: '' }); + const mockContext = createResourceContext('MyResource', { + text: '', + nodeType: YamlNodeTypes.STRING_SCALAR, + propertyPath: ['Resources', 'MyResource', ''], + }); const result = provider.getCompletions(mockContext, mockParams); @@ -58,7 +63,10 @@ describe('ResourceEntityCompletionProvider', () => { }); test('should return filtered resource heading completions when text is provided', () => { - const mockContext = createResourceContext('MyResource', { text: 'Prop' }); + const mockContext = createResourceContext('MyResource', { + text: '', + propertyPath: ['Resources', 'MyResource', ''], + }); const result = provider.getCompletions(mockContext, mockParams); @@ -72,7 +80,10 @@ describe('ResourceEntityCompletionProvider', () => { }); test('should provide correct insert text for resource properties', () => { - const mockContext = createResourceContext('MyResource', { text: '' }); + const mockContext = createResourceContext('MyResource', { + text: '', + propertyPath: ['Resources', 'MyResource', ''], + }); const completions = provider.getCompletions(mockContext, mockParams); expect(completions).toBeDefined(); @@ -137,7 +148,7 @@ describe('ResourceEntityCompletionProvider', () => { // Setup context with a resource that has a Type const mockContext = createResourceContext('MyInstance', { text: '', - propertyPath: ['Resources', 'MyInstance'], + propertyPath: ['Resources', 'MyInstance', ''], data: { Type: 'AWS::EC2::Instance', }, @@ -154,7 +165,7 @@ describe('ResourceEntityCompletionProvider', () => { expect(propertiesItem).toBeDefined(); // Verify it's a snippet - expect(propertiesItem!.kind).toBe(CompletionItemKind.Snippet); + expect(propertiesItem!.kind).toBe(CompletionItemKind.File); expect(propertiesItem!.insertTextFormat).toBe(InsertTextFormat.Snippet); // Verify data type is object @@ -166,7 +177,7 @@ describe('ResourceEntityCompletionProvider', () => { // Setup context with a resource that has a Type const mockContext = createResourceContext('MyInstance', { text: '', - propertyPath: ['Resources', 'MyInstance'], + propertyPath: ['Resources', 'MyInstance', ''], data: { Type: 'AWS::EC2::Instance', }, @@ -193,7 +204,7 @@ describe('ResourceEntityCompletionProvider', () => { // Setup context with a resource that has a Type const mockContext = createResourceContext('MyInstance', { text: '', - propertyPath: ['Resources', 'MyInstance'], + propertyPath: ['Resources', 'MyInstance', ''], data: { Type: 'AWS::EC2::Instance', }, @@ -218,7 +229,7 @@ describe('ResourceEntityCompletionProvider', () => { // Setup context with a resource that has no Type const mockContext = createResourceContext('MyResource', { text: '', - propertyPath: ['Resources', 'MyResource'], + propertyPath: ['Resources', 'MyResource', ''], data: {}, }); @@ -238,7 +249,7 @@ describe('ResourceEntityCompletionProvider', () => { // Setup context with a resource that has a Type const mockContext = createResourceContext('MyResource', { text: '', - propertyPath: ['Resources', 'MyResource'], + propertyPath: ['Resources', 'MyResource', ''], data: { Type: 'AWS::Unknown::Resource', }, diff --git a/tst/unit/autocomplete/ResourceSectionCompletionProvider.test.ts b/tst/unit/autocomplete/ResourceSectionCompletionProvider.test.ts index 528430c6..991898f6 100644 --- a/tst/unit/autocomplete/ResourceSectionCompletionProvider.test.ts +++ b/tst/unit/autocomplete/ResourceSectionCompletionProvider.test.ts @@ -108,7 +108,10 @@ describe('ResourceSectionCompletionProvider', () => { }); test('should delegate to entity provider when at entity key level', async () => { - const mockContext = createResourceContext('MyResource', { text: '' }); + const mockContext = createResourceContext('MyResource', { + text: '', + propertyPath: ['Resources', 'MyResource', ''], + }); const entityProvider = resourceProviders.get('Entity' as any)!; const mockCompletions = [ { label: 'Type', kind: CompletionItemKind.Property }, @@ -147,7 +150,7 @@ describe('ResourceSectionCompletionProvider', () => { test('should delegate to property provider when at nested entity key level Properties', async () => { const mockContext = createResourceContext('MyBucket', { text: 'Bucket', - propertyPath: ['Resources', 'MyBucket', 'Properties'], + propertyPath: ['Resources', 'MyBucket', 'Properties', 'Bucket'], data: { Type: 'AWS::S3::Bucket', Properties: {}, diff --git a/tst/unit/autocomplete/TopLevelSectionCompletionProvider.test.ts b/tst/unit/autocomplete/TopLevelSectionCompletionProvider.test.ts index d11efd88..2dfc94ac 100644 --- a/tst/unit/autocomplete/TopLevelSectionCompletionProvider.test.ts +++ b/tst/unit/autocomplete/TopLevelSectionCompletionProvider.test.ts @@ -61,7 +61,7 @@ describe('TopLevelSectionCompletionProvider', () => { expect(regularSections).toHaveLength(10); // Should return all snippet sections - const snippetSections = result!.filter((item) => item.kind === CompletionItemKind.Snippet); + const snippetSections = result!.filter((item) => item.kind === CompletionItemKind.File); expect(snippetSections).toHaveLength(5); // Should return all sections without fuzzy search modifications @@ -182,16 +182,16 @@ describe('TopLevelSectionCompletionProvider', () => { // Find snippet completions for Resources, Parameters, Outputs, and Conditions const resourcesSnippet = result!.find( - (item) => item.label === 'Resources' && item.kind === CompletionItemKind.Snippet, + (item) => item.label === 'Resources' && item.kind === CompletionItemKind.File, ); const parametersSnippet = result!.find( - (item) => item.label === 'Parameters' && item.kind === CompletionItemKind.Snippet, + (item) => item.label === 'Parameters' && item.kind === CompletionItemKind.File, ); const outputsSnippet = result!.find( - (item) => item.label === 'Outputs' && item.kind === CompletionItemKind.Snippet, + (item) => item.label === 'Outputs' && item.kind === CompletionItemKind.File, ); const conditionsSnippet = result!.find( - (item) => item.label === 'Conditions' && item.kind === CompletionItemKind.Snippet, + (item) => item.label === 'Conditions' && item.kind === CompletionItemKind.File, ); // Verify that all snippet completions exist @@ -218,7 +218,7 @@ describe('TopLevelSectionCompletionProvider', () => { // Find the Resources snippet const resourcesSnippet = result!.find( - (item) => item.label === 'Resources' && item.kind === CompletionItemKind.Snippet, + (item) => item.label === 'Resources' && item.kind === CompletionItemKind.File, ); expect(resourcesSnippet).toBeDefined(); @@ -239,7 +239,7 @@ describe('TopLevelSectionCompletionProvider', () => { // Find the Resources snippet const resourcesSnippet = result!.find( - (item) => item.label === 'Resources' && item.kind === CompletionItemKind.Snippet, + (item) => item.label === 'Resources' && item.kind === CompletionItemKind.File, ); expect(resourcesSnippet).toBeDefined(); @@ -293,7 +293,7 @@ describe('TopLevelSectionCompletionProvider', () => { // Find the Parameters snippet const parametersSnippet = result!.find( - (item) => item.label === 'Parameters' && item.kind === CompletionItemKind.Snippet, + (item) => item.label === 'Parameters' && item.kind === CompletionItemKind.File, ); expect(parametersSnippet).toBeDefined(); @@ -308,7 +308,7 @@ describe('TopLevelSectionCompletionProvider', () => { // Find the Outputs snippet const outputsSnippet = result!.find( - (item) => item.label === 'Outputs' && item.kind === CompletionItemKind.Snippet, + (item) => item.label === 'Outputs' && item.kind === CompletionItemKind.File, ); expect(outputsSnippet).toBeDefined(); @@ -323,7 +323,7 @@ describe('TopLevelSectionCompletionProvider', () => { // Find the Conditions snippet const conditionsSnippet = result!.find( - (item) => item.label === 'Conditions' && item.kind === CompletionItemKind.Snippet, + (item) => item.label === 'Conditions' && item.kind === CompletionItemKind.File, ); expect(conditionsSnippet).toBeDefined(); @@ -341,10 +341,10 @@ describe('TopLevelSectionCompletionProvider', () => { // Verify that defined sections are filtered out from snippets const resourcesSnippet = result!.find( - (item) => item.label === 'Resources' && item.kind === CompletionItemKind.Snippet, + (item) => item.label === 'Resources' && item.kind === CompletionItemKind.File, ); const parametersSnippet = result!.find( - (item) => item.label === 'Parameters' && item.kind === CompletionItemKind.Snippet, + (item) => item.label === 'Parameters' && item.kind === CompletionItemKind.File, ); expect(resourcesSnippet).toBeUndefined(); @@ -352,10 +352,10 @@ describe('TopLevelSectionCompletionProvider', () => { // But other snippets should still be there const outputsSnippet = result!.find( - (item) => item.label === 'Outputs' && item.kind === CompletionItemKind.Snippet, + (item) => item.label === 'Outputs' && item.kind === CompletionItemKind.File, ); const conditionsSnippet = result!.find( - (item) => item.label === 'Conditions' && item.kind === CompletionItemKind.Snippet, + (item) => item.label === 'Conditions' && item.kind === CompletionItemKind.File, ); expect(outputsSnippet).toBeDefined(); @@ -389,7 +389,7 @@ describe('TopLevelSectionCompletionProvider', () => { const result = testProvider.getCompletions(mockContext, mockParams); const resourcesSnippet = result!.find( - (item) => item.label === 'Resources' && item.kind === CompletionItemKind.Snippet, + (item) => item.label === 'Resources' && item.kind === CompletionItemKind.File, ); expect(resourcesSnippet).toBeDefined(); @@ -422,7 +422,7 @@ describe('TopLevelSectionCompletionProvider', () => { const result = testProvider.getCompletions(mockContext, mockParams); const resourcesSnippet = result!.find( - (item) => item.label === 'Resources' && item.kind === CompletionItemKind.Snippet, + (item) => item.label === 'Resources' && item.kind === CompletionItemKind.File, ); expect(resourcesSnippet).toBeDefined(); @@ -457,7 +457,7 @@ describe('TopLevelSectionCompletionProvider', () => { const result = testProvider.getCompletions(mockContext, mockParams); const resourcesSnippet = result!.find( - (item) => item.label === 'Resources' && item.kind === CompletionItemKind.Snippet, + (item) => item.label === 'Resources' && item.kind === CompletionItemKind.File, ); expect(resourcesSnippet).toBeDefined(); @@ -497,7 +497,7 @@ describe('TopLevelSectionCompletionProvider', () => { const result = testProvider.getCompletions(mockContext, mockParams); const resourcesSnippet = result!.find( - (item) => item.label === 'Resources' && item.kind === CompletionItemKind.Snippet, + (item) => item.label === 'Resources' && item.kind === CompletionItemKind.File, ); expect(resourcesSnippet).toBeDefined(); @@ -537,7 +537,7 @@ describe('TopLevelSectionCompletionProvider', () => { const result = testProvider.getCompletions(mockContext, mockParams); const resourcesSnippet = result!.find( - (item) => item.label === 'Resources' && item.kind === CompletionItemKind.Snippet, + (item) => item.label === 'Resources' && item.kind === CompletionItemKind.File, ); expect(resourcesSnippet).toBeDefined(); diff --git a/tst/unit/templates/TemplateWorkflowOperations.test.ts b/tst/unit/templates/TemplateWorkflowOperations.test.ts index 02050bf1..bb16f0fe 100644 --- a/tst/unit/templates/TemplateWorkflowOperations.test.ts +++ b/tst/unit/templates/TemplateWorkflowOperations.test.ts @@ -316,7 +316,7 @@ describe('TemplateWorkflowOperations', () => { expect(result.status).toBe(TemplateStatus.VALIDATION_FAILED); expect(result.result).toBe(WorkflowResult.FAILED); - expect(result.reason).toBe('Validation failed'); + expect(result.reason).toBe('String error'); }); }); }); diff --git a/tst/utils/TemplateBuilder.ts b/tst/utils/TemplateBuilder.ts index c7fb33c7..aa3e325d 100644 --- a/tst/utils/TemplateBuilder.ts +++ b/tst/utils/TemplateBuilder.ts @@ -17,6 +17,7 @@ import { DocumentType } from '../../src/document/Document'; import { DocumentManager } from '../../src/document/DocumentManager'; import { HoverRouter } from '../../src/hover/HoverRouter'; import { SchemaRetriever } from '../../src/schema/SchemaRetriever'; +import { LoggerFactory } from '../../src/telemetry/LoggerFactory'; import { extractErrorMessage } from '../../src/utils/Errors'; import { expectThrow } from './Expect'; import { @@ -27,6 +28,8 @@ import { } from './MockServerComponents'; import { combinedSchemas } from './SchemaUtils'; +const log = LoggerFactory.getLogger('IntrinsicFunctionArgumentCompletionProvider'); + function expectAt(actual: any, position: Position, description?: string) { const positionStr = `${position.line}:${position.character}`; const errorContext = description ? ` ${description}` : ''; @@ -905,8 +908,9 @@ export class CompletionExpectationBuilder { return this; } - todo(): CompletionExpectationBuilder { + todo(comment: string): CompletionExpectationBuilder { this.expectation.todo = true; + log.debug(comment); return this; } diff --git a/webpack.config.js b/webpack.config.js index 047b65f0..6b463e22 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -6,77 +6,55 @@ const webpack = require('webpack'); const fs = require('fs'); const { execSync } = require('child_process'); const path = require('path'); -const { minimatch } = require('minimatch'); const BUNDLE_NAME = 'cfn-lsp-server-standalone'; +const Package = JSON.parse(fs.readFileSync('package.json', 'utf8')); +const PackageLock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8')); const COPY_FILES = ['LICENSE', 'NOTICE', 'THIRD-PARTY-LICENSES.txt', 'README.md']; -const ALWAYS_IGNORE = ['**/@types/**', '**/typescript/**']; -const IGNORE_PATTERNS = [ - // 1. Tests, Specs, & Benchmarks - '**/__tests__/**', - '**/{test,spec}{,s}/**', - '**/*.{spec,test}.{js,jsx,ts,tsx}', - '**/test.js', - '**/spec.js', - '**/bench.js', - '**/benchmark.js', - '**/*bench*/**', - - // 2. Documentation & Project Metadata - '**/{doc,docs,example{,s},demo{,s},fixture{,s}}/**', - '**/{README,CHANGELOG,HISTORY,NOTICE,AUTHORS,CONTRIBUTORS}{,.*}', - '**/README*', - '**/CHANGELOG*', - '**/HISTORY*', - - // 3. Config Files & Tooling - '**/{ts,js}config*.json', - '**/{vite,webpack,rollup,esbuild,parcel,babel,swc}.config.*', - '**/.{babelrc,swcrc,npmrc,yarnrc,nvmrc,env}*', - '**/{.eslint,.prettier,.stylelint}*', - '**/.{editorconfig,gitattributes,jshintrc,npmignore,nojekyll}', - '**/commitlint.config.js', - '**/.release-it*.json', - '**/.husky/**', - '**/.github/**', - '**/.{nycrc,taprc,c8rc}*', - - // 4. Source Code & Type Definitions - '**/*.{ts,cts,mts}', - '**/*.d.{ts,cts,mts}', - '**/*.{flow,coffee,proto,scm}', - - // 5. Assets & Non-Code Files - '**/*.{css,scss,less,styl}', - '**/*.{html,md,txt,markdown,doc,jsdoc}', - '**/*.{png,jpg,jpeg,gif,svg,ico,webp,avif}', - '**/*.{woff,woff2,eot,ttf,otf}', - '**/*.zip', - - // 6. Build System & Misc Files - '**/*.{c,cc,cpp,cxx,h,hpp,hxx,gyp,gypi}', - '**/Makefile*', - '**/CMakeLists.txt', - '**/binding.gyp', - '**/*.{def,in,1}', - - // 7. IDE, System, & Log Files - '**/.{idea,vscode,DS_Store}/**', - '**/{.*.log,*.log}', - '**/.gitkeep', - '**/*.map', - '**/.history/**', - - // 8. Scripts - '**/.bin/**', - '**/*.{cmd,sh}', +const PLATFORMS = ['linux', 'win32', 'darwin']; +const KEEP_FILES = [ + '.cjs', + '.gyp', + '.js', + '.mjs', + '.node', + '.wasm', + 'mappingTable.json', + 'package.json', + 'pyodide-lock.json', + 'python_stdlib.zip', ]; +const IGNORE_PATHS = ['/bin/', '/test/', '/benchmarks/', '/examples/']; + +function generateExternals() { + const externals = Package.externalDependencies; + const collected = new Set(externals); + const queue = [...externals]; + + while (queue.length > 0) { + const dep = queue.shift(); + const pkgInfo = PackageLock.packages?.[`node_modules/${dep}`]; + + if (pkgInfo?.dependencies) { + for (const subDep of Object.keys(pkgInfo.dependencies)) { + if (!collected.has(subDep) && !subDep.startsWith('@types/') && !pkgInfo.dev && !pkgInfo.optional) { + collected.add(subDep); + queue.push(subDep); + } + } + } + } + + for (const dep of Object.keys(Package.nativePrebuilds)) { + collected.add(dep); + } + return Array.from(collected).sort(); +} -// Keeping license for legal -const KEEP_PATTERNS = ['**/python_stdlib.zip', '**/yaml/dist/doc/**']; +const EXTERNALS = generateExternals(); -function createPlugins(isDevelopment, outputPath, mode, env) { +function createPlugins(isDevelopment, outputPath, mode, env, targetPlatform, targetArch) { const plugins = []; plugins.push( @@ -113,20 +91,21 @@ function createPlugins(isDevelopment, outputPath, mode, env) { if (!isDevelopment) { const tmpDir = path.join(__dirname, 'tmp-node-modules'); + console.debug('Working in tmpDir:', tmpDir); plugins.push({ apply: (compiler) => { compiler.hooks.beforeRun.tapAsync('InstallDependencies', (compilation, callback) => { try { - const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); - const tmpPkg = { - ...pkg, + ...Package, main: `./${BUNDLE_NAME}.js`, }; delete tmpPkg['scripts']; delete tmpPkg['devDependencies']; + delete tmpPkg['externalDependencies']; + delete tmpPkg['nativePrebuilds']; if (fs.existsSync(tmpDir)) { fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -135,13 +114,53 @@ function createPlugins(isDevelopment, outputPath, mode, env) { fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(tmpPkg, null, 2)); fs.copyFileSync('package-lock.json', `${tmpDir}/package-lock.json`); - execSync('npm ci --only=prod', { cwd: tmpDir, stdio: 'inherit' }); + execSync('npm ci --omit=dev', { cwd: tmpDir, stdio: 'inherit' }); + const otherDeps = Object.entries(Package.nativePrebuilds) + .filter(([key, _version]) => { + return key.endsWith(`${targetPlatform}-${targetArch}`); + }) + .map(([key, version]) => { + return `${key}@${version}`; + }) + .join(' '); + + execSync(`npm install --save-exact --force ${otherDeps}`, { cwd: tmpDir, stdio: 'inherit' }); callback(); } catch (error) { callback(error); } }); + compiler.hooks.afterEmit.tap('CleanUnusedNativeModules', () => { + const nodeModulesPath = path.join(outputPath, 'node_modules'); + + if (!fs.existsSync(nodeModulesPath)) return; + + function cleanPlatformDirs(dir) { + if (!fs.existsSync(dir)) return; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const entryPath = path.join(dir, entry.name); + const isPlatformDir = PLATFORMS.some((p) => entry.name.includes(`${p}-`)); + const shouldKeep = entry.name.includes(`${targetPlatform}-${targetArch}`); + + if (isPlatformDir && !shouldKeep) { + fs.rmSync(entryPath, { recursive: true, force: true }); + console.log(`Deleted: ${entryPath}`); + } else if (entry.name === 'prebuilds') { + cleanPlatformDirs(entryPath); + } else { + cleanPlatformDirs(entryPath); + } + } + } + + cleanPlatformDirs(nodeModulesPath); + }); + compiler.hooks.done.tap('CleanupTemp', () => { if (fs.existsSync(tmpDir)) { fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -149,7 +168,7 @@ function createPlugins(isDevelopment, outputPath, mode, env) { const dotPackageLock = 'bundle/production/node_modules/.package-lock.json'; if (fs.existsSync(dotPackageLock)) { - fs.rmSync(dotPackageLock); + fs.rmSync(dotPackageLock, { force: true }); } }); }, @@ -162,17 +181,13 @@ function createPlugins(isDevelopment, outputPath, mode, env) { from: path.join('tmp-node-modules', 'node_modules'), to: 'node_modules', filter: (resourcePath) => { - const relativePath = resourcePath.replaceAll(process.cwd(), ''); - - if (ALWAYS_IGNORE.some((pattern) => minimatch(relativePath, pattern))) { - return false; - } - // If matches KEEP_PATTERNS, always include - return ( - KEEP_PATTERNS.some((pattern) => minimatch(relativePath, pattern)) || - // Otherwise, check if it's not ignored - !IGNORE_PATTERNS.some((pattern) => minimatch(relativePath, pattern)) - ); + const relativePath = resourcePath.replace(process.cwd(), ''); + const isExternal = EXTERNALS.some((external) => { + return relativePath.includes(`/node_modules/${external}/`); + }); + const keep = KEEP_FILES.some((pattern) => relativePath.endsWith(pattern)); + const ignore = IGNORE_PATHS.some((pattern) => relativePath.includes(pattern)); + return isExternal && keep && !ignore; }, }, { @@ -189,6 +204,12 @@ function createPlugins(isDevelopment, outputPath, mode, env) { ], }), ); + + plugins.push( + new webpack.IgnorePlugin({ + resourceRegExp: /^@opentelemetry\/(winston-transport|exporter-jaeger)$/, + }), + ); } plugins.push( @@ -201,29 +222,6 @@ function createPlugins(isDevelopment, outputPath, mode, env) { return plugins; } -function createOptimization(isDevelopment) { - const baseOptimization = { - minimize: false, - }; - - if (isDevelopment) { - return { - ...baseOptimization, - removeAvailableModules: false, - removeEmptyChunks: false, - splitChunks: false, - }; - } - - return { - ...baseOptimization, - moduleIds: 'deterministic', - chunkIds: 'deterministic', - usedExports: true, - sideEffects: false, - }; -} - const baseConfig = { target: 'node', entry: { @@ -277,6 +275,8 @@ const baseConfig = { module.exports = (env = {}) => { const mode = env.mode; let awsEnv = env.env; + const targetPlatform = env.platform || process.platform; + const targetArch = env.arch || process.arch; // Validate mode const validModes = ['development', 'production']; @@ -302,7 +302,8 @@ module.exports = (env = {}) => { console.info(`Building server with mode: ${mode}`); console.info(`NODE_ENV: ${mode}`); console.info(`AWS_ENV: ${awsEnv}`); - console.info(`Platform: ${process.platform}-${process.arch}`); + console.info(`Platform: ${targetPlatform}`); + console.info(`Arch: ${targetArch}`); console.info(`Output path: ${outputPath}`); return { @@ -317,8 +318,17 @@ module.exports = (env = {}) => { type: 'commonjs2', }, }, - externals: [nodeExternals()], - optimization: createOptimization(isDevelopment), - plugins: createPlugins(isDevelopment, outputPath, mode, awsEnv), + externals: isDevelopment ? [nodeExternals()] : EXTERNALS, + optimization: { + minimize: false, + moduleIds: 'deterministic', + chunkIds: 'deterministic', + usedExports: true, + sideEffects: false, + splitChunks: { + chunks: 'all', + }, + }, + plugins: createPlugins(isDevelopment, outputPath, mode, awsEnv, targetPlatform, targetArch), }; };