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
8 changes: 4 additions & 4 deletions src/ai/CfnAI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export class CfnAI implements SettingsConfigurable, Closeable {
constructor(
private readonly documentManager: DocumentManager,
private readonly awsClient: AwsClient,
private readonly relationshipSchemaService: RelationshipSchemaService = new RelationshipSchemaService(),
) {
this.llmConfig = new LLMConfig();
}
Expand Down Expand Up @@ -108,9 +109,8 @@ export class CfnAI implements SettingsConfigurable, Closeable {

const templateContent = document.contents();

const relationshipService = RelationshipSchemaService.getInstance();
const resourceTypes = relationshipService.extractResourceTypesFromTemplate(templateContent);
const relationshipContext = relationshipService.getRelationshipContext(resourceTypes);
const resourceTypes = this.relationshipSchemaService.extractResourceTypesFromTemplate(templateContent);
const relationshipContext = this.relationshipSchemaService.getRelationshipContext(resourceTypes);

let scannedResourcesInfo: string | undefined;
let hasResourceScan = false;
Expand All @@ -119,7 +119,7 @@ export class CfnAI implements SettingsConfigurable, Closeable {
const filteredResources = await getFilteredScannedResources(
this.awsClient,
resourceTypes,
relationshipService,
this.relationshipSchemaService,
);
if (filteredResources) {
scannedResourcesInfo = formatScannedResourcesForAI(filteredResources);
Expand Down
4 changes: 1 addition & 3 deletions src/autocomplete/InlineCompletionProvider.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { InlineCompletionItem, InlineCompletionParams } from 'vscode-languageserver-protocol';
import { Context } from '../context/Context';
import { EditorSettings } from '../settings/Settings';

export interface InlineCompletionProvider {
getlineCompletion(
getInlineCompletion(
context: Context,
params: InlineCompletionParams,
editorSettings: EditorSettings,
): Promise<InlineCompletionItem[]> | InlineCompletionItem[] | undefined;
}
16 changes: 8 additions & 8 deletions src/autocomplete/InlineCompletionRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { InlineCompletionList, InlineCompletionParams, InlineCompletionItem } fr
import { Context } from '../context/Context';
import { ContextManager } from '../context/ContextManager';
import { DocumentManager } from '../document/DocumentManager';
import { SchemaRetriever } from '../schema/SchemaRetriever';
import { CfnExternal } from '../server/CfnExternal';
import { CfnInfraCore } from '../server/CfnInfraCore';
import { RelationshipSchemaService } from '../services/RelationshipSchemaService';
import { SettingsConfigurable, ISettingsSubscriber, SettingsSubscription } from '../settings/ISettingsSubscriber';
Expand Down Expand Up @@ -49,11 +51,7 @@ export class InlineCompletionRouter implements SettingsConfigurable, Closeable {
if (this.isAuthoringNewResource(context)) {
const relatedResourcesProvider = this.inlineCompletionProviderMap.get('RelatedResources');
if (relatedResourcesProvider) {
const documentSpecificSettings = this.documentManager.getEditorSettingsForDocument(
params.textDocument.uri,
);

const result = relatedResourcesProvider.getlineCompletion(context, params, documentSpecificSettings);
const result = relatedResourcesProvider.getInlineCompletion(context, params);

if (result instanceof Promise) {
return result.then((items) => {
Expand Down Expand Up @@ -98,10 +96,11 @@ export class InlineCompletionRouter implements SettingsConfigurable, Closeable {
);
}

static create(core: CfnInfraCore) {
static create(core: CfnInfraCore, external: CfnExternal) {
const relationshipSchemaService = new RelationshipSchemaService();
return new InlineCompletionRouter(
core.contextManager,
createInlineCompletionProviders(core.documentManager, RelationshipSchemaService.getInstance()),
createInlineCompletionProviders(core.documentManager, relationshipSchemaService, external.schemaRetriever),
core.documentManager,
);
}
Expand All @@ -110,12 +109,13 @@ export class InlineCompletionRouter implements SettingsConfigurable, Closeable {
export function createInlineCompletionProviders(
documentManager: DocumentManager,
relationshipSchemaService: RelationshipSchemaService,
schemaRetriever: SchemaRetriever,
): Map<InlineCompletionProviderType, InlineCompletionProvider> {
const inlineCompletionProviderMap = new Map<InlineCompletionProviderType, InlineCompletionProvider>();

inlineCompletionProviderMap.set(
'RelatedResources',
new RelatedResourcesInlineCompletionProvider(relationshipSchemaService, documentManager),
new RelatedResourcesInlineCompletionProvider(relationshipSchemaService, documentManager, schemaRetriever),
);

return inlineCompletionProviderMap;
Expand Down
187 changes: 162 additions & 25 deletions src/autocomplete/RelatedResourcesInlineCompletionProvider.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,30 @@
import { InlineCompletionItem, InlineCompletionParams } from 'vscode-languageserver-protocol';
import { Context } from '../context/Context';
import { Document, DocumentType } from '../document/Document';
import { DocumentManager } from '../document/DocumentManager';
import { ResourceSchema } from '../schema/ResourceSchema';
import { SchemaRetriever } from '../schema/SchemaRetriever';
import { RelationshipSchemaService } from '../services/RelationshipSchemaService';
import { EditorSettings } from '../settings/Settings';
import { LoggerFactory } from '../telemetry/LoggerFactory';
import { applySnippetIndentation } from '../utils/IndentationUtils';
import { CompletionFormatter } from './CompletionFormatter';
import { InlineCompletionProvider } from './InlineCompletionProvider';

type RelatedResource = { type: string; relatedTo: string };

export class RelatedResourcesInlineCompletionProvider implements InlineCompletionProvider {
private readonly log = LoggerFactory.getLogger(RelatedResourcesInlineCompletionProvider);
private readonly MAX_SUGGESTIONS = 5;

constructor(
private readonly relationshipSchemaService: RelationshipSchemaService,
private readonly documentManager: DocumentManager,
private readonly schemaRetriever: SchemaRetriever,
) {}

getlineCompletion(
getInlineCompletion(
context: Context,
params: InlineCompletionParams,
_editorSettings: EditorSettings,
): Promise<InlineCompletionItem[]> | InlineCompletionItem[] | undefined {
this.log.debug(
{
Expand Down Expand Up @@ -56,22 +63,29 @@ export class RelatedResourcesInlineCompletionProvider implements InlineCompletio
}
}

private getRelatedResourceTypes(existingResourceTypes: string[]): string[] {
private getRelatedResourceTypes(existingResourceTypes: string[]): RelatedResource[] {
const existingRelationships = new Map<string, Set<string>>();
const allRelatedTypes = new Set<string>();
const typeToRelatedMap = new Map<string, string>();

for (const resourceType of existingResourceTypes) {
const relatedTypes = this.relationshipSchemaService.getAllRelatedResourceTypes(resourceType);
existingRelationships.set(resourceType, relatedTypes);
for (const type of relatedTypes) {
allRelatedTypes.add(type);
typeToRelatedMap.set(type, typeToRelatedMap.get(type) ?? resourceType);
}
}

const existingTypesSet = new Set(existingResourceTypes);
const suggestedTypes = [...allRelatedTypes].filter((type) => !existingTypesSet.has(type));

return this.rankSuggestionsByFrequency(suggestedTypes, existingRelationships);
const rankedTypes = this.rankSuggestionsByFrequency(suggestedTypes, existingRelationships);

return rankedTypes.map((type) => ({
type,
relatedTo: typeToRelatedMap.get(type) ?? type,
}));
}

private rankSuggestionsByFrequency(
Expand Down Expand Up @@ -103,39 +117,162 @@ export class RelatedResourcesInlineCompletionProvider implements InlineCompletio
}

private generateInlineCompletionItems(
relatedResourceTypes: string[],
relatedResourceTypes: RelatedResource[],
params: InlineCompletionParams,
): InlineCompletionItem[] {
const completionItems: InlineCompletionItem[] = [];

const topSuggestions = relatedResourceTypes.slice(0, 5);
const document = this.documentManager.get(params.textDocument.uri);
const topSuggestions = relatedResourceTypes.slice(0, this.MAX_SUGGESTIONS);

for (const resourceType of topSuggestions) {
const insertText = this.generatePropertySnippet(resourceType);
return topSuggestions.map(({ type: resourceType, relatedTo }) => {
const insertText = this.generatePropertySnippet(resourceType, relatedTo, params, document);

completionItems.push({
return {
insertText,
range: {
start: params.position,
end: params.position,
},
filterText: `${resourceType}`,
});
filterText: resourceType,
};
});
}

private generatePropertySnippet(
resourceType: string,
relatedToType: string,
params: InlineCompletionParams,
document: Document | undefined,
): string {
const documentType = document?.documentType ?? DocumentType.YAML;
const baseLogicalId = `RelatedTo${relatedToType
.split('::')
.slice(1)
.join('')
.replaceAll(/[^a-zA-Z0-9]/g, '')}`;
const logicalId = this.getUniqueLogicalId(baseLogicalId, document);
const indent0 = CompletionFormatter.getIndentPlaceholder(0);
const indent1 = CompletionFormatter.getIndentPlaceholder(1);

try {
const schema = this.schemaRetriever.getDefault().schemas.get(resourceType);

if (!schema) {
// Fallback to simple format if schema not found
return this.formatSnippetForDocumentType(
documentType === DocumentType.JSON
? `"${logicalId}": {\n${indent1}"Type": "${resourceType}"\n}`
: `${logicalId}:\n${indent1}Type: ${resourceType}`,
documentType,
params,
);
}

const propertiesSnippet = this.generateRequiredPropertiesSnippet(schema, documentType);

let snippet: string;
if (propertiesSnippet) {
snippet =
documentType === DocumentType.JSON
? `"${logicalId}": {\n${indent1}"Type": "${resourceType}",\n${indent1}"Properties": {\n${propertiesSnippet}\n${indent1}}\n${indent0}}`
: `${logicalId}:\n${indent1}Type: ${resourceType}\n${indent1}Properties:\n${propertiesSnippet}`;
} else {
snippet =
documentType === DocumentType.JSON
? `"${logicalId}": {\n${indent1}"Type": "${resourceType}"\n${indent0}}`
: `${logicalId}:\n${indent1}Type: ${resourceType}`;
}

return this.formatSnippetForDocumentType(snippet, documentType, params);
} catch (error) {
this.log.warn(
{ error: String(error), resourceType },
'Error generating property snippet, falling back to simple format',
);
return this.formatSnippetForDocumentType(
documentType === DocumentType.JSON
? `"${logicalId}": {\n${indent1}"Type": "${resourceType}"\n}`
: `${logicalId}:\n${indent1}Type: ${resourceType}`,
documentType,
params,
);
}
}

this.log.debug(
{
suggestedCount: completionItems.length,
suggestions: completionItems.map((item) => item.insertText),
},
'Generated related resource inline completions',
);
private generateRequiredPropertiesSnippet(schema: ResourceSchema, documentType: DocumentType): string {
if (!schema.required || schema.required.length === 0) {
return '';
}

return completionItems;
const indent2 = CompletionFormatter.getIndentPlaceholder(2);

const requiredProps = schema.required
.map((propName) => {
if (documentType === DocumentType.JSON) {
return `${indent2}"${propName}": ""`;
} else {
return `${indent2}${propName}: `;
}
})
.join(documentType === DocumentType.JSON ? ',\n' : '\n');

return requiredProps;
}

private formatSnippetForDocumentType(
snippet: string,
documentType: DocumentType,
params: InlineCompletionParams,
): string {
const documentSpecificSettings = this.documentManager.getEditorSettingsForDocument(params.textDocument.uri);
const document = this.documentManager.get(params.textDocument.uri);

if (!document) {
return applySnippetIndentation(snippet, documentSpecificSettings, documentType);
}

const lines = document.getLines();
const currentLine = lines[params.position.line] || '';

const currentIndent = this.getCurrentLineIndentation(currentLine);
const baseIndentSize = documentSpecificSettings.tabSize;

return this.applyRelativeIndentation(snippet, currentIndent, baseIndentSize);
}

private generatePropertySnippet(resourceType: string): string {
// TODO: Convert AWS::Service::Resource to a complete resource type snippet
return `${resourceType}:`;
private getCurrentLineIndentation(line: string): number {
const match = line.match(/^(\s*)/);
return match ? match[1].length : 0;
}

private applyRelativeIndentation(template: string, currentIndent: number, baseIndentSize: number): string {
const logicalIdIndent = ' '.repeat(currentIndent);
const resourceIndent = ' '.repeat(currentIndent + baseIndentSize);
const propertyIndent = ' '.repeat(currentIndent + baseIndentSize * 2);

return template
.replaceAll(/\n\s*{INDENT0}/g, `\n${logicalIdIndent}`)
.replaceAll(/\n\s*{INDENT1}/g, `\n${resourceIndent}`)
.replaceAll(/\n\s*{INDENT2}/g, `\n${propertyIndent}`)
.replaceAll(/\n\s*{INDENT3}/g, `\n${' '.repeat(currentIndent + baseIndentSize * 3)}`);
}

private getUniqueLogicalId(baseId: string, document: Document | undefined): string {
const logicalId = `${baseId}LogicalId`;

if (!document) {
return logicalId;
}

const templateText = document.getText();

let counter = 0;
let candidateId = logicalId;

while (templateText.includes(candidateId)) {
counter++;
candidateId = `${baseId}${counter}LogicalId`;
}

return candidateId;
}
}
4 changes: 4 additions & 0 deletions src/document/Document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ export class Document {
return this.textDocument.getText(range);
}

public getLines(): string[] {
return this.getText().split('\n');
}

public positionAt(offset: number) {
return this.textDocument.positionAt(offset);
}
Expand Down
2 changes: 1 addition & 1 deletion src/server/CfnLspProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export class CfnLspProviders implements Configurables, Closeable {
this.hoverRouter = overrides.hoverRouter ?? new HoverRouter(core.contextManager, external.schemaRetriever);
this.completionRouter = overrides.completionRouter ?? CompletionRouter.create(core, external, this);

this.inlineCompletionRouter = overrides.inlineCompletionRouter ?? InlineCompletionRouter.create(core);
this.inlineCompletionRouter = overrides.inlineCompletionRouter ?? InlineCompletionRouter.create(core, external);
this.definitionProvider = overrides.definitionProvider ?? new DefinitionProvider(core.contextManager);
this.codeActionService = overrides.codeActionService ?? CodeActionService.create(core);
this.documentSymbolRouter = overrides.documentSymbolRouter ?? new DocumentSymbolRouter(core.syntaxTreeManager);
Expand Down
8 changes: 0 additions & 8 deletions src/services/RelationshipSchemaService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ export type RelationshipSchemaData = Record<string, RelationshipGroupData[]>;
export type RelationshipGroupData = Record<string, RelatedResourceType[]>;

export class RelationshipSchemaService {
private static instance: RelationshipSchemaService;
private readonly relationshipCache: Map<string, ResourceTypeRelationships> = new Map();
private readonly schemaFilePath: string;

Expand All @@ -34,13 +33,6 @@ export class RelationshipSchemaService {
this.loadAllSchemas();
}

static getInstance(schemaFilePath?: string): RelationshipSchemaService {
if (!RelationshipSchemaService.instance) {
RelationshipSchemaService.instance = new RelationshipSchemaService(schemaFilePath);
}
return RelationshipSchemaService.instance;
}

private loadAllSchemas(): void {
try {
const schemaContent = readFileSync(this.schemaFilePath, 'utf8');
Expand Down
Loading
Loading