diff --git a/packages/language-support/src/autocompletion/completionCoreCompletions.ts b/packages/language-support/src/autocompletion/completionCoreCompletions.ts index 81185ce22..999bab86e 100644 --- a/packages/language-support/src/autocompletion/completionCoreCompletions.ts +++ b/packages/language-support/src/autocompletion/completionCoreCompletions.ts @@ -44,6 +44,7 @@ import { completeRelationshipType, allLabelCompletions, allReltypeCompletions, + completeNodeLabel, } from './schemaBasedCompletions'; import { backtickIfNeeded, uniq } from './autocompletionHelpers'; @@ -702,7 +703,7 @@ export function completionCoreCompletion( } if (topExprParent === CypherParser.RULE_nodePattern) { - return allLabelCompletions(dbSchema); + return completeNodeLabel(dbSchema, parsingResult, symbolsInfo); } if (topExprParent === CypherParser.RULE_relationshipPattern) { diff --git a/packages/language-support/src/autocompletion/schemaBasedCompletions.ts b/packages/language-support/src/autocompletion/schemaBasedCompletions.ts index 23a128593..a6f045e3d 100644 --- a/packages/language-support/src/autocompletion/schemaBasedCompletions.ts +++ b/packages/language-support/src/autocompletion/schemaBasedCompletions.ts @@ -10,8 +10,8 @@ import { NodePatternContext, PatternElementContext, QuantifierContext, + RelationshipPatternContext, } from '../generated-parser/CypherCmdParser'; -import { ParserRuleContext } from 'antlr4'; import { backtickIfNeeded } from './autocompletionHelpers'; import { _internalFeatureFlags } from '../featureFlags'; @@ -52,45 +52,45 @@ export const allReltypeCompletions = (dbSchema: DbSchema) => reltypesToCompletions(dbSchema.relationshipTypes); function intersectChildren( - relsFromLabels: Map>, + connectedLabels: Map>, children: LabelOrCondition[], ): Set { let intersection: Set = undefined; children.forEach((c) => { intersection = intersection ? (intersection = intersection.intersection( - walkLabelTree(relsFromLabels, c), + walkLabelTree(connectedLabels, c), )) - : walkLabelTree(relsFromLabels, c); + : walkLabelTree(connectedLabels, c); }); return intersection ?? new Set(); } function uniteChildren( - relsFromLabels: Map>, + connectedLabels: Map>, children: LabelOrCondition[], ): Set { let union: Set = new Set(); children.forEach( - (c) => (union = union.union(walkLabelTree(relsFromLabels, c))), + (c) => (union = union.union(walkLabelTree(connectedLabels, c))), ); return union; } function walkLabelTree( - relsFromLabels: Map>, + connectedLabels: Map>, labelTree: LabelOrCondition, ): Set { if (isLabelLeaf(labelTree)) { - return relsFromLabels.get(labelTree.value); + return connectedLabels.get(labelTree.value); } else if (labelTree.andOr == 'and') { - return intersectChildren(relsFromLabels, labelTree.children); + return intersectChildren(connectedLabels, labelTree.children); } else { - return uniteChildren(relsFromLabels, labelTree.children); + return uniteChildren(connectedLabels, labelTree.children); } } -function getRelsFromLabelsSet(dbSchema: DbSchema): Map> { +function getRelsFromNodesSet(dbSchema: DbSchema): Map> { if (dbSchema.graphSchema) { const relsFromLabelsSet: Map> = new Map(); dbSchema.graphSchema.forEach((rel) => { @@ -112,21 +112,34 @@ function getRelsFromLabelsSet(dbSchema: DbSchema): Map> { return undefined; } -export function completeRelationshipType( +function getNodesFromRelsSet(dbSchema: DbSchema): Map> { + if (dbSchema.graphSchema) { + const nodesFromRelsSet: Map> = new Map(); + dbSchema.graphSchema.forEach((rel) => { + if (!nodesFromRelsSet.has(rel.relType)) { + nodesFromRelsSet.set(rel.relType, new Set()); + } + const currentRelEntry = nodesFromRelsSet.get(rel.relType); + currentRelEntry.add(rel.to); + currentRelEntry.add(rel.from); + }); + return nodesFromRelsSet; + } + return undefined; +} + +export function completeNodeLabel( dbSchema: DbSchema, parsingResult: ParsedStatement, symbolsInfo: SymbolsInfo, ): CompletionItem[] { - if (!_internalFeatureFlags.schemaBasedPatternCompletions) { - return allReltypeCompletions(dbSchema); + if ( + !_internalFeatureFlags.schemaBasedPatternCompletions || + dbSchema.graphSchema === undefined + ) { + return allLabelCompletions(dbSchema); } - if (dbSchema.graphSchema === undefined) { - return allReltypeCompletions(dbSchema); - } - - // limitation: not checking PathPatternNonEmptyContext - // limitation: not handling parenthesized paths const callContext = findParent( parsingResult.stopNode.parentCtx, (x) => x instanceof PatternElementContext, @@ -134,13 +147,79 @@ export function completeRelationshipType( if (callContext instanceof PatternElementContext) { const lastValidElement = callContext.children.toReversed().find((child) => { - if (child instanceof ParserRuleContext) { + if (child instanceof RelationshipPatternContext) { if (child.exception === null) { return true; } } }); + // limitation: bailing out on quantifiers + if (lastValidElement instanceof QuantifierContext) { + return allLabelCompletions(dbSchema); + } + + if (lastValidElement instanceof RelationshipPatternContext) { + // limitation: not checking anonymous variables + const variable = lastValidElement.variable(); + if (variable === null) { + return allLabelCompletions(dbSchema); + } + + const foundVariable = symbolsInfo?.symbolTables + ?.flat() + .find((entry) => entry.references.includes(variable.start.start)); + + if ( + foundVariable === undefined || + ('children' in foundVariable.labels && + foundVariable.labels.children.length == 0) + ) { + return allLabelCompletions(dbSchema); + } + + // limitation: not direction-aware (ignores <- vs ->) + // limitation: not checking node label repetition + const nodesFromRelsSet = getNodesFromRelsSet(dbSchema); + const rels = walkLabelTree(nodesFromRelsSet, foundVariable.labels); + + return labelsToCompletions(Array.from(rels)); + } + } + + return allLabelCompletions(dbSchema); +} + +export function completeRelationshipType( + dbSchema: DbSchema, + parsingResult: ParsedStatement, + symbolsInfo: SymbolsInfo, +): CompletionItem[] { + if ( + !_internalFeatureFlags.schemaBasedPatternCompletions || + dbSchema.graphSchema === undefined + ) { + return allReltypeCompletions(dbSchema); + } + + // limitation: not checking PathPatternNonEmptyContext + // limitation: not handling parenthesized paths + const patternContext = findParent( + parsingResult.stopNode.parentCtx, + (x) => x instanceof PatternElementContext, + ); + + if (patternContext instanceof PatternElementContext) { + const lastValidElement = patternContext.children + .toReversed() + .find((child) => { + if (child instanceof NodePatternContext) { + if (child.exception === null) { + return true; + } + } + }); + // limitation: bailing out on quantifiers if (lastValidElement instanceof QuantifierContext) { return allReltypeCompletions(dbSchema); @@ -166,8 +245,8 @@ export function completeRelationshipType( } // limitation: not direction-aware (ignores <- vs ->) - // limitation: not checking relationship variable reuse - const relsFromLabelsSet = getRelsFromLabelsSet(dbSchema); + // limitation: not checking relationship type repetition + const relsFromLabelsSet = getRelsFromNodesSet(dbSchema); const rels = walkLabelTree(relsFromLabelsSet, foundVariable.labels); return reltypesToCompletions(Array.from(rels)); diff --git a/packages/language-support/src/tests/autocompletion/schemaBasedPatternCompletion.test.ts b/packages/language-support/src/tests/autocompletion/schemaBasedPatternCompletion.test.ts index cf097e987..d4759ab42 100644 --- a/packages/language-support/src/tests/autocompletion/schemaBasedPatternCompletion.test.ts +++ b/packages/language-support/src/tests/autocompletion/schemaBasedPatternCompletion.test.ts @@ -64,7 +64,68 @@ describe('completeRelationshipType', () => { }); }); - test('Simple pattern 1', () => { + test('Simple node completion pattern 1', () => { + const query = 'MATCH (t:Trainer)-[r:CATCHES]->(:'; + + testCompletions({ + query, + dbSchema, + computeSymbolsInfo: true, + expected: [ + { label: 'Pokemon', kind: CompletionItemKind.TypeParameter }, + //Limitation: does not handle direction + { label: 'Trainer', kind: CompletionItemKind.TypeParameter }, + ], + excluded: [ + { label: 'Gym', kind: CompletionItemKind.TypeParameter }, + { label: 'Type', kind: CompletionItemKind.TypeParameter }, + ], + }); + }); + + test('Simple node completion pattern 2', () => { + const query = 'MATCH (g:Gym)<-[r:CHALLENGES]-(:'; + + testCompletions({ + query, + dbSchema, + computeSymbolsInfo: true, + expected: [ + { label: 'Pokemon', kind: CompletionItemKind.TypeParameter }, + { label: 'Trainer', kind: CompletionItemKind.TypeParameter }, + //Limitation: does not handle direciton + { label: 'Gym', kind: CompletionItemKind.TypeParameter }, + ], + excluded: [ + { label: 'Region', kind: CompletionItemKind.TypeParameter }, + { label: 'Type', kind: CompletionItemKind.TypeParameter }, + { label: 'Move', kind: CompletionItemKind.TypeParameter }, + { label: 'UnrelatedLabel', kind: CompletionItemKind.TypeParameter }, + ], + }); + }); + + test('Simple node completion pattern with WHERE', () => { + const query = 'MATCH (n)-[r]->(m) WHERE r:IS_IN MATCH (n)-[r]->(:'; + + testCompletions({ + query, + dbSchema, + computeSymbolsInfo: true, + expected: [ + { label: 'Region', kind: CompletionItemKind.TypeParameter }, + //Limitation: does not handle direction + { label: 'Gym', kind: CompletionItemKind.TypeParameter }, + { label: 'Trainer', kind: CompletionItemKind.TypeParameter }, + ], + excluded: [ + { label: 'Pokemon', kind: CompletionItemKind.TypeParameter }, + { label: 'Type', kind: CompletionItemKind.TypeParameter }, + ], + }); + }); + + test('Simple rel completion pattern 1', () => { const query = 'MATCH (t:Trainer)-[r:'; testCompletions({ @@ -84,7 +145,7 @@ describe('completeRelationshipType', () => { }); }); - test('Simple pattern 2', () => { + test('Simple rel completion pattern 2', () => { const query = 'MATCH (p:Pokemon)-[r:'; testCompletions({ @@ -104,7 +165,7 @@ describe('completeRelationshipType', () => { }); }); - test('Longer path pattern', () => { + test('Longer relationship path pattern', () => { const query = 'MATCH (t:Trainer)-[:CATCHES]->(p:Pokemon)-[r:'; testCompletions({ @@ -124,6 +185,50 @@ describe('completeRelationshipType', () => { }); }); + test('Longer node path pattern', () => { + const query = 'MATCH (t:Trainer)-[:CATCHES]->(p:Pokemon)-[r:WEAK_TO]->(:'; + + testCompletions({ + query, + dbSchema, + computeSymbolsInfo: true, + expected: [ + { label: 'Type', kind: CompletionItemKind.TypeParameter }, + //Limitation: Does not handle direction + { label: 'Pokemon', kind: CompletionItemKind.TypeParameter }, + ], + excluded: [ + { label: 'UnrelatedLabel', kind: CompletionItemKind.TypeParameter }, + { label: 'Trainer', kind: CompletionItemKind.TypeParameter }, + { label: 'Gym', kind: CompletionItemKind.TypeParameter }, + { label: 'Region', kind: CompletionItemKind.TypeParameter }, + { label: 'Move', kind: CompletionItemKind.TypeParameter }, + ], + }); + }); + + test('Handles variables overlapping from other statements', () => { + const query = `MATCH (p:Trainer) RETURN p; + MATCH (p:Gym) RETURN p; + MATCH (p:Pokemon)-[r:`; + + testCompletions({ + query, + dbSchema, + computeSymbolsInfo: true, + expected: [ + { label: 'KNOWS', kind: CompletionItemKind.TypeParameter }, + { label: 'WEAK_TO', kind: CompletionItemKind.TypeParameter }, + { label: 'CATCHES', kind: CompletionItemKind.TypeParameter }, + { label: 'TRAINS', kind: CompletionItemKind.TypeParameter }, + ], + excluded: [ + { label: 'BATTLES', kind: CompletionItemKind.TypeParameter }, + { label: 'UNRELATED_RELTYPE', kind: CompletionItemKind.TypeParameter }, + ], + }); + }); + test('Handles scope', () => { const query = `MATCH (t:Trainer) CALL () { @@ -186,6 +291,29 @@ WHERE EXISTS { }); }); + test('Node completion works in pattern expression ', () => { + const query = `MATCH (p:Pokemon)-[r:KNOWS]->(m) +WHERE EXISTS { + (p)-[r]->(:`; + + testCompletions({ + query, + dbSchema, + computeSymbolsInfo: true, + expected: [ + { label: 'Move', kind: CompletionItemKind.TypeParameter }, + //Limitation: does not handle direction + { label: 'Pokemon', kind: CompletionItemKind.TypeParameter }, + ], + excluded: [ + { label: 'Gym', kind: CompletionItemKind.TypeParameter }, + { label: 'Trainer', kind: CompletionItemKind.TypeParameter }, + { label: 'Type', kind: CompletionItemKind.TypeParameter }, + { label: 'Region', kind: CompletionItemKind.TypeParameter }, + ], + }); + }); + test('Works in pattern comprehension ', () => { const query = `MATCH (p:Pokemon) RETURN [(p)-[:`; @@ -315,7 +443,7 @@ RETURN [(p)-[:`; }); }); - test('Handles AND logic with self-referencing ', () => { + test('Handles AND logic with self-referencing', () => { const query = 'MATCH (x) WHERE x:Pokemon OR x:Type MATCH (x)-[:'; testCompletions({ query, @@ -334,6 +462,45 @@ RETURN [(p)-[:`; }); }); + test('Handles undirected rels for node completions', () => { + const query = 'MATCH (p:Pokemon)-[r:KNOWS]-(:'; + testCompletions({ + query, + dbSchema, + computeSymbolsInfo: true, + expected: [ + { label: 'Move', kind: CompletionItemKind.TypeParameter }, + { label: 'Pokemon', kind: CompletionItemKind.TypeParameter }, + ], + excluded: [ + { label: 'Trainer', kind: CompletionItemKind.TypeParameter }, + { label: 'Region', kind: CompletionItemKind.TypeParameter }, + { label: 'Type', kind: CompletionItemKind.TypeParameter }, + { label: 'Gym', kind: CompletionItemKind.TypeParameter }, + ], + }); + }); + + test('Handles undirected rels for rel completions', () => { + const query = + 'MATCH (p:Pokemon)-[r:CHALLENGES]-(m:Gym)-[r2:IS_IN]->(reg:Region)-[:'; + testCompletions({ + query, + dbSchema, + computeSymbolsInfo: true, + expected: [{ label: 'IS_IN', kind: CompletionItemKind.TypeParameter }], + excluded: [ + { label: 'UNRELATED_RELTYPE', kind: CompletionItemKind.TypeParameter }, + { label: 'BATTLES', kind: CompletionItemKind.TypeParameter }, + { label: 'WEAK_TO', kind: CompletionItemKind.TypeParameter }, + { label: 'STRONG_AGAINST', kind: CompletionItemKind.TypeParameter }, + { label: 'CATCHES', kind: CompletionItemKind.TypeParameter }, + { label: 'TRAINS', kind: CompletionItemKind.TypeParameter }, + { label: 'CHALLENGES', kind: CompletionItemKind.TypeParameter }, + ], + }); + }); + test('Limitation: Does not handle union types, as they are not yet supported in symbol table ', () => { const query = 'MATCH (x:Pokemon|Trainer)-[r:'; @@ -397,7 +564,7 @@ RETURN [(p)-[:`; }); }); - test('Limitation: Does not handle anonymous variables as context ', () => { + test('Limitation: Does not handle anonymous variables as context - relationship completion ', () => { const query = 'MATCH (:Trainer)-[r:'; testCompletions({ @@ -414,6 +581,28 @@ RETURN [(p)-[:`; }); }); + test('Limitation: Does not handle anonymous variables as context - node completion', () => { + const query = 'MATCH (:Trainer)-[:TRAINS]->(:'; + + testCompletions({ + query, + dbSchema, + computeSymbolsInfo: true, + expected: [ + { label: 'Pokemon', kind: CompletionItemKind.TypeParameter }, + //Limitation - does not handle direction + { label: 'Trainer', kind: CompletionItemKind.TypeParameter }, + //Limitation - bails on anon + { label: 'Gym', kind: CompletionItemKind.TypeParameter }, + { label: 'Region', kind: CompletionItemKind.TypeParameter }, + { label: 'Type', kind: CompletionItemKind.TypeParameter }, + { label: 'Move', kind: CompletionItemKind.TypeParameter }, + { label: 'UnrelatedLabel', kind: CompletionItemKind.TypeParameter }, + { label: 'Unconnected', kind: CompletionItemKind.TypeParameter }, + ], + }); + }); + test('Limitation: Does not properly handle quantifiers ', () => { const query = 'MATCH (t:Trainer)-[r:CATCHES*1..3]->(p:Pokemon)-[r2:'; @@ -450,9 +639,9 @@ RETURN [(p)-[:`; }); }); - test('Limitation: Does not handle mid query cursor', () => { + test('Limitation: Does not handle direction-aware completions with context after caret ', () => { const beforeCursor = 'MATCH (p:Pokemon)-[r:'; - const query = beforeCursor + ']-(t:Trainer)--(u:UnrelatedLabel)'; + const query = beforeCursor + ']->(t:Trainer)'; testCompletions({ query, @@ -460,9 +649,12 @@ RETURN [(p)-[:`; computeSymbolsInfo: true, offset: beforeCursor.length, expected: [ + // all should be excluded as there is no relationship from pokemon to trainer + { label: 'CATCHES', kind: CompletionItemKind.TypeParameter }, + { label: 'TRAINS', kind: CompletionItemKind.TypeParameter }, { label: 'KNOWS', kind: CompletionItemKind.TypeParameter }, { label: 'WEAK_TO', kind: CompletionItemKind.TypeParameter }, - // always takes the latest finished node, rather than the correct one + { label: 'CHALLENGES', kind: CompletionItemKind.TypeParameter }, ], excluded: [ { label: 'UNRELATED_RELTYPE', kind: CompletionItemKind.TypeParameter }, @@ -470,71 +662,119 @@ RETURN [(p)-[:`; }); }); - test('Limitation: Does not handle direction-aware completions ', () => { - const beforeCursor = 'MATCH (p:Pokemon)-[r:'; - const query = beforeCursor + ']->(t:Trainer)'; + test('Limitation: Does not deduplicate existing relationship types in pattern ', () => { + const query = 'MATCH (t:Trainer)-[r:CATCHES|TRAINS|'; testCompletions({ query, dbSchema, computeSymbolsInfo: true, - offset: beforeCursor.length, expected: [ - // all should be excluded as there is no relationship from pokemon to trainer + { label: 'BATTLES', kind: CompletionItemKind.TypeParameter }, + // below should be excluded { label: 'CATCHES', kind: CompletionItemKind.TypeParameter }, { label: 'TRAINS', kind: CompletionItemKind.TypeParameter }, + ], + excluded: [ { label: 'KNOWS', kind: CompletionItemKind.TypeParameter }, { label: 'WEAK_TO', kind: CompletionItemKind.TypeParameter }, - { label: 'CHALLENGES', kind: CompletionItemKind.TypeParameter }, + ], + }); + }); + + test('Limitation: Does not deduplicate existing node labels in simple "|" pattern ', () => { + const query = 'MATCH (g)<-[r:CHALLENGES]-(p:Pokemon|Gym|'; + + testCompletions({ + query, + dbSchema, + computeSymbolsInfo: true, + expected: [ + { label: 'Trainer', kind: CompletionItemKind.TypeParameter }, + // below should be excluded + { label: 'Pokemon', kind: CompletionItemKind.TypeParameter }, + //Limitation - does not handle direction + { label: 'Gym', kind: CompletionItemKind.TypeParameter }, ], excluded: [ - { label: 'UNRELATED_RELTYPE', kind: CompletionItemKind.TypeParameter }, + { label: 'Type', kind: CompletionItemKind.TypeParameter }, + { label: 'Region', kind: CompletionItemKind.TypeParameter }, + { label: 'Move', kind: CompletionItemKind.TypeParameter }, + { label: 'UnrelatedLabel', kind: CompletionItemKind.TypeParameter }, ], }); }); - test('Limitation: Does not deduplicate existing relationship types in pattern ', () => { - const query = 'MATCH (t:Trainer)-[r:CATCHES|TRAINS|'; + test('Limitation: Does not deduplicate existing node labels in simple "&" pattern ', () => { + const query = 'MATCH (g)<-[r:CHALLENGES]-(p:Pokemon&Gym&'; testCompletions({ query, dbSchema, computeSymbolsInfo: true, expected: [ - { label: 'BATTLES', kind: CompletionItemKind.TypeParameter }, + { label: 'Trainer', kind: CompletionItemKind.TypeParameter }, // below should be excluded - { label: 'CATCHES', kind: CompletionItemKind.TypeParameter }, - { label: 'TRAINS', kind: CompletionItemKind.TypeParameter }, + { label: 'Pokemon', kind: CompletionItemKind.TypeParameter }, + //Limitation - does not handle direction + { label: 'Gym', kind: CompletionItemKind.TypeParameter }, ], excluded: [ - { label: 'KNOWS', kind: CompletionItemKind.TypeParameter }, - { label: 'WEAK_TO', kind: CompletionItemKind.TypeParameter }, + { label: 'Type', kind: CompletionItemKind.TypeParameter }, + { label: 'Region', kind: CompletionItemKind.TypeParameter }, + { label: 'Move', kind: CompletionItemKind.TypeParameter }, + { label: 'UnrelatedLabel', kind: CompletionItemKind.TypeParameter }, ], }); }); - test('Handles cursor position', () => { - const query = 'MATCH (t1:Trainer)-[r1:CATCHES]-(p1:Pokemon)-[r2:'; + test('Handles cursor position for rel completion', () => { + const beforeCursor = 'MATCH (t1:Trainer)-[r1:'; + const query = beforeCursor + 'CATCHES]-(p1:Pokemon)-[r2:'; testCompletions({ query, dbSchema, computeSymbolsInfo: true, - offset: 'MATCH (t1:Trainer)-[r1:'.length, + offset: beforeCursor.length, expected: [ { label: 'CATCHES', kind: CompletionItemKind.TypeParameter }, { label: 'TRAINS', kind: CompletionItemKind.TypeParameter }, + { label: 'IS_IN', kind: CompletionItemKind.TypeParameter }, + //Limitation: Only take preceding node into account + { label: 'BATTLES', kind: CompletionItemKind.TypeParameter }, ], excluded: [ { label: 'UNRELATED_RELTYPE', kind: CompletionItemKind.TypeParameter }, - // there's no outoing knows from trainer - // { label: 'KNOWS', kind: CompletionItemKind.TypeParameter }, - // { label: 'WEAK_TO', kind: CompletionItemKind.TypeParameter }, + // there's no in/outoing knows from trainer + { label: 'KNOWS', kind: CompletionItemKind.TypeParameter }, + { label: 'WEAK_TO', kind: CompletionItemKind.TypeParameter }, // there's no BATTLE between trainer and pokemon // { label: 'BATTLES', kind: CompletionItemKind.TypeParameter }, ], }); }); + + test('Handles cursor position for node completion', () => { + const beforeCursor = 'MATCH (t1:Trainer)-[r1:CATCHES]-(p1:'; + const query = beforeCursor + 'Pokemon)-[r2:IS_IN]->(:'; + + testCompletions({ + query, + dbSchema, + computeSymbolsInfo: true, + offset: beforeCursor.length, + expected: [ + { label: 'Pokemon', kind: CompletionItemKind.TypeParameter }, + //limitation: direction + { label: 'Trainer', kind: CompletionItemKind.TypeParameter }, + ], + excluded: [ + { label: 'Region', kind: CompletionItemKind.TypeParameter }, + { label: 'Gym', kind: CompletionItemKind.TypeParameter }, + ], + }); + }); });