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
20 changes: 18 additions & 2 deletions src/autocomplete/CompletionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,32 @@ export function createCompletionItem(
sortText?: string;
documentation?: string;
data?: Record<string, unknown>;
context?: Context;
},
): CompletionItem {
let textEdit: TextEdit | undefined = undefined;
let filterText = label;
const insertText = options?.insertText ?? label;
if (options?.context) {
const textInQuotes = options.context.textInQuotes();
if (textInQuotes) {
const range = createReplacementRange(options.context);
filterText = `${textInQuotes}${String(label)}${textInQuotes}`;
if (range) {
textEdit = TextEdit.replace(range, `${textInQuotes}${insertText}${textInQuotes}`);
}
}
}

return {
label,
kind,
detail: options?.detail ?? ExtensionName,
insertText: options?.insertText ?? label,
insertText: insertText,
insertTextFormat: options?.insertTextFormat,
insertTextMode: options?.insertTextMode,
filterText: label,
textEdit: textEdit,
filterText: filterText,
sortText: options?.sortText,
documentation: `${options?.documentation ? `${options?.documentation}\n` : ''}Source: ${ExtensionName}`,
data: options?.data,
Expand Down
62 changes: 10 additions & 52 deletions src/autocomplete/IntrinsicFunctionArgumentCompletionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,19 +462,9 @@ export class IntrinsicFunctionArgumentCompletionProvider implements CompletionPr

private getMappingNameCompletions(mappingsEntities: Map<string, Context>, context: Context): CompletionItem[] {
try {
const items = [...mappingsEntities.keys()].map((key) => {
const item = createCompletionItem(key, CompletionItemKind.EnumMember);

if (context.text.length > 0) {
const range = createReplacementRange(context);
if (range) {
item.textEdit = TextEdit.replace(range, key);
delete item.insertText;
}
}

return item;
});
const items = [...mappingsEntities.keys()].map((key) =>
createCompletionItem(key, CompletionItemKind.EnumMember, { context }),
);

return context.text.length > 0 ? this.fuzzySearch(items, context.text) : items;
} catch (error) {
Expand Down Expand Up @@ -508,19 +498,9 @@ export class IntrinsicFunctionArgumentCompletionProvider implements CompletionPr
return undefined;
}

const items = topLevelKeys.map((key) => {
const item = createCompletionItem(key, CompletionItemKind.EnumMember);

if (context.text.length > 0) {
const range = createReplacementRange(context);
if (range) {
item.textEdit = TextEdit.replace(range, key);
delete item.insertText;
}
}

return item;
});
const items = topLevelKeys.map((key) =>
createCompletionItem(key, CompletionItemKind.EnumMember, { context }),
);

return context.text.length > 0 ? this.fuzzySearch(items, context.text) : items;
} catch (error) {
Expand Down Expand Up @@ -556,19 +536,9 @@ export class IntrinsicFunctionArgumentCompletionProvider implements CompletionPr
return undefined;
}

const items = secondLevelKeys.map((key) => {
const item = createCompletionItem(key, CompletionItemKind.EnumMember);

if (context.text.length > 0) {
const range = createReplacementRange(context);
if (range) {
item.textEdit = TextEdit.replace(range, key);
delete item.insertText;
}
}

return item;
});
const items = secondLevelKeys.map((key) =>
createCompletionItem(key, CompletionItemKind.EnumMember, { context }),
);

return context.text.length > 0 ? this.fuzzySearch(items, context.text) : items;
} catch (error) {
Expand Down Expand Up @@ -652,19 +622,7 @@ export class IntrinsicFunctionArgumentCompletionProvider implements CompletionPr

const items = [...resourceEntities.keys()]
.filter((logicalId) => logicalId !== context.logicalId)
.map((logicalId) => {
const item = createCompletionItem(logicalId, CompletionItemKind.Reference);

if (context.text.length > 0) {
const range = createReplacementRange(context);
if (range) {
item.textEdit = TextEdit.replace(range, logicalId);
delete item.insertText;
}
}

return item;
});
.map((logicalId) => createCompletionItem(logicalId, CompletionItemKind.Reference, { context }));

return context.text.length > 0 ? this.fuzzySearch(items, context.text) : items;
}
Expand Down
4 changes: 4 additions & 0 deletions src/autocomplete/ResourcePropertyCompletionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export class ResourcePropertyCompletionProvider implements CompletionProvider {
existingProperties,
context.text.length === 0,
schema,
context,
);
}

Expand Down Expand Up @@ -162,6 +163,7 @@ export class ResourcePropertyCompletionProvider implements CompletionProvider {
const completions = enumValues.map((value, index) =>
createCompletionItem(String(value), CompletionItemKind.EnumMember, {
sortText: `${index}`,
context: context,
}),
);

Expand Down Expand Up @@ -204,6 +206,7 @@ export class ResourcePropertyCompletionProvider implements CompletionProvider {
existingProperties: Set<string>,
isEmptyText: boolean,
schema: ResourceSchema,
context: Context,
): CompletionItem[] {
const result: CompletionItem[] = [];

Expand Down Expand Up @@ -232,6 +235,7 @@ export class ResourcePropertyCompletionProvider implements CompletionProvider {
CompletionItemKind.Property,
{
data: itemData,
context: context,
},
);

Expand Down
19 changes: 18 additions & 1 deletion src/context/Context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { NodeType } from './syntaxtree/utils/NodeType';
import { YamlNodeTypes, CommonNodeTypes } from './syntaxtree/utils/TreeSitterTypes';

export type SectionType = TopLevelSection | 'Unknown';
export type QuoteCharacter = '"' | "'";

export class Context {
public readonly section: SectionType;
Expand Down Expand Up @@ -94,6 +95,16 @@ export class Context {
);
}

public textInQuotes(): QuoteCharacter | undefined {
if (NodeType.isNodeType(this.node, YamlNodeTypes.DOUBLE_QUOTE_SCALAR)) {
return '"';
} else if (NodeType.isNodeType(this.node, YamlNodeTypes.SINGLE_QUOTE_SCALAR)) {
return "'";
}

return undefined;
}

public isValue() {
// SYNTHETIC_KEY_OR_VALUE can be both key and value
if (NodeType.isNodeType(this.node, CommonNodeTypes.SYNTHETIC_KEY_OR_VALUE)) {
Expand Down Expand Up @@ -121,7 +132,10 @@ export class Context {
// Find the parent block_mapping_pair node to get the key position
let current = this.node.parent;
while (current) {
if (NodeType.isNodeType(current, YamlNodeTypes.BLOCK_MAPPING_PAIR)) {
// if we are in a FLOW (nested JSON) then just return FALSE because this doesn't apply
if (NodeType.isNodeType(current, YamlNodeTypes.FLOW_MAPPING, YamlNodeTypes.FLOW_SEQUENCE)) {
return false;
} else if (NodeType.isNodeType(current, YamlNodeTypes.BLOCK_MAPPING_PAIR)) {
// If current node starts on a different row than the key, it could be a new key
return current.startPosition.row < this.node.startPosition.row;
}
Expand Down Expand Up @@ -252,13 +266,16 @@ export class Context {
section: this.section,
logicalId: this.logicalId,
text: this.text,
nodeType: this.node.type,
propertyPath: this.propertyPath,
entitySection: this.entitySection,
metadata: `isTopLevel=${this.isTopLevel}, isResourceType=${this.isResourceType}, isIntrinsicFunction=${this.isIntrinsicFunc}, isPseudoParameter=${this.isPseudoParameter}, isResourceAttribute=${this.isResourceAttribute}`,
node: { start: this.node.startPosition, end: this.node.endPosition },
root: { start: this.entityRootNode?.startPosition, end: this.entityRootNode?.endPosition },
entity: this.entity,
intrinsicContext: this.intrinsicContext.record(),
isKey: this.isKey(),
isValue: this.isValue(),
};
}
}
Expand Down
90 changes: 75 additions & 15 deletions src/context/syntaxtree/SyntaxTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,17 +109,25 @@
const isYAML = this.type === DocumentType.YAML;
let hasError = this.hasErrorInParentChain(initialNode);

if (isYAML && hasError) {
const incrementalNode = this.tryIncrementalParsing(position);
if (incrementalNode) {
initialNode = incrementalNode;
hasError = this.hasErrorInParentChain(initialNode); // Recalculate after incremental parsing
if (hasError) {
if (isYAML) {
const incrementalNode = this.tryIncrementalYamlParsing(position);
if (incrementalNode) {
initialNode = incrementalNode;
hasError = this.hasErrorInParentChain(initialNode); // Recalculate after incremental parsing
}
} else {
const incrementalNode = this.tryIncrementalJsonParsing(position);
if (incrementalNode) {
initialNode = incrementalNode;
hasError = this.hasErrorInParentChain(initialNode); // Recalculate after incremental parsing
}
}
}

// YAML-specific: if namedDescendantForPosition returned a block_mapping_pair,
// validate we're actually at colon/separator position
if (this.type === DocumentType.YAML && NodeType.isNodeType(initialNode, YamlNodeTypes.BLOCK_MAPPING_PAIR)) {
// 3. validate we're actually at colon/separator position
// prevents : triggering which has a bad parent path and results in false positive autocompletion
if (this.type === DocumentType.YAML && NodeType.isPairNode(initialNode, this.type)) {
const key = initialNode.childForFieldName('key');
const value = initialNode.childForFieldName('value');
if (
Expand All @@ -131,7 +139,13 @@
}
}

// Special handling for YAML: check for whitespace-only lines first
// 4. if we are in JSON even if this is YAML we probably have the right node already
// For scalar types, return immediately as they're already the most specific node
if (NodeType.isScalarNode(initialNode, this.type) && !hasError) {
return initialNode;
}

// 5. Special handling for YAML: check for whitespace-only lines first
if (this.type === DocumentType.YAML && !hasError) {
const currentLine = this.lines[point.row];
const trimmedLine = currentLine?.trim() || '';
Expand All @@ -144,7 +158,7 @@
}
}

// 3. Try to find the ideal node immediately: the most specific, valid, small node.
// 6. Try to find the ideal node immediately: the most specific, valid, small node.
// This is the best-case scenario and allows for a very fast exit.
const specificNode = NodeSearch.findMostSpecificNode(
initialNode,
Expand All @@ -155,7 +169,7 @@
return specificNode;
}

// 4. If no ideal node was found, the initialNode might be large or invalid.
// 7. If no ideal node was found, the initialNode might be large or invalid.
// Now, we search for a "better" alternative nearby.
const betterNode = NodeSearch.findNearbyNode(
this.tree.rootNode,
Expand All @@ -172,13 +186,13 @@
return betterNode;
}

// 5. Fallback: If no better alternative is found, return the original node
// 8. Fallback: If no better alternative is found, return the original node
// ONLY if it's valid. A large but valid node is better than nothing.
if (NodeType.isValidNode(initialNode)) {
return initialNode;
}

// 6. Last Resort: The initial node was invalid, and we found no good alternative.
// 9. Last Resort: The initial node was invalid, and we found no good alternative.
// Find any smaller node nearby, even if it's not perfectly valid, as it's
// better than returning a large, broken node.
const anySmallerNode = NodeSearch.findNearbyNode(
Expand Down Expand Up @@ -243,6 +257,13 @@
return undefined;
};

if (NodeType.isNodeType(node, YamlNodeTypes.FLOW_MAPPING)) {
const syntheticKey = createSyntheticNode('', point, point, node);
syntheticKey.type = CommonNodeTypes.SYNTHETIC_KEY;
syntheticKey.grammarType = CommonNodeTypes.SYNTHETIC_KEY;
return syntheticKey;
}

let closestKey = findClosestKey();

if (closestKey) {
Expand Down Expand Up @@ -306,7 +327,7 @@
return false;
}

private tryIncrementalParsing(position: Position): SyntaxNode | undefined {
private tryIncrementalYamlParsing(position: Position): SyntaxNode | undefined {
const currentLine = this.lines[position.line] ?? '';
const textBeforeCursor = currentLine.slice(0, Math.max(0, position.character));
const textAfterCursor = currentLine.slice(Math.max(0, position.character));
Expand All @@ -324,6 +345,33 @@
return undefined;
}

private tryIncrementalJsonParsing(position: Position): SyntaxNode | undefined {
const currentLine = this.lines[position.line] ?? '';
const textBeforeCursor = currentLine.slice(0, Math.max(0, position.character));
const textAfterCursor = currentLine.slice(Math.max(0, position.character));

// Strategy 1: If typing a key, add colon and space
if (textBeforeCursor.endsWith(':') && textBeforeCursor.trim() && !textAfterCursor.trim()) {
// Insert colon and space at cursor position
const modifiedLines = [...this.lines];
modifiedLines[position.line] = textBeforeCursor + ' null';
const completedContent = modifiedLines.join('\n');
const result = this.testIncrementalParsing(completedContent, position);
if (result) return result;
} else if (currentLine.includes('"')) {
// Strategy 2: Handle any quoted string as potential incomplete key
// Look for patterns like "text" and convert to "text": null
const modifiedLines = [...this.lines];
// Replace quoted strings that aren't followed by : with complete key-value pairs
modifiedLines[position.line] = currentLine.replace(/"([^"]*)"\s*(?!:)/g, '"$1": null');

Check warning on line 366 in src/context/syntaxtree/SyntaxTree.ts

View workflow job for this annotation

GitHub Actions / pr-build-test-nodejs

Prefer `String#replaceAll()` over `String#replace()`
const completedContent = modifiedLines.join('\n');
const result = this.testIncrementalParsing(completedContent, position);
if (result) return result;
}

return undefined;
}

private testIncrementalParsing(completedContent: string, position: Position): SyntaxNode | undefined {
try {
// Parse the completed content to create a temporary tree
Expand Down Expand Up @@ -437,7 +485,7 @@
if (NodeType.isPairNode(parent, this.type)) {
// This is a key-value pair. Add the key to our semantic path.
const key = NodeType.extractKeyFromPair(parent, this.type);
if (key) {
if (key !== undefined) {
propertyPath.push(key);
}
entityPath.push(parent);
Expand Down Expand Up @@ -474,6 +522,18 @@
propertyPath.push(index);
entityPath.push(current);
}
} else if (
NodeType.isNodeType(parent, YamlNodeTypes.FLOW_NODE) &&
NodeType.isNodeType(current, YamlNodeTypes.DOUBLE_QUOTE_SCALAR)
) {
// Could be incomplete key in nested JSON but need to look to grandparent
const grandparent = parent.parent;
if (grandparent && NodeType.isNodeType(grandparent, YamlNodeTypes.FLOW_MAPPING)) {
// Is incomplete key pair in an object
// { "" }
propertyPath.push(current.text.replace(/^,?\s*"|"\s*/g, ''));

Check warning on line 534 in src/context/syntaxtree/SyntaxTree.ts

View workflow job for this annotation

GitHub Actions / pr-build-test-nodejs

Prefer `String#replaceAll()` over `String#replace()`
entityPath.push(current);
}
}

current = parent;
Expand Down
5 changes: 5 additions & 0 deletions src/context/syntaxtree/utils/NodeType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export class NodeType {
return types.includes(node.type);
}

public static isScalarNode(node: SyntaxNode, documentType: DocumentType): boolean {
const set = documentType === DocumentType.JSON ? JSON_NODE_SETS.scalar : YAML_NODE_SETS.scalar;
return set.has(node.type);
}

public static isNotNodeType(node: SyntaxNode, ...types: string[]) {
if (types.length === 1) {
return node.type !== types[0];
Expand Down
Loading
Loading