Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/autocomplete/CompletionFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,10 @@ export class CompletionFormatter {

// Set filterText for ALL items (including snippets) when in JSON with quotes
const isInJsonString = documentType === DocumentType.JSON && context.syntaxNode.type === 'string';
if (isInJsonString) {
const isJsonValueNode = isInJsonString && context.isJsonPairValue();
if (isJsonValueNode) {
formattedItem.filterText = `"${item.label}"`;
} else if (isInJsonString) {
formattedItem.filterText = `"${context.text}"`;
}

Expand Down Expand Up @@ -171,6 +174,20 @@ export class CompletionFormatter {
const indentation = ' '.repeat(context.startPosition.column);
const indentString = getIndentationString(editorSettings, DocumentType.JSON);

// When completing a value (e.g. resource type "AWS::S3::Bucket"), just replace the value text
// Check the syntax tree: if the node is the value child of a JSON pair, it's a value completion
const isValueCompletion = context.isJsonPairValue();
if (isValueCompletion) {
// Include surrounding quotes in the range so VS Code matches the full token
const startCol = Math.max(0, context.startPosition.column - 1);
const endCol = context.endPosition.column + 1;
const range = Range.create(
Position.create(context.startPosition.row, startCol),
Position.create(context.endPosition.row, endCol),
);
return { text: `"${label}"`, range, isSnippet: false };
}

let replacementText = `${indentation}"${label}":`;
let isSnippet = false;

Expand Down
14 changes: 13 additions & 1 deletion src/context/Context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { entityTypeFromSection, nodeToEntity } from './semantic/EntityBuilder';
import { normalizeIntrinsicFunction } from './semantic/Intrinsics';
import { PropertyPath } from './syntaxtree/SyntaxTree';
import { NodeType } from './syntaxtree/utils/NodeType';
import { YamlNodeTypes, CommonNodeTypes } from './syntaxtree/utils/TreeSitterTypes';
import { YamlNodeTypes, CommonNodeTypes, JsonNodeTypes, FieldNames } from './syntaxtree/utils/TreeSitterTypes';
import { TransformContext } from './TransformContext';

type QuoteCharacter = '"' | "'";
Expand Down Expand Up @@ -179,6 +179,18 @@ export class Context {
return !(this.propertyPath.at(-1) === this.text);
}

/**
* Check if the cursor is at a JSON value position using the syntax tree.
* Uses the tree-sitter pair node structure: a node is a value when it is
* the value child of a pair node.
*/
public isJsonPairValue(): boolean {
return (
this.node.parent?.type === JsonNodeTypes.PAIR &&
this.node.parent.childForFieldName(FieldNames.VALUE) === this.node
);
}

public isResourceAttributeProperty(): boolean {
if (this.section !== TopLevelSection.Resources || !this.hasLogicalId) {
return false;
Expand Down
2 changes: 1 addition & 1 deletion tools/debug_tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ class DebugTreeTool {
path: filePath,
extension: fileExtension,
size: content.length,
cloudFormationFileType: cloudFormationFileType!.toString(),
cloudFormationFileType: cloudFormationFileType?.toString() ?? 'unknown',
},
syntaxTree: {
rootNodeType: '',
Expand Down
69 changes: 69 additions & 0 deletions tst/e2e/Completion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2508,6 +2508,75 @@ Resources:
});
});

describe('Resource Type Value', () => {
it('should provide resource type value completions when typing in Type field', async () => {
const template = getSimpleJsonTemplateText();
const updatedTemplate = `{
"AWSTemplateFormatVersion": "2010-09-09",
"Resources": {
"MyBucket": {
"Type": "AWS::S3::B"
}
}
}`;
const uri = await client.openJsonTemplate(template);

await client.changeDocument({
textDocument: { uri, version: 2 },
contentChanges: [{ text: updatedTemplate }],
});

const completions: any = await client.completion({
textDocument: { uri },
position: { line: 4, character: 23 },
});

expect(completions).toBeDefined();
expect(completions?.items).toBeDefined();
expect(completions.items.length).toBeGreaterThan(0);

const labels = completions.items.map((item: any) => item.label);
expect(labels).toContain('AWS::S3::Bucket');

await client.closeDocument({ textDocument: { uri } });
});
});

describe('Enum Value Completions', () => {
it('should provide enum value completions for DeletionPolicy in JSON', async () => {
const template = getSimpleJsonTemplateText();
const updatedTemplate = `{
"AWSTemplateFormatVersion": "2010-09-09",
"Resources": {
"MyBucket": {
"Type": "AWS::S3::Bucket",
"DeletionPolicy": ""
}
}
}`;
const uri = await client.openJsonTemplate(template);

await client.changeDocument({
textDocument: { uri, version: 2 },
contentChanges: [{ text: updatedTemplate }],
});

const completions: any = await client.completion({
textDocument: { uri },
position: { line: 5, character: 24 },
});

expect(completions).toBeDefined();
expect(completions?.items).toBeDefined();
expect(completions.items.length).toBeGreaterThan(0);

const labels = completions.items.map((item: any) => item.label);
expect(labels).toContain('Retain');
expect(labels).toContain('Delete');

await client.closeDocument({ textDocument: { uri } });
});
});
describe('Resource Attributes', () => {
it('should provide Type attribute completion', async () => {
const template = getSimpleJsonTemplateText();
Expand Down
77 changes: 75 additions & 2 deletions tst/unit/autocomplete/CompletionFormatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,7 @@ describe('CompletionFormatAdapter', () => {
const mockContext = createResourceContext('MyBucket', {
type: DocumentType.JSON,
text: 'BucketEncryption',
propertyPath: ['Resources', 'MyBucket', 'Properties'],
propertyPath: ['Resources', 'MyBucket', 'Properties', 'BucketEncryption'],
data: { Type: 'AWS::S3::Bucket' },
nodeType: 'string',
});
Expand Down Expand Up @@ -779,10 +779,11 @@ describe('CompletionFormatAdapter', () => {

describe('JSON filterText handling', () => {
test('should set filterText with quotes when in JSON string context', () => {
const mockContext = createTopLevelContext('Resources', {
const mockContext = createTopLevelContext('Unknown', {
type: DocumentType.JSON,
nodeType: 'string',
text: 'Res',
propertyPath: ['Res'],
});
const completions: CompletionList = {
isIncomplete: false,
Expand Down Expand Up @@ -869,4 +870,76 @@ describe('CompletionFormatAdapter', () => {
expect(result.items[0].textEdit?.newText).toContain('"MultiTypeProperty": "$0"');
});
});

describe('JSON value completions', () => {
function mockValueNode(context: any) {
const node = context.syntaxNode;
const pairParent = {
type: 'pair',
childForFieldName: (name: string) => (name === 'value' ? node : null),
};
Object.defineProperty(node, 'parent', { value: pairParent, writable: true });
}

test('should format resource type value completion without colon', () => {
const mockContext = createResourceContext('MyBucket', {
type: DocumentType.JSON,
text: 'AWS::S3::B',
propertyPath: ['Resources', 'MyBucket', 'Type'],
data: { Type: 'AWS::S3::B' },
nodeType: 'string',
});
mockValueNode(mockContext);

const completions: CompletionList = {
isIncomplete: false,
items: [
{
label: 'AWS::S3::Bucket',
kind: CompletionItemKind.Class,
},
],
};

const lineContent = ' "Type": "AWS::S3::B"';
const result = formatter.format(completions, mockContext, defaultEditorSettings, lineContent);

expect(result.items[0].textEdit).toBeDefined();
// Value completions should not append a colon after the value
expect(result.items[0].textEdit?.newText).not.toMatch(/:$/);
expect(result.items[0].textEdit?.newText).not.toMatch(/:\s*$/);
// Should include quotes around the value
expect(result.items[0].textEdit?.newText).toContain('"AWS::S3::Bucket"');
// filterText should include quotes for VS Code matching
expect(result.items[0].filterText).toBe('"AWS::S3::Bucket"');
});

test('should not replace the key when completing a value', () => {
const mockContext = createResourceContext('MyBucket', {
type: DocumentType.JSON,
text: 'AWS::S3::B',
propertyPath: ['Resources', 'MyBucket', 'Type'],
data: { Type: 'AWS::S3::B' },
nodeType: 'string',
});
mockValueNode(mockContext);

const completions: CompletionList = {
isIncomplete: false,
items: [
{
label: 'AWS::S3::Bucket',
kind: CompletionItemKind.Class,
},
],
};

const lineContent = ' "Type": "AWS::S3::B"';
const result = formatter.format(completions, mockContext, defaultEditorSettings, lineContent);

// Range should start near the value, not at column 0
const textEdit = result.items[0].textEdit as any;
expect(textEdit.range.start.character).toBeGreaterThan(0);
});
});
});