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
37 changes: 35 additions & 2 deletions src/autocomplete/CompletionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
CompletionParams,
InsertTextFormat,
InsertTextMode,
MarkupContent,
MarkupKind,
Position,
Range,
TextEdit,
Expand Down Expand Up @@ -33,6 +35,20 @@ export function createReplacementRange(context: Context, includeQuotes?: boolean
}
}

/**
* Creates a MarkupContent object from a markdown string.
* This ensures consistent formatting for completion documentation.
*
* @param markdown The markdown content string
* @returns A MarkupContent object with markdown formatting
*/
export function createMarkupContent(markdown: string): MarkupContent {
return {
kind: MarkupKind.Markdown,
value: markdown,
};
}

/**
* Creates a base completion item with common properties.
* This reduces duplication across different completion providers.
Expand All @@ -51,7 +67,7 @@ export function createCompletionItem(
insertTextFormat?: InsertTextFormat;
insertTextMode?: InsertTextMode;
sortText?: string;
documentation?: string;
documentation?: string | MarkupContent;
data?: Record<string, unknown>;
context?: Context;
},
Expand All @@ -70,6 +86,23 @@ export function createCompletionItem(
}
}

// Handle documentation - support both string and MarkupContent
let documentation: string | MarkupContent | undefined;
if (options?.documentation) {
if (typeof options.documentation === 'string') {
// For string documentation, add the source attribution
documentation = `${options.documentation}\n\nSource: ${ExtensionName}`;
} else {
// For MarkupContent, add source attribution to the markdown value
documentation = {
kind: options.documentation.kind,
value: `${options.documentation.value}\n\n**Source:** ${ExtensionName}`,
};
}
} else {
documentation = `Source: ${ExtensionName}`;
}

return {
label,
kind,
Expand All @@ -80,7 +113,7 @@ export function createCompletionItem(
textEdit: textEdit,
filterText: filterText,
sortText: options?.sortText,
documentation: `${options?.documentation ? `${options?.documentation}\n` : ''}Source: ${ExtensionName}`,
documentation: documentation,
data: options?.data,
};
}
Expand Down
52 changes: 48 additions & 4 deletions src/autocomplete/IntrinsicFunctionArgumentCompletionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { SchemaRetriever } from '../schema/SchemaRetriever';
import { LoggerFactory } from '../telemetry/LoggerFactory';
import { getFuzzySearchFunction } from '../utils/FuzzySearchUtil';
import { CompletionProvider } from './CompletionProvider';
import { createCompletionItem, createReplacementRange } from './CompletionUtils';
import { createCompletionItem, createMarkupContent, createReplacementRange } from './CompletionUtils';

interface IntrinsicFunctionInfo {
type: IntrinsicFunction;
Expand Down Expand Up @@ -344,10 +344,25 @@ export class IntrinsicFunctionArgumentCompletionProvider implements CompletionPr

const attributes = this.getResourceAttributes(resource.Type);
for (const attributeName of attributes) {
const schema = this.schemaRetriever.getDefault().schemas.get(resource.Type);
let attributeDescription = `${attributeName} attribute of ${resource.Type}`;

if (schema) {
const jsonPointerPath = `/properties/${attributeName.replaceAll('.', '/')}`;

try {
const resolvedSchemas = schema.resolveJsonPointerPath(jsonPointerPath);
if (resolvedSchemas.length > 0 && resolvedSchemas[0].description) {
attributeDescription = resolvedSchemas[0].description;
}
} catch (error) {
log.error({ error }, 'Error resolving JSON Pointer path');
}
}
completionItems.push(
createCompletionItem(`${resourceName}.${attributeName}`, CompletionItemKind.Property, {
detail: `GetAtt (${resource.Type})`,
documentation: `Get attribute ${attributeName} from resource ${resourceName}`,
documentation: createMarkupContent(attributeDescription),
data: {
isIntrinsicFunction: true,
},
Expand Down Expand Up @@ -648,7 +663,8 @@ export class IntrinsicFunctionArgumentCompletionProvider implements CompletionPr
}

const resource = resourceContext.entity as Resource;
if (!resource.Type || typeof resource.Type !== 'string') {
const resourceType = resource.Type;
if (!resourceType || typeof resourceType !== 'string') {
return undefined;
}

Expand All @@ -658,8 +674,36 @@ export class IntrinsicFunctionArgumentCompletionProvider implements CompletionPr
}

const completionItems = attributes.map((attributeName) => {
const schema = this.schemaRetriever.getDefault().schemas.get(resourceType);
let documentation;

if (schema) {
const jsonPointerPath = `/properties/${attributeName.replaceAll('.', '/properties/')}`;
documentation = createMarkupContent(
`**${attributeName}** attribute of **${resource.Type}**\n\nReturns the value of this attribute when used with the GetAtt intrinsic function.`,
);

try {
const resolvedSchemas = schema.resolveJsonPointerPath(jsonPointerPath);

if (resolvedSchemas.length > 0) {
const firstSchema = resolvedSchemas[0];

if (firstSchema.description) {
documentation = createMarkupContent(firstSchema.description);
}
}
} catch (error) {
log.debug(error);
}
}

const item = createCompletionItem(attributeName, CompletionItemKind.Property, {
data: { isIntrinsicFunction: true },
documentation: documentation,
detail: `GetAtt attribute for ${resource.Type}`,
data: {
isIntrinsicFunction: true,
},
});

if (context.text.length > 0) {
Expand Down
16 changes: 14 additions & 2 deletions src/autocomplete/ResourcePropertyCompletionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import { Resource } from '../context/semantic/Entity';
import { CfnValue } from '../context/semantic/SemanticTypes';
import { NodeType } from '../context/syntaxtree/utils/NodeType';
import { CommonNodeTypes } from '../context/syntaxtree/utils/TreeSitterTypes';
import { propertyTypesToMarkdown } from '../hover/HoverFormatter';
import { PropertyType, ResourceSchema } from '../schema/ResourceSchema';
import { SchemaRetriever } from '../schema/SchemaRetriever';
import { getFuzzySearchFunction } from '../utils/FuzzySearchUtil';
import { templatePathToJsonPointerPath } from '../utils/PathUtils';
import { CompletionItemData, ExtendedCompletionItem } from './CompletionFormatter';
import { CompletionProvider } from './CompletionProvider';
import { createCompletionItem } from './CompletionUtils';
import { createCompletionItem, createMarkupContent } from './CompletionUtils';

export class ResourcePropertyCompletionProvider implements CompletionProvider {
private readonly fuzzySearch = getFuzzySearchFunction();
Expand Down Expand Up @@ -237,7 +238,6 @@ export class ResourcePropertyCompletionProvider implements CompletionProvider {
context: Context,
): CompletionItem[] {
const result: CompletionItem[] = [];

const availableRequiredProperties = [...requiredProperties].filter(
(propName) => allProperties.has(propName) && !existingProperties.has(propName),
);
Expand All @@ -255,10 +255,22 @@ export class ResourcePropertyCompletionProvider implements CompletionProvider {

const itemData = this.getPropertyType(schema, propertyDef);

// Generate rich markdown documentation for the property
let documentation;
if (propertyDef.description || propertyDef.properties || propertyDef.type) {
// Use the rich markdown formatter from hover system
const markdownDoc = propertyTypesToMarkdown(propertyName, [propertyDef]);
documentation = createMarkupContent(markdownDoc);
} else {
// Fallback to simple description for properties without schema details
documentation = `${propertyName} property of ${schema.typeName}`;
}

const completionItem: ExtendedCompletionItem = createCompletionItem(
propertyName,
CompletionItemKind.Property,
{
documentation: documentation,
data: itemData,
context: context,
},
Expand Down
94 changes: 94 additions & 0 deletions tst/unit/autocomplete/CompletionUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, test } from 'vitest';
import { CompletionItemKind, MarkupContent, MarkupKind } from 'vscode-languageserver';
import { createCompletionItem } from '../../../src/autocomplete/CompletionUtils';

describe('CompletionUtils', () => {
describe('createCompletionItem', () => {
describe('documentation handling', () => {
const stringDoc = 'This is a test documentation';
const expectedStringResult = 'This is a test documentation\n\nSource: AWS CloudFormation';

const markupDoc = { kind: MarkupKind.Markdown, value: '**Bold** documentation' };
const expectedMarkupResult = {
kind: MarkupKind.Markdown,
value: '**Bold** documentation\n\n**Source:** AWS CloudFormation',
};

const emptyDoc = '';
const defaultSource = 'Source: AWS CloudFormation';

const markdownDoc = { kind: MarkupKind.Markdown, value: '**Bold** text' };
const plainTextDoc = { kind: MarkupKind.PlainText, value: 'Plain text' };

const complexMarkdown = {
kind: MarkupKind.Markdown,
value: `# S3 Bucket

Creates an **Amazon S3** bucket with:
- Versioning enabled
- Public access *blocked*
- [Documentation](https://docs.aws.amazon.com)`,
};

const expectedComplexMarkdown = {
kind: MarkupKind.Markdown,
value: `# S3 Bucket

Creates an **Amazon S3** bucket with:
- Versioning enabled
- Public access *blocked*
- [Documentation](https://docs.aws.amazon.com)

**Source:** AWS CloudFormation`,
};

test('should handle string documentation with source attribution', () => {
const result = createCompletionItem('Test', CompletionItemKind.Keyword, {
documentation: stringDoc,
});
expect(result.documentation).toEqual(expectedStringResult);
});

test('should handle MarkupContent documentation with source attribution', () => {
const result = createCompletionItem('Test', CompletionItemKind.Keyword, {
documentation: markupDoc,
});
expect(result.documentation).toEqual(expectedMarkupResult);
});

test('should handle undefined documentation with default source', () => {
const result = createCompletionItem('Test', CompletionItemKind.Keyword);
expect(result.documentation).toEqual(defaultSource);
});

test('should handle empty string documentation', () => {
const result = createCompletionItem('Test', CompletionItemKind.Keyword, {
documentation: emptyDoc,
});
expect(result.documentation).toEqual(defaultSource);
});

test('should preserve MarkupContent kind when adding source attribution', () => {
const markdownResult = createCompletionItem('Test', CompletionItemKind.Keyword, {
documentation: markdownDoc,
});
const markdownAsMarkup = markdownResult.documentation as MarkupContent;
expect(markdownAsMarkup.kind).toEqual(MarkupKind.Markdown);

const plainTextResult = createCompletionItem('Test', CompletionItemKind.Keyword, {
documentation: plainTextDoc,
});
const plainTextAsMarkup = plainTextResult.documentation as MarkupContent;
expect(plainTextAsMarkup.kind).toEqual(MarkupKind.PlainText);
});

test('should handle MarkupContent with existing markdown formatting', () => {
const result = createCompletionItem('Test', CompletionItemKind.Keyword, {
documentation: complexMarkdown,
});

expect(result.documentation).toEqual(expectedComplexMarkdown);
});
});
});
});
Loading