diff --git a/src/context/syntaxtree/SyntaxTree.ts b/src/context/syntaxtree/SyntaxTree.ts index 936647d5..3fe56458 100644 --- a/src/context/syntaxtree/SyntaxTree.ts +++ b/src/context/syntaxtree/SyntaxTree.ts @@ -779,6 +779,10 @@ export abstract class SyntaxTree { return this.tree.rootNode.text; } + public getRootNode() { + return this.tree.rootNode; + } + /** * Find block_mapping that contains keys at the same indentation level as the cursor * Used for creating sibling synthetic keys diff --git a/src/handlers/DocumentHandler.ts b/src/handlers/DocumentHandler.ts index 63914512..3991c396 100644 --- a/src/handlers/DocumentHandler.ts +++ b/src/handlers/DocumentHandler.ts @@ -1,9 +1,8 @@ -import { Edit, Point } from 'tree-sitter'; +import { Point } from 'tree-sitter'; import { DidChangeTextDocumentParams } from 'vscode-languageserver'; import { TextDocumentChangeEvent } from 'vscode-languageserver/lib/common/textDocuments'; import { NotificationHandler } from 'vscode-languageserver-protocol'; import { TextDocument } from 'vscode-languageserver-textdocument'; -import { SyntaxTreeManager } from '../context/syntaxtree/SyntaxTreeManager'; import { CloudFormationFileType, Document } from '../document/Document'; import { createEdit } from '../document/DocumentUtils'; import { LspDocuments } from '../protocol/LspDocuments'; @@ -35,26 +34,9 @@ export function didOpenHandler(components: ServerComponents): (event: TextDocume } } - components.cfnLintService.lintDelayed(content, uri, LintTrigger.OnOpen).catch((reason) => { - // Handle cancellation gracefully - user might have closed/changed the document - if (reason instanceof CancellationError) { - // Do nothing - cancellation is expected behavior - } else { - log.error(reason, `Linting error for ${uri}`); - } - }); + triggerValidation(components, content, uri, LintTrigger.OnOpen, ValidationTrigger.OnOpen); - // Trigger Guard validation - components.guardService.validateDelayed(content, uri, ValidationTrigger.OnOpen).catch((reason) => { - // Handle cancellation gracefully - user might have closed/changed the document - if (reason instanceof Error && reason.message.includes('Request cancelled')) { - // Do nothing - } else { - log.error(reason, `Guard validation error for ${uri}`); - } - }); - - components.documentManager.sendDocumentMetadata(0); + components.documentManager.sendDocumentMetadata(); }; } @@ -64,6 +46,7 @@ export function didChangeHandler( ): NotificationHandler { return (params) => { const documentUri = params.textDocument.uri; + const version = params.textDocument.version; const textDocument = documents.documents.get(documentUri); if (!textDocument) { @@ -71,59 +54,64 @@ export function didChangeHandler( return; } - const content = textDocument.getText(); - const changes = params.contentChanges; - try { - let hasFullDocumentChange = false; - for (const change of changes) { - if ('range' in change) { - // This is an incremental change with a specific range - const start: Point = { - row: change.range.start.line, - column: change.range.start.character, - }; - const end: Point = { - row: change.range.end.line, - column: change.range.end.character, - }; - - const { edit } = createEdit(content, change.text, start, end); - updateSyntaxTree(components.syntaxTreeManager, textDocument, edit); - } else { - hasFullDocumentChange = true; - } - } + // This is the document AFTER changes + const document = new Document(textDocument); + const finalContent = document.getText(); - if (hasFullDocumentChange) { - components.syntaxTreeManager.add(documentUri, content); + const tree = components.syntaxTreeManager.getSyntaxTree(documentUri); + + // Short-circuit if this is not a template (anymore) + if (document.cfnFileType === CloudFormationFileType.Other) { + if (tree) { + // Clean-up if was but no longer is a template + components.syntaxTreeManager.deleteSyntaxTree(documentUri); } - } catch (error) { - log.error(error, `Error updating tree ${documentUri}`); - // Create a new tree if partial updates fail - components.syntaxTreeManager.add(documentUri, content); + components.documentManager.sendDocumentMetadata(); + return; } - // Trigger cfn-lint validation - components.cfnLintService.lintDelayed(content, documentUri, LintTrigger.OnChange, true).catch((reason) => { - // Handle both getTextDocument and linting errors - if (reason instanceof CancellationError) { - // Do nothing - cancellation is expected behavior - } else { - log.error(reason, `Error in didChange processing for ${documentUri}`); + if (tree) { + // This starts as the text BEFORE changes + let currentContent = tree.content(); + try { + const changes = params.contentChanges; + for (const change of changes) { + if ('range' in change) { + // Incremental change + const start: Point = { + row: change.range.start.line, + column: change.range.start.character, + }; + const end: Point = { + row: change.range.end.line, + column: change.range.end.character, + }; + const { edit, newContent } = createEdit(currentContent, change.text, start, end); + components.syntaxTreeManager.updateWithEdit(documentUri, newContent, edit); + currentContent = newContent; + } else { + // Full document change + components.syntaxTreeManager.add(documentUri, change.text); + currentContent = change.text; + } + } + } catch (error) { + log.error({ error, uri: documentUri, version }, 'Error updating tree - recreating'); + components.syntaxTreeManager.add(documentUri, finalContent); } - }); + } else { + // If we don't have a tree yet, just parse the final document + components.syntaxTreeManager.add(documentUri, finalContent); + } - // Trigger Guard validation - components.guardService - .validateDelayed(content, documentUri, ValidationTrigger.OnChange, true) - .catch((reason) => { - // Handle both getTextDocument and validation errors - if (reason instanceof Error && reason.message.includes('Request cancelled')) { - // Do nothing - } else { - log.error(reason, `Error in Guard didChange processing for ${documentUri}`); - } - }); + triggerValidation( + components, + finalContent, + documentUri, + LintTrigger.OnChange, + ValidationTrigger.OnChange, + true, + ); // Republish validation diagnostics if available const validationDetails = components.validationManager @@ -171,40 +159,33 @@ export function didSaveHandler(components: ServerComponents): (event: TextDocume const documentUri = event.document.uri; const documentContent = event.document.getText(); - // Trigger cfn-lint validation - components.cfnLintService.lintDelayed(documentContent, documentUri, LintTrigger.OnSave).catch((reason) => { - if (reason instanceof CancellationError) { - // Do nothing - cancellation is expected behavior - } else { - log.error(reason, `Linting error for ${documentUri}`); - } - }); - - // Trigger Guard validation - components.guardService - .validateDelayed(documentContent, documentUri, ValidationTrigger.OnSave) - .catch((reason) => { - if (reason instanceof Error && reason.message.includes('Request cancelled')) { - // Do nothing - } else { - log.error(reason, `Guard validation error for ${documentUri}`); - } - }); + triggerValidation(components, documentContent, documentUri, LintTrigger.OnSave, ValidationTrigger.OnSave); components.documentManager.sendDocumentMetadata(0); }; } -function updateSyntaxTree(syntaxTreeManager: SyntaxTreeManager, textDocument: TextDocument, edit: Edit) { - const uri = textDocument.uri; - const document = new Document(textDocument); - if (syntaxTreeManager.getSyntaxTree(uri)) { - if (document.cfnFileType === CloudFormationFileType.Other) { - syntaxTreeManager.deleteSyntaxTree(uri); +function triggerValidation( + components: ServerComponents, + content: string, + uri: string, + lintTrigger: LintTrigger, + validationTrigger: ValidationTrigger, + debounce?: boolean, +): void { + components.cfnLintService.lintDelayed(content, uri, lintTrigger, debounce).catch((reason) => { + if (reason instanceof CancellationError) { + // Do nothing - cancellation is expected behavior + } else { + log.error(reason, `Linting error for ${uri}`); + } + }); + + components.guardService.validateDelayed(content, uri, validationTrigger, debounce).catch((reason) => { + if (reason instanceof Error && reason.message.includes('Request cancelled')) { + // Do nothing } else { - syntaxTreeManager.updateWithEdit(uri, document.contents(), edit); + log.error(reason, `Guard validation error for ${uri}`); } - } else { - syntaxTreeManager.addWithTypes(uri, document.contents(), document.documentType, document.cfnFileType); - } + }); } diff --git a/tst/e2e/DocumentHandler.test.ts b/tst/e2e/DocumentHandler.test.ts index cddd4949..b597d254 100644 --- a/tst/e2e/DocumentHandler.test.ts +++ b/tst/e2e/DocumentHandler.test.ts @@ -103,4 +103,107 @@ describe('DocumentHandler', () => { expect(extension.components.documentManager.get(uri)).toBeUndefined(); }); }); + + it('should create syntax tree for template documents on open', async () => { + const content = 'AWSTemplateFormatVersion: "2010-09-09"\nResources: {}'; + + await extension.openDocument({ + textDocument: { + uri, + languageId: 'yaml', + version: 1, + text: content, + }, + }); + + await WaitFor.waitFor(() => { + const tree = extension.components.syntaxTreeManager.getSyntaxTree(uri); + expect(tree).toBeDefined(); + expect(tree?.content()).toBe(content); + }); + }); + + it('should update syntax tree on incremental document changes', async () => { + const initialContent = 'AWSTemplateFormatVersion: "2010-09-09"\nResources: {}'; + + await extension.openDocument({ + textDocument: { + uri, + languageId: 'yaml', + version: 1, + text: initialContent, + }, + }); + + await WaitFor.waitFor(() => { + expect(extension.components.syntaxTreeManager.getSyntaxTree(uri)).toBeDefined(); + }); + + const editRange = { start: { line: 0, character: 35 }, end: { line: 0, character: 37 } }; + const editText = '00'; + const expectedContent = TestExtension.applyEdit(initialContent, editRange, editText); + + await extension.changeDocument({ + textDocument: { uri, version: 2 }, + contentChanges: [ + { + range: editRange, + text: editText, + }, + ], + }); + + await WaitFor.waitFor(() => { + const tree = extension.components.syntaxTreeManager.getSyntaxTree(uri); + expect(tree).toBeDefined(); + expect(tree?.content()).toBe(expectedContent); + }); + }); + + it('should delete syntax tree when document is closed', async () => { + const content = 'AWSTemplateFormatVersion: "2010-09-09"'; + + await extension.openDocument({ + textDocument: { + uri, + languageId: 'yaml', + version: 1, + text: content, + }, + }); + + await WaitFor.waitFor(() => { + expect(extension.components.syntaxTreeManager.getSyntaxTree(uri)).toBeDefined(); + }); + + await extension.closeDocument({ + textDocument: { uri }, + }); + + await WaitFor.waitFor(() => { + expect(extension.components.syntaxTreeManager.getSyntaxTree(uri)).toBeUndefined(); + }); + }); + + it('should not create syntax tree for non-template documents', async () => { + const content = 'someKey: someValue\nanotherKey: anotherValue'; + + await extension.openDocument({ + textDocument: { + uri, + languageId: 'yaml', + version: 1, + text: content, + }, + }); + + await WaitFor.waitFor(() => { + expect(extension.components.documentManager.get(uri)).toBeDefined(); + }); + + // Give it time to potentially create a tree (it shouldn't) + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(extension.components.syntaxTreeManager.getSyntaxTree(uri)).toBeUndefined(); + }); }); diff --git a/tst/resources/templates/sample_template_after_edits.json b/tst/resources/templates/sample_template_after_edits.json new file mode 100644 index 00000000..3302afab --- /dev/null +++ b/tst/resources/templates/sample_template_after_edits.json @@ -0,0 +1,338 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "Template with parameters, mappings, and conditions", + "Parameters": { + "StringParam": { + "Type": "String", + "Default": "default-value", + "Description": "A string parameter", + "AllowedValues": [ + "default-value", + "other-value" + ], + "MinLength": 3, + "MaxLength": 20, + "AllowedPattern": "[a-zA-Z0-9-]+", + "NoEcho": false, + "ConstraintDescription": "Must be alphanumeric with hyphens" + }, + "NumberParam": { + "Type": "Number", + "Default": 42, + "MinValue": 1, + "MaxValue": 100 + }, + "EnvironmentType": { + "Type": "String", + "Default": "dev", + "Description": "Environment type", + "AllowedValues": [ + "dev", + "test", + "prod" + ], + "ConstraintDescription": "Must be dev, test, or prod" + }, + "InstanceType": { + "Type": "String", + "Default": "t2.micro", + "Description": "EC2 instance type", + "AllowedValues": [ + "t2.micro", + "t2.small", + "t2.medium" + ], + "ConstraintDescription": "Must be a valid EC2 instance type" + }, + "LambdaExecutionRoleAction": { + "Type": "String", + "Default": "sts:AssumeRole", + "Description": "" + } + }, + "Mappings": { + "EnvironmentMap": { + "dev": { + "InstanceType": "t2.micro", + "MinSize": 1, + "MaxSize": 2 + }, + "test": { + "InstanceType": "t2.small", + "MinSize": 2, + "MaxSize": 4 + }, + "prod": { + "InstanceType": "t2.medium", + "MinSize": 3, + "MaxSize": 6 + } + }, + "RegionMap": { + "us-east-1": { + "AMI": "ami-0c55b159cbfafe1f0" + }, + "us-west-2": { + "AMI": "ami-0d1cd67c26f5fca19" + } + } + }, + "Conditions": { + "IsProduction": { + "Fn::Equals": [ + { + "Ref": "EnvironmentType" + }, + "prod" + ] + }, + "IsDevelopment": { + "Fn::Equals": [ + { + "Ref": "EnvironmentType" + }, + "dev" + ] + }, + "CreateProdResources": { + "Fn::And": [ + { + "Condition": "IsProduction" + }, + { + "Fn::Equals": [ + { + "Ref": "AWS::Region" + }, + "us-east-1" + ] + } + ] + } + }, + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": { + "Fn::Sub": "${AWS::StackName}-bucket" + }, + "VersioningConfiguration": { + "Status": "Enabled" + }, + "Tags": [ + { + "Key": "Environment", + "Value": { + "Ref": "EnvironmentType" + } + }, + { + "Key": "Name", + "Value": { + "Fn::Sub": "${AWS::StackName}-bucket" + } + } + ] + }, + "Condition": "CreateProdResources" + }, + "MyInstance": { + "Type": "AWS::EC2::Instance", + "Properties": { + "InstanceType": { + "Fn::FindInMap": [ + "EnvironmentMap", + { + "Ref": "EnvironmentType" + }, + "InstanceType" + ] + }, + "ImageId": { + "Fn::FindInMap": [ + "RegionMap", + { + "Ref": "AWS::Region" + }, + "AMI" + ] + }, + "Tags": [ + { + "Key": "Name", + "Value": { + "Fn::Sub": "${AWS::StackName}-instance" + } + }, + { + "Key": "Environment", + "Value": { + "Ref": "EnvironmentType" + } + } + ], + "UserData": { + "Fn::Base64": { + "Fn::Sub": "#!/bin/bash\necho 'Hello from ${EnvironmentType}'\n" + } + } + } + }, + "MyAutoScalingGroup": { + "Type": "AWS::AutoScaling::AutoScalingGroup", + "Properties": { + "MinSize": { + "Fn::FindInMap": [ + "EnvironmentMap", + { + "Ref": "EnvironmentType" + }, + "MinSize" + ] + }, + "MaxSize": { + "Fn::FindInMap": [ + "EnvironmentMap", + { + "Ref": "EnvironmentType" + }, + "MaxSize" + ] + }, + "DesiredCapacity": { + "Fn::If": [ + "IsProduction", + 3, + 1 + ] + }, + "LaunchConfigurationName": { + "Ref": "MyLaunchConfig" + }, + "AvailabilityZones": { + "Fn::GetAZs": "" + }, + "Tags": [ + { + "Key": "Name", + "Value": { + "Fn::Sub": "${AWS::StackName}-asg" + }, + "PropagateAtLaunch": true + } + ] + } + }, + "MyLaunchConfig": { + "Type": "AWS::AutoScaling::LaunchConfiguration", + "Properties": { + "ImageId": { + "Fn::FindInMap": [ + "RegionMap", + { + "Ref": "AWS::Region" + }, + "AMI" + ] + }, + "InstanceType": { + "Ref": "InstanceType" + } + } + }, + "MyLambdaFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "FunctionName": { + "Fn::Sub": "${AWS::StackName}-function" + }, + "Runtime": "python3.9", + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "LambdaExecutionRole", + "Arn" + ] + }, + "Code": { + "ZipFile": "def handler(event, context):\n return {'statusCode': 200}\n" + }, + "Environment": { + "Variables": { + "ENV_TYPE": { + "Ref": "EnvironmentType" + }, + "BUCKET_NAME": { + "Ref": "MyBucket" + } + } + } + } + }, + "LambdaExecutionRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + }, + "Action": {"Ref": "LambdaExecutionRoleAction"} + } + ] + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Policies": [ + { + "PolicyName": "S3Access", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:PutObject" + ], + "Resource": { + "Fn::Sub": "${MyBucket.Arn}/*" + } + } + ] + } + } + ] + } + } + }, + "Outputs": { + "BucketName": { + "Description": "Name of the S3 bucket", + "Value": { + "Ref": "MyBucket" + }, + "Condition": "CreateProdResources" + }, + "InstanceId": { + "Description": "Instance ID", + "Value": { + "Ref": "MyInstance" + } + }, + "LambdaFunctionArn": { + "Description": "Lambda function ARN", + "Value": { + "Fn::GetAtt": [ + "MyLambdaFunction", + "Arn" + ] + } + } + } +} diff --git a/tst/unit/handlers/DocumentHandler.test.ts b/tst/unit/handlers/DocumentHandler.test.ts index 7edf98c0..5a1a3fb1 100644 --- a/tst/unit/handlers/DocumentHandler.test.ts +++ b/tst/unit/handlers/DocumentHandler.test.ts @@ -2,7 +2,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { DidChangeTextDocumentParams, Range } from 'vscode-languageserver'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { DocumentUri } from 'vscode-languageserver-textdocument/lib/esm/main'; +import { SyntaxTreeManager } from '../../../src/context/syntaxtree/SyntaxTreeManager'; import { Document, CloudFormationFileType } from '../../../src/document/Document'; +import { createEdit } from '../../../src/document/DocumentUtils'; import { didOpenHandler, didChangeHandler, @@ -11,6 +13,7 @@ import { } from '../../../src/handlers/DocumentHandler'; import { LintTrigger } from '../../../src/services/cfnLint/CfnLintService'; import { createMockComponents, MockedServerComponents } from '../../utils/MockServerComponents'; +import { Templates } from '../../utils/TemplateUtils'; import { flushAllPromises } from '../../utils/Utils'; describe('DocumentHandler', () => { @@ -122,9 +125,14 @@ describe('DocumentHandler', () => { } it('should handle incremental changes and update syntax tree', () => { - const textDocument = createTextDocument(); + const expectedContent = 'AWSTemplateFormatVersion: "2010-09-10"'; + const textDocument = TextDocument.create(testUri, 'yaml', 1, expectedContent); mockDocuments({ get: vi.fn().mockReturnValue(textDocument) }); + mockServices.syntaxTreeManager.getSyntaxTree.returns({ + content: () => testContent, + } as any); + const handler = didChangeHandler(mockServices.documents, mockServices); handler( @@ -132,20 +140,121 @@ describe('DocumentHandler', () => { textDocument: { uri: testUri }, contentChanges: [ { - range: Range.create(0, 0, 0, 5), - text: 'Hello', + range: Range.create(0, 37, 0, 38), + text: '0', }, ], }), ); + expect(mockServices.syntaxTreeManager.updateWithEdit.calledOnce).toBe(true); expect( - mockServices.cfnLintService.lintDelayed.calledWith(testContent, testUri, LintTrigger.OnChange, true), + mockServices.cfnLintService.lintDelayed.calledWith( + expectedContent, + testUri, + LintTrigger.OnChange, + true, + ), ).toBe(true); }); - it('should use delayed linting and Guard validation for files', () => { - const textDocument = createTextDocument(); + it('should apply multiple sequential edits correctly', () => { + const syntaxTreeManager = new SyntaxTreeManager(); + const testUri = 'file:///test/sample_template.json'; + const expectedUri = 'file:///test/sample_template_expected.json'; + const initialContent = Templates.sample.json.contents; + const expectedContent = Templates.sampleExpected.json.contents; + + syntaxTreeManager.add(testUri, initialContent); + syntaxTreeManager.add(expectedUri, expectedContent); + + const changes = [ + { range: { start: { line: 248, character: 40 }, end: { line: 248, character: 40 } }, text: '}' }, + { range: { start: { line: 248, character: 39 }, end: { line: 248, character: 39 } }, text: 'Action' }, + { range: { start: { line: 248, character: 35 }, end: { line: 248, character: 35 } }, text: 'cution' }, + { range: { start: { line: 248, character: 34 }, end: { line: 248, character: 34 } }, text: 'bdaEx' }, + { range: { start: { line: 248, character: 29 }, end: { line: 248, character: 33 } }, text: ' "La' }, + { range: { start: { line: 248, character: 25 }, end: { line: 248, character: 28 } }, text: 'Ref"' }, + { range: { start: { line: 248, character: 24 }, end: { line: 248, character: 24 } }, text: '{' }, + { + range: { start: { line: 45, character: 5 }, end: { line: 45, character: 5 } }, + text: ',\n "LambdaExecutionRoleAction": {\n "Type": "String",\n "Default": "sts:AssumeRole",\n "Description": ""\n }\n', + }, + ]; + + let currentContent = initialContent; + + for (const change of changes) { + const start = { row: change.range.start.line, column: change.range.start.character }; + const end = { row: change.range.end.line, column: change.range.end.character }; + const { edit, newContent } = createEdit(currentContent, change.text, start, end); + syntaxTreeManager.updateWithEdit(testUri, newContent, edit); + currentContent = newContent; + } + + const actualTree = syntaxTreeManager.getSyntaxTree(testUri); + const expectedTree = syntaxTreeManager.getSyntaxTree(expectedUri); + expect(actualTree).toBeDefined(); + expect(expectedTree).toBeDefined(); + + const actualRoot = actualTree!.getRootNode(); + const expectedRoot = expectedTree!.getRootNode(); + + // Check for corruption in actual tree + const corruptedNodes: string[] = []; + const errorNodes: string[] = []; + + function walkTree(node: any): void { + const nodeText = node.text; + + if (nodeText.startsWith(': null')) { + corruptedNodes.push(`${node.type} at ${node.startPosition.row}:${node.startPosition.column}`); + } + + if (node.type === 'ERROR') { + errorNodes.push(`ERROR at ${node.startPosition.row}:${node.startPosition.column}`); + } + + for (let i = 0; i < node.childCount; i++) { + walkTree(node.child(i)); + } + } + + walkTree(actualRoot); + + expect(corruptedNodes, `Found nodes with ": null" corruption:\n${corruptedNodes.join('\n')}`).toHaveLength( + 0, + ); + expect(errorNodes, `Found ERROR nodes:\n${errorNodes.join('\n')}`).toHaveLength(0); + + // Compare tree structures + expect(actualRoot.type).toBe(expectedRoot.type); + expect(actualRoot.hasError).toBe(false); + expect(expectedRoot.hasError).toBe(false); + + // Compare parsed JSON to ensure semantic equivalence + const actualJson = JSON.parse(actualTree!.content()); + const expectedJson = JSON.parse(expectedTree!.content()); + + expect(actualJson.Parameters.LambdaExecutionRoleAction).toEqual({ + Type: 'String', + Default: 'sts:AssumeRole', + Description: '', + }); + expect( + actualJson.Resources.LambdaExecutionRole.Properties.AssumeRolePolicyDocument.Statement[0].Action, + ).toEqual({ + Ref: 'LambdaExecutionRoleAction', + }); + + // Verify overall structure matches + expect(Object.keys(actualJson)).toEqual(Object.keys(expectedJson)); + expect(Object.keys(actualJson.Parameters)).toEqual(Object.keys(expectedJson.Parameters)); + }); + + it('should handle full document replacement and trigger validation', () => { + const newContent = 'Resources:\n MyBucket:\n Type: AWS::S3::Bucket'; + const textDocument = TextDocument.create(testUri, 'yaml', 1, newContent); mockDocuments({ get: vi.fn().mockReturnValue(textDocument) }); const handler = didChangeHandler(mockServices.documents, mockServices); @@ -153,21 +262,24 @@ describe('DocumentHandler', () => { handler( createParams({ textDocument: { uri: testUri }, - contentChanges: [{ text: 'new content' }], + contentChanges: [{ text: newContent }], }), ); + expect(mockServices.syntaxTreeManager.add.calledWith(testUri, newContent)).toBe(true); expect( - mockServices.cfnLintService.lintDelayed.calledWith(testContent, testUri, LintTrigger.OnChange, true), + mockServices.cfnLintService.lintDelayed.calledWith(newContent, testUri, LintTrigger.OnChange, true), ).toBe(true); - expect(mockServices.guardService.validateDelayed.calledWith(testContent, testUri)).toBe(true); + expect(mockServices.guardService.validateDelayed.calledWith(newContent, testUri)).toBe(true); }); it('should create syntax tree when update fails', () => { const textDocument = createTextDocument(); mockDocuments({ get: vi.fn().mockReturnValue(textDocument) }); - mockServices.syntaxTreeManager.getSyntaxTree.returns({} as any); // Mock existing tree + mockServices.syntaxTreeManager.getSyntaxTree.returns({ + content: () => testContent, + } as any); mockServices.syntaxTreeManager.updateWithEdit.throws(new Error('Update failed')); const handler = didChangeHandler(mockServices.documents, mockServices); @@ -225,6 +337,55 @@ describe('DocumentHandler', () => { expect(mockServices.cfnLintService.lintDelayed.called).toBe(false); expect(mockServices.guardService.validateDelayed.called).toBe(false); }); + + it('should return early for non-template documents', () => { + const textDocument = TextDocument.create(testUri, 'yaml', 1, 'Foo: Bar'); + mockDocuments({ get: vi.fn().mockReturnValue(textDocument) }); + + const handler = didChangeHandler(mockServices.documents, mockServices); + handler( + createParams({ + textDocument: { uri: testUri }, + contentChanges: [{ text: 'not a template' }], + }), + ); + + expect(mockServices.syntaxTreeManager.add.called).toBe(false); + expect(mockServices.cfnLintService.lintDelayed.called).toBe(false); + }); + + it('should delete syntax tree when document becomes non-template', () => { + const textDocument = TextDocument.create(testUri, 'yaml', 1, 'someKey: someValue'); + mockDocuments({ get: vi.fn().mockReturnValue(textDocument) }); + mockServices.syntaxTreeManager.getSyntaxTree.returns({ content: () => 'old content' } as any); + + const handler = didChangeHandler(mockServices.documents, mockServices); + handler( + createParams({ + textDocument: { uri: testUri }, + contentChanges: [{ text: 'someKey: someValue' }], + }), + ); + + expect(mockServices.syntaxTreeManager.deleteSyntaxTree.calledWith(testUri)).toBe(true); + }); + + it('should create new tree when no existing tree', () => { + const newContent = 'Resources:\n MyBucket:\n Type: AWS::S3::Bucket'; + const textDocument = TextDocument.create(testUri, 'yaml', 1, newContent); + mockDocuments({ get: vi.fn().mockReturnValue(textDocument) }); + mockServices.syntaxTreeManager.getSyntaxTree.returns(undefined); + + const handler = didChangeHandler(mockServices.documents, mockServices); + handler( + createParams({ + textDocument: { uri: testUri }, + contentChanges: [{ text: newContent }], + }), + ); + + expect(mockServices.syntaxTreeManager.add.calledWith(testUri, newContent)).toBe(true); + }); }); describe('didCloseHandler', () => { diff --git a/tst/utils/TemplateUtils.ts b/tst/utils/TemplateUtils.ts index a60f4d52..965994fa 100644 --- a/tst/utils/TemplateUtils.ts +++ b/tst/utils/TemplateUtils.ts @@ -55,6 +55,26 @@ export const Templates: Record