Skip to content

Commit 1845125

Browse files
authored
json indentation improvement (#178)
* Changes to formatter based on if suggestion has data type as object. Then add {} to completion suggestion * Working json indentation improvement * handles completions for labels with dynamic schema references * cursor lands within brackets or quotes * clean up * Restore CompletionFormatter.ts with correct implementation from detached HEAD * context.getEntityType() not used anymore * fix lint errors, handle top level sections in formatter * fix lint error * switch formatter params, break up formatter try block * resolve merge * fix lint errors
1 parent bfe1e1b commit 1845125

File tree

7 files changed

+647
-19
lines changed

7 files changed

+647
-19
lines changed

src/autocomplete/CompletionFormatter.ts

Lines changed: 210 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
1-
import { CompletionItem, CompletionItemKind, CompletionList, InsertTextFormat } from 'vscode-languageserver';
1+
import {
2+
CompletionItem,
3+
CompletionItemKind,
4+
CompletionList,
5+
InsertTextFormat,
6+
Range,
7+
Position,
8+
TextEdit,
9+
} from 'vscode-languageserver';
210
import { Context } from '../context/Context';
311
import { ResourceAttributesSet, TopLevelSection, TopLevelSectionsSet } from '../context/ContextType';
12+
import { Resource } from '../context/semantic/Entity';
13+
import { EntityType } from '../context/semantic/SemanticTypes';
414
import { NodeType } from '../context/syntaxtree/utils/NodeType';
515
import { DocumentType } from '../document/Document';
16+
import { SchemaRetriever } from '../schema/SchemaRetriever';
617
import { EditorSettings } from '../settings/Settings';
718
import { LoggerFactory } from '../telemetry/LoggerFactory';
819
import { getIndentationString } from '../utils/IndentationUtils';
20+
import { RESOURCE_ATTRIBUTE_TYPES } from './CompletionUtils';
921

1022
export type CompletionItemData = {
1123
type?: 'object' | 'array' | 'simple';
@@ -17,6 +29,8 @@ export interface ExtendedCompletionItem extends CompletionItem {
1729
}
1830

1931
export class CompletionFormatter {
32+
// In CompletionFormatter class
33+
2034
private static readonly log = LoggerFactory.getLogger(CompletionFormatter);
2135
private static instance: CompletionFormatter;
2236

@@ -38,12 +52,18 @@ export class CompletionFormatter {
3852
return `{INDENT${numberOfIndents}}`;
3953
}
4054

41-
format(completions: CompletionList, context: Context, editorSettings: EditorSettings): CompletionList {
55+
format(
56+
completions: CompletionList,
57+
context: Context,
58+
editorSettings: EditorSettings,
59+
lineContent?: string,
60+
schemaRetriever?: SchemaRetriever,
61+
): CompletionList {
4262
try {
4363
const documentType = context.documentType;
44-
45-
const formattedItems = completions.items.map((item) => this.formatItem(item, documentType, editorSettings));
46-
64+
const formattedItems = completions.items.map((item) =>
65+
this.formatItem(item, documentType, editorSettings, context, lineContent, schemaRetriever),
66+
);
4767
return {
4868
...completions,
4969
items: formattedItems,
@@ -58,6 +78,9 @@ export class CompletionFormatter {
5878
item: CompletionItem,
5979
documentType: DocumentType,
6080
editorSettings: EditorSettings,
81+
context: Context,
82+
lineContent?: string,
83+
schemaRetriever?: SchemaRetriever,
6184
): CompletionItem {
6285
const formattedItem = { ...item };
6386

@@ -66,19 +89,198 @@ export class CompletionFormatter {
6689
return formattedItem;
6790
}
6891

92+
// Set filterText for ALL items (including snippets) when in JSON with quotes
93+
const isInJsonString = documentType === DocumentType.JSON && context.syntaxNode.type === 'string';
94+
if (isInJsonString) {
95+
formattedItem.filterText = `"${context.text}"`;
96+
}
97+
6998
const textToFormat = item.insertText ?? item.label;
7099

71100
if (documentType === DocumentType.JSON) {
72-
formattedItem.insertText = this.formatForJson(textToFormat);
101+
const result = this.formatForJson(
102+
editorSettings,
103+
textToFormat,
104+
item,
105+
context,
106+
lineContent,
107+
schemaRetriever,
108+
);
109+
formattedItem.textEdit = TextEdit.replace(result.range, result.text);
110+
if (result.isSnippet) {
111+
formattedItem.insertTextFormat = InsertTextFormat.Snippet;
112+
}
113+
delete formattedItem.insertText;
73114
} else {
74115
formattedItem.insertText = this.formatForYaml(textToFormat, item, editorSettings);
75116
}
76117

77118
return formattedItem;
78119
}
79120

80-
private formatForJson(label: string): string {
81-
return label;
121+
private formatForJson(
122+
editorSettings: EditorSettings,
123+
label: string,
124+
item: CompletionItem,
125+
context: Context,
126+
lineContent?: string,
127+
schemaRetriever?: SchemaRetriever,
128+
): { text: string; range: Range; isSnippet: boolean } {
129+
const shouldFormat = context.syntaxNode.type === 'string' && !context.isValue() && lineContent;
130+
131+
const itemData = item.data as CompletionItemData | undefined;
132+
133+
let formatAsObject = itemData?.type === 'object';
134+
let formatAsArray = itemData?.type === 'array';
135+
let formatAsString = false;
136+
137+
if (this.isTopLevelSection(label)) {
138+
if (label === String(TopLevelSection.Description)) {
139+
formatAsString = true;
140+
} else {
141+
formatAsObject = true;
142+
}
143+
}
144+
// If type is not in item.data and we have schemaRetriever, look it up from schema
145+
if ((!itemData?.type || itemData?.type === 'simple') && schemaRetriever && context.entity) {
146+
const propertyType = this.getPropertyTypeFromSchema(schemaRetriever, context, label);
147+
148+
switch (propertyType) {
149+
case 'object': {
150+
formatAsObject = true;
151+
break;
152+
}
153+
case 'array': {
154+
formatAsArray = true;
155+
156+
break;
157+
}
158+
case 'string': {
159+
formatAsString = true;
160+
161+
break;
162+
}
163+
// No default
164+
}
165+
}
166+
167+
const indentation = ' '.repeat(context.startPosition.column);
168+
const indentString = getIndentationString(editorSettings, DocumentType.JSON);
169+
170+
let replacementText = `${indentation}"${label}":`;
171+
let isSnippet = false;
172+
173+
if (shouldFormat) {
174+
isSnippet = true;
175+
if (formatAsObject) {
176+
replacementText = `${indentation}"${label}": {\n${indentation}${indentString}$0\n${indentation}}`;
177+
} else if (formatAsArray) {
178+
replacementText = `${indentation}"${label}": [\n${indentation}${indentString}$0\n${indentation}]`;
179+
} else if (formatAsString) {
180+
replacementText = `${indentation}"${label}": "$0"`;
181+
}
182+
}
183+
184+
const range = Range.create(
185+
Position.create(context.startPosition.row, 0),
186+
Position.create(context.endPosition.row, context.endPosition.column + 1),
187+
);
188+
189+
return {
190+
text: replacementText,
191+
range: range,
192+
isSnippet: isSnippet,
193+
};
194+
}
195+
196+
/**
197+
* Get the type of a property from the CloudFormation schema
198+
* @param schemaRetriever - SchemaRetriever instance to get schemas
199+
* @param context - Current context with entity and property path information
200+
* @param propertyName - Name of the property to look up
201+
* @returns The first type found in the schema ('object', 'array', 'string', etc.) or undefined
202+
*/
203+
private getPropertyTypeFromSchema(
204+
schemaRetriever: SchemaRetriever,
205+
context: Context,
206+
propertyName: string,
207+
): string | undefined {
208+
let resourceSchema;
209+
210+
if (ResourceAttributesSet.has(propertyName)) {
211+
return RESOURCE_ATTRIBUTE_TYPES[propertyName];
212+
}
213+
214+
const entity = context.entity;
215+
if (!entity || context.getEntityType() !== EntityType.Resource) {
216+
return undefined;
217+
}
218+
219+
const resourceType = (entity as Resource).Type;
220+
if (!resourceType) {
221+
return undefined;
222+
}
223+
224+
try {
225+
const combinedSchemas = schemaRetriever.getDefault();
226+
227+
resourceSchema = combinedSchemas.schemas.get(resourceType);
228+
if (!resourceSchema) {
229+
return undefined;
230+
}
231+
} catch {
232+
return undefined;
233+
}
234+
235+
const propertiesIndex = context.propertyPath.indexOf('Properties');
236+
let propertyPath: string[];
237+
238+
if (propertiesIndex === -1) {
239+
propertyPath = [propertyName];
240+
} else {
241+
const pathAfterProperties = context.propertyPath.slice(propertiesIndex + 1).map(String);
242+
243+
if (
244+
pathAfterProperties.length > 0 &&
245+
pathAfterProperties[pathAfterProperties.length - 1] === context.text
246+
) {
247+
propertyPath = [...pathAfterProperties.slice(0, -1), propertyName];
248+
} else if (pathAfterProperties[pathAfterProperties.length - 1] === propertyName) {
249+
propertyPath = pathAfterProperties;
250+
} else {
251+
propertyPath = [...pathAfterProperties, propertyName];
252+
}
253+
}
254+
255+
// Build JSON pointer path using wildcard notation for array indices
256+
// CloudFormation schemas use /properties/Tags/*/Key format for array item properties
257+
const schemaPath = propertyPath.map((part) => (Number.isNaN(Number(part)) ? part : '*'));
258+
const jsonPointerParts = ['properties', ...schemaPath];
259+
260+
const jsonPointerPath = '/' + jsonPointerParts.join('/');
261+
262+
try {
263+
const propertyDefinitions = resourceSchema.resolveJsonPointerPath(jsonPointerPath);
264+
265+
if (propertyDefinitions.length === 0) {
266+
return undefined;
267+
}
268+
269+
const propertyDef = propertyDefinitions[0];
270+
271+
if (propertyDef && 'type' in propertyDef) {
272+
const type = propertyDef.type;
273+
if (Array.isArray(type)) {
274+
return type[0];
275+
} else if (typeof type === 'string') {
276+
return type;
277+
}
278+
}
279+
280+
return undefined;
281+
} catch {
282+
return undefined;
283+
}
82284
}
83285

84286
private formatForYaml(label: string, item: CompletionItem | undefined, editorSettings: EditorSettings): string {

src/autocomplete/CompletionRouter.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Entity, Output, Parameter } from '../context/semantic/Entity';
1212
import { EntityType } from '../context/semantic/SemanticTypes';
1313
import { DocumentType } from '../document/Document';
1414
import { DocumentManager } from '../document/DocumentManager';
15+
import { SchemaRetriever } from '../schema/SchemaRetriever';
1516
import { CfnExternal } from '../server/CfnExternal';
1617
import { CfnInfraCore } from '../server/CfnInfraCore';
1718
import { CfnLspProviders } from '../server/CfnLspProviders';
@@ -48,6 +49,7 @@ export class CompletionRouter implements SettingsConfigurable, Closeable {
4849
private readonly contextManager: ContextManager,
4950
private readonly completionProviderMap: Map<CompletionProviderType, CompletionProvider>,
5051
private readonly documentManager: DocumentManager,
52+
private readonly schemaRetriever: SchemaRetriever,
5153
private readonly entityFieldCompletionProviderMap = createEntityFieldProviders(),
5254
) {}
5355

@@ -89,6 +91,7 @@ export class CompletionRouter implements SettingsConfigurable, Closeable {
8991

9092
const completions = provider?.getCompletions(context, params) ?? [];
9193
const editorSettings = this.documentManager.getEditorSettingsForDocument(params.textDocument.uri);
94+
const lineContent = this.documentManager.getLine(params.textDocument.uri, context.startPosition.row);
9295

9396
if (completions instanceof Promise) {
9497
return await completions.then((result) => {
@@ -99,6 +102,8 @@ export class CompletionRouter implements SettingsConfigurable, Closeable {
99102
},
100103
context,
101104
editorSettings,
105+
lineContent,
106+
this.schemaRetriever,
102107
);
103108
});
104109
} else if (completions) {
@@ -107,7 +112,7 @@ export class CompletionRouter implements SettingsConfigurable, Closeable {
107112
items: completions.slice(0, this.completionSettings.maxCompletions),
108113
};
109114

110-
return this.formatter.format(completionList, context, editorSettings);
115+
return this.formatter.format(completionList, context, editorSettings, lineContent, this.schemaRetriever);
111116
}
112117
return;
113118
}
@@ -272,10 +277,13 @@ export class CompletionRouter implements SettingsConfigurable, Closeable {
272277
}
273278

274279
static create(core: CfnInfraCore, external: CfnExternal, providers: CfnLspProviders) {
280+
CompletionFormatter.getInstance();
275281
return new CompletionRouter(
276282
core.contextManager,
277283
createCompletionProviders(core, external, providers),
278284
core.documentManager,
285+
external.schemaRetriever,
286+
createEntityFieldProviders(),
279287
);
280288
}
281289
}

src/autocomplete/CompletionUtils.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ import { LoggerFactory } from '../telemetry/LoggerFactory';
1616
import { ExtensionName } from '../utils/ExtensionConfig';
1717
import { ExtendedCompletionItem } from './CompletionFormatter';
1818

19+
// Resource attributes are not given a data.type upon completionItem creation and
20+
// no schema for resource attributes. This is used for formatting completion items.
21+
export const RESOURCE_ATTRIBUTE_TYPES: Record<string, string> = {
22+
DependsOn: 'string', // string | string[] - array of resource logical IDs
23+
Condition: 'string', // Reference to a condition name
24+
Metadata: 'object', // Arbitrary JSON/YAML object
25+
CreationPolicy: 'object', // Configuration object with specific properties
26+
DeletionPolicy: 'string', // Enum: "Delete" | "Retain" | "Snapshot"
27+
UpdatePolicy: 'object', // Configuration object with update policies
28+
UpdateReplacePolicy: 'string', // Enum: "Delete" | "Retain" | "Snapshot"
29+
};
30+
1931
/**
2032
* Creates a replacement range from a context's start and end positions.
2133
* This is used for text edits in completion items.

src/autocomplete/TopLevelSectionCompletionProvider.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { CompletionItem, CompletionItemKind, CompletionParams, InsertTextFormat } from 'vscode-languageserver';
22
import { Context } from '../context/Context';
3-
import { TopLevelSection, TopLevelSections } from '../context/ContextType';
3+
import { TopLevelSection, TopLevelSections, TopLevelSectionsWithLogicalIdsSet } from '../context/ContextType';
44
import { SyntaxTreeManager } from '../context/syntaxtree/SyntaxTreeManager';
55
import { DocumentType } from '../document/Document';
66
import { DocumentManager } from '../document/DocumentManager';
@@ -119,7 +119,17 @@ ${CompletionFormatter.getIndentPlaceholder(1)}\${1:ConditionName}: $2`,
119119
return this.constantsFeatureFlag.isEnabled();
120120
}
121121
return true;
122-
}).map((section) => createCompletionItem(section, CompletionItemKind.Class));
122+
}).map((section) => {
123+
const shouldBeObject = TopLevelSectionsWithLogicalIdsSet.has(section);
124+
125+
const options = shouldBeObject
126+
? {
127+
data: { type: 'object' },
128+
}
129+
: undefined;
130+
131+
return createCompletionItem(section, CompletionItemKind.Class, options);
132+
});
123133
}
124134

125135
private getTopLevelSectionSnippetCompletions(context: Context, params: CompletionParams): CompletionItem[] {

0 commit comments

Comments
 (0)