UNPKG

@neo4j-cypher/language-support

Version:
686 lines (597 loc) 21.8 kB
import { Token } from 'antlr4'; import type { CandidateRule, CandidatesCollection } from 'antlr4-c3'; import { CodeCompletionCore } from 'antlr4-c3'; import { CompletionItem, CompletionItemKind, InsertTextFormat, } from 'vscode-languageserver-types'; import { DbSchema } from '../dbSchema'; import CypherLexer from '../generated-parser/CypherCmdLexer'; import CypherParser, { Expression2Context, } from '../generated-parser/CypherCmdParser'; import { findPreviousNonSpace, rulesDefiningVariables } from '../helpers'; import { CypherTokenType, lexerKeywords, lexerSymbols, tokenNames, } from '../lexerSymbols'; import { ParsedStatement } from '../parserWrapper'; import { consoleCommandEnabled } from '../parserWrapper'; const uniq = <T>(arr: T[]) => Array.from(new Set(arr)); const labelCompletions = (dbSchema: DbSchema) => dbSchema.labels?.map((labelName) => ({ label: labelName, kind: CompletionItemKind.TypeParameter, })) ?? []; const reltypeCompletions = (dbSchema: DbSchema) => dbSchema.relationshipTypes?.map((relType) => ({ label: relType, kind: CompletionItemKind.TypeParameter, })) ?? []; const functionNameCompletions = ( candidateRule: CandidateRule, tokens: Token[], dbSchema: DbSchema, ) => namespacedCompletion( candidateRule, tokens, Object.keys(dbSchema?.functions ?? {}), 'function', ); const procedureNameCompletions = ( candidateRule: CandidateRule, tokens: Token[], dbSchema: DbSchema, ) => namespacedCompletion( candidateRule, tokens, Object.keys(dbSchema?.procedures ?? {}), 'procedure', ); const namespacedCompletion = ( candidateRule: CandidateRule, tokens: Token[], fullNames: string[], type: 'procedure' | 'function', ) => { const namespacePrefix = calculateNamespacePrefix(candidateRule, tokens); if (namespacePrefix === null) { return []; } const kind = type === 'procedure' ? CompletionItemKind.Method : CompletionItemKind.Function; const detail = type === 'procedure' ? '(procedure)' : '(function)'; if (namespacePrefix === '') { // If we don't have any prefix show full functions and top level namespaces const topLevelPrefixes = fullNames .filter((fn) => fn.includes('.')) .map((fnName) => fnName.split('.')[0]); return uniq(topLevelPrefixes) .map((label) => ({ label, kind, detail: `(namespace)` })) .concat(fullNames.map((label) => ({ label, kind, detail }))); } else { // if we have a namespace prefix, complete on the namespace level: // apoc. => show `util` | `load` | `date` etc. const funcOptions = new Set<string>(); const namespaceOptions = new Set<string>(); for (const name of fullNames) { if (name.startsWith(namespacePrefix)) { // given prefix `apoc.` turn `apoc.util.sleep` => `util` const splitByDot = name.slice(namespacePrefix.length).split('.'); const option = splitByDot[0]; const isFunctionName = splitByDot.length === 1; // handle prefix `time.truncate.` turning `time.truncate` => `` if (option !== '') { if (isFunctionName) { funcOptions.add(option); } else { namespaceOptions.add(option); } } } } // test handle namespace with same name as function const functionNameCompletions = Array.from(funcOptions).map((label) => ({ label, kind, detail, })); const namespaceCompletions = Array.from(namespaceOptions).map((label) => ({ label, kind, detail: '(namespace)', })); return functionNameCompletions.concat(namespaceCompletions); } }; function getTokenCompletions( candidates: CandidatesCollection, ignoredTokens: Set<number>, ): CompletionItem[] { const tokenEntries = candidates.tokens.entries(); const completions = Array.from(tokenEntries).flatMap((value) => { const [tokenNumber, followUpList] = value; if (!ignoredTokens.has(tokenNumber)) { const isConsoleCommand = lexerSymbols[tokenNumber] === CypherTokenType.consoleCommand; const kind = isConsoleCommand ? CompletionItemKind.Event : CompletionItemKind.Keyword; const firstToken = isConsoleCommand ? tokenNames[tokenNumber].toLowerCase() : tokenNames[tokenNumber]; const followUpIndexes = followUpList.indexes; const firstIgnoredToken = followUpIndexes.findIndex((t) => ignoredTokens.has(t), ); const followUpTokens = followUpIndexes.slice( 0, firstIgnoredToken >= 0 ? firstIgnoredToken : followUpIndexes.length, ); const followUpString = followUpTokens.map((i) => tokenNames[i]).join(' '); if (firstToken === undefined) { return []; } else if (followUpString === '') { return [{ label: firstToken, kind }]; } else { const followUp = firstToken + ' ' + (isConsoleCommand ? followUpString.toLowerCase() : followUpString); if (followUpList.optional) { return [ { label: firstToken, kind }, { label: followUp, kind }, ]; } return [{ label: followUp, kind }]; } } else { return []; } }); return completions; } const parameterCompletions = ( dbInfo: DbSchema, expectedType: ExpectedParameterType, ): CompletionItem[] => Object.entries(dbInfo.parameters ?? {}) // eslint-disable-next-line @typescript-eslint/no-unused-vars .filter(([_, paramType]) => isExpectedParameterType(expectedType, paramType), ) .map(([paramName]) => ({ label: `$${paramName}`, kind: CompletionItemKind.Variable, })); const propertyKeyCompletions = (dbInfo: DbSchema): CompletionItem[] => dbInfo.propertyKeys?.map((propertyKey) => ({ label: propertyKey, kind: CompletionItemKind.Property, })) ?? []; enum ExpectedParameterType { String = 'STRING', Map = 'MAP', Any = 'ANY', } const inferExpectedParameterTypeFromContext = (context: CandidateRule) => { const parentRule = context.ruleList.at(-1); if ( [ CypherParser.RULE_stringOrParameter, CypherParser.RULE_commandNameExpression, CypherParser.RULE_symbolicNameOrStringParameter, CypherParser.RULE_symbolicNameOrStringParameterList, CypherParser.RULE_symbolicAliasNameOrParameter, CypherParser.RULE_passwordExpression, CypherParser.RULE_createUser, CypherParser.RULE_dropUser, CypherParser.RULE_alterUser, CypherParser.RULE_renameUser, CypherParser.RULE_createRole, CypherParser.RULE_dropRole, CypherParser.RULE_userNames, CypherParser.RULE_roleNames, CypherParser.RULE_renameRole, ].includes(parentRule) ) { return ExpectedParameterType.String; } else if ( [CypherParser.RULE_properties, CypherParser.RULE_mapOrParameter].includes( parentRule, ) ) { return ExpectedParameterType.Map; } else { return ExpectedParameterType.Any; } }; const isExpectedParameterType = ( expectedType: ExpectedParameterType, value: unknown, ) => { const typeName = typeof value; switch (expectedType) { case ExpectedParameterType.String: return typeName === 'string'; case ExpectedParameterType.Map: return typeName === 'object'; case ExpectedParameterType.Any: return true; } }; function calculateNamespacePrefix( candidateRule: CandidateRule, tokens: Token[], ): string | null { const ruleTokens = tokens.slice(candidateRule.startTokenIndex); const lastNonEOFToken = ruleTokens.at(-2); const nonSpaceTokens = ruleTokens.filter( (token) => token.type !== CypherLexer.SPACE && token.type !== CypherLexer.EOF, ); const lastNonSpaceIsDot = nonSpaceTokens.at(-1)?.type === CypherLexer.DOT; // `gds version` is invalid but `gds .version` and `gds. version` are valid // so if the last token is a space and the last non-space token // is anything but a dot return empty completions to avoid // creating invalid suggestions (db ping) if (lastNonEOFToken?.type === CypherLexer.SPACE && !lastNonSpaceIsDot) { return null; } // calculate the current namespace prefix // only keep finished namespaces both second level `gds.ver` => `gds.` // and first level make `gds` => '' if (!lastNonSpaceIsDot) { nonSpaceTokens.pop(); } const namespacePrefix = nonSpaceTokens.map((token) => token.text).join(''); return namespacePrefix; } export function completionCoreCompletion( parsingResult: ParsedStatement, dbSchema: DbSchema, manualTrigger = false, ): CompletionItem[] { const parser = parsingResult.parser; const tokens = parsingResult.tokens; const codeCompletion = new CodeCompletionCore(parser); // Move the caret index to the end of the query let caretIndex = tokens.length > 0 ? tokens.length - 1 : 0; const eof = tokens[caretIndex]; // When we have EOF with a different text in the token, it means the parser has failed to parse it. // We give empty completions in that case because the query is severely broken at the // point of completion (e.g. an unclosed string) if (eof.text !== '<EOF>') { return []; } // If the previous token is an identifier, we don't count it as "finished" so we move the caret back one token // The identifier is finished when the last token is a SPACE or dot etc. etc. // this allows us to give completions that replace the current text => for example `RET` <- it's parsed as an identifier // The need for this caret movement is outlined in the documentation of antlr4-c3 in the section about caret position // When an identifier overlaps with a keyword, it's no longer treats as an identifier (although it's a valid identifier) // So we need to move the caret back for keywords as well const previousToken = tokens[caretIndex - 1]?.type; if ( previousToken === CypherLexer.IDENTIFIER || lexerKeywords.includes(previousToken) ) { caretIndex--; } codeCompletion.preferredRules = new Set<number>([ CypherParser.RULE_functionName, CypherParser.RULE_procedureName, CypherParser.RULE_labelExpression1, CypherParser.RULE_symbolicAliasName, CypherParser.RULE_parameter, CypherParser.RULE_propertyKeyName, CypherParser.RULE_variable, CypherParser.RULE_leftArrow, // this rule is used for usernames and roles. CypherParser.RULE_commandNameExpression, // Either enable the helper rules for lexer clashes, // or collect all console commands like below with symbolicNameString ...(consoleCommandEnabled() ? [ CypherParser.RULE_useCompletionRule, CypherParser.RULE_listCompletionRule, ] : [CypherParser.RULE_consoleCommand]), // Because of the overlap of keywords and identifiers in cypher // We will suggest keywords when users type identifiers as well // To avoid this we want custom completion for identifiers // Until we've covered all the ways we can reach symbolic name string we'll keep this here // Ideally we'd find another way to get around this issue CypherParser.RULE_symbolicNameString, ]); // Keep only keywords as suggestions const ignoredTokens = new Set<number>( Object.entries(lexerSymbols) .filter( ([, type]) => type !== CypherTokenType.keyword && type !== CypherTokenType.consoleCommand, ) .map(([token]) => Number(token)), ); const candidates = codeCompletion.collectCandidates(caretIndex); const ruleCompletions = Array.from(candidates.rules.entries()).flatMap( (candidate): CompletionItem[] => { const [ruleNumber, candidateRule] = candidate; if (ruleNumber === CypherParser.RULE_functionName) { return functionNameCompletions(candidateRule, tokens, dbSchema); } if (ruleNumber === CypherParser.RULE_procedureName) { return procedureNameCompletions(candidateRule, tokens, dbSchema); } if (ruleNumber === CypherParser.RULE_parameter) { return parameterCompletions( dbSchema, inferExpectedParameterTypeFromContext(candidateRule), ); } if (ruleNumber === CypherParser.RULE_propertyKeyName) { // Map literal keys are also parsed as "propertyKey"s even though // they are not considered propertyKeys by the database // We check if the parent mapLiteral is used as a literal // to avoid suggesting property keys when defining a map literal const parentRule = candidateRule.ruleList.at(-1); const grandParentRule = candidateRule.ruleList.at(-2); if ( parentRule === CypherParser.RULE_map && grandParentRule === CypherParser.RULE_literal ) { return []; } const greatGrandParentRule = candidateRule.ruleList.at(-3); // When propertyKey is used as postfix to an expr there are many false positives // because expression are very flexible. For this case we only suggest property // keys if the expr is a simple variable that is defined. // We still don't know the type of the variable we're completing without a symbol table // but it is likely to be a node/relationship if ( parentRule === CypherParser.RULE_property && grandParentRule == CypherParser.RULE_postFix && greatGrandParentRule === CypherParser.RULE_expression2 ) { const expr2 = parsingResult.stopNode?.parentCtx?.parentCtx?.parentCtx; if (expr2 instanceof Expression2Context) { const variableName = expr2.expression1().variable()?.getText(); if ( !variableName || !parsingResult.collectedVariables.includes(variableName) ) { return []; } } } return propertyKeyCompletions(dbSchema); } if (ruleNumber === CypherParser.RULE_variable) { const parentRule = candidateRule.ruleList.at(-1); // some rules only define, never use variables if ( typeof parentRule === 'number' && rulesDefiningVariables.includes(parentRule) ) { return []; } return parsingResult.collectedVariables.map((variableNames) => ({ label: variableNames, kind: CompletionItemKind.Variable, })); } if (ruleNumber === CypherParser.RULE_symbolicAliasName) { return completeAliasName({ candidateRule, dbSchema, parsingResult }); } if (ruleNumber === CypherParser.RULE_commandNameExpression) { return completeSymbolicName({ candidateRule, dbSchema, parsingResult }); } if (ruleNumber === CypherParser.RULE_labelExpression1) { const topExprIndex = candidateRule.ruleList.indexOf( CypherParser.RULE_labelExpression, ); const topExprParent = candidateRule.ruleList[topExprIndex - 1]; if (topExprParent === undefined) { return []; } if (topExprParent === CypherParser.RULE_nodePattern) { return labelCompletions(dbSchema); } if (topExprParent === CypherParser.RULE_relationshipPattern) { return reltypeCompletions(dbSchema); } return [...labelCompletions(dbSchema), ...reltypeCompletions(dbSchema)]; } // These are simple tokens that get completed as the wrong kind, due to a lexer conflict if (ruleNumber === CypherParser.RULE_useCompletionRule) { return [{ label: 'use', kind: CompletionItemKind.Event }]; } if (ruleNumber === CypherParser.RULE_listCompletionRule) { return [{ label: 'list', kind: CompletionItemKind.Event }]; } if (ruleNumber === CypherParser.RULE_leftArrow) { return [ { label: '-[]->()', kind: CompletionItemKind.Snippet, insertTextFormat: InsertTextFormat.Snippet, insertText: '-[${1: }]->(${2: })', detail: 'path template', // vscode does not call the completion provider for every single character // after the second character is typed (i.e) `MATCH ()-[` the completion is no longer valid // it'd insert `MATCH ()-[[]->()` which is not valid. Hence we filter it out by using the filterText filterText: '', }, { label: '-[]-()', kind: CompletionItemKind.Snippet, insertTextFormat: InsertTextFormat.Snippet, insertText: '-[${1: }]-(${2: })', detail: 'path template', filterText: '', }, { label: '<-[]-()', kind: CompletionItemKind.Snippet, insertTextFormat: InsertTextFormat.Snippet, insertText: '<-[${1: }]-(${2: })', detail: 'path template', filterText: '', }, ]; } return []; }, ); // if the completion was automatically triggered by a snippet trigger character // we should only return snippet completions if (CypherLexer.RPAREN === previousToken && !manualTrigger) { return ruleCompletions.filter( (completion) => completion.kind === CompletionItemKind.Snippet, ); } return [ ...ruleCompletions, ...getTokenCompletions(candidates, ignoredTokens), ]; } type CompletionHelperArgs = { parsingResult: ParsedStatement; dbSchema: DbSchema; candidateRule: CandidateRule; }; function completeAliasName({ candidateRule, dbSchema, parsingResult, }: CompletionHelperArgs): CompletionItem[] { // The rule for RULE_symbolicAliasName technically allows for spaces given that a dot is included in the name // so ALTER ALIAS a . b FOR DATABASE neo4j is accepted by neo4j. It does however only drop the spaces for the alias // it becomes just a.b // The issue for us is that when we complete "ALTER ALIAS a " <- according to the grammar points say we could still be building a name // To handle this we check if the token after the first identifier in the rule is a space (as opposed to a dot) // if so we have a false positive and we return null to ignore the rule // symbolicAliasName: (symbolicNameString (DOT symbolicNameString)* | parameter); if ( parsingResult.tokens[candidateRule.startTokenIndex + 1]?.type === CypherLexer.SPACE ) { return []; } // parameters are valid values in all cases of symbolicAliasName const baseSuggestions = parameterCompletions( dbSchema, ExpectedParameterType.String, ); const rulesCreatingNewAliasOrDb = [ CypherParser.RULE_createAlias, CypherParser.RULE_createDatabase, CypherParser.RULE_createCompositeDatabase, ]; // avoid suggesting existing database names when creating a new alias or database if ( rulesCreatingNewAliasOrDb.some((rule) => candidateRule.ruleList.includes(rule), ) ) { return baseSuggestions; } const rulesThatOnlyAcceptAlias = [ CypherParser.RULE_dropAlias, CypherParser.RULE_alterAlias, CypherParser.RULE_showAliases, ]; if ( rulesThatOnlyAcceptAlias.some((rule) => candidateRule.ruleList.includes(rule), ) ) { return [ ...baseSuggestions, ...(dbSchema?.aliasNames ?? []).map((aliasName) => ({ label: aliasName, kind: CompletionItemKind.Value, })), ]; } // Suggest both database and alias names when it's not alias specific or creating new alias or database return [ ...baseSuggestions, ...(dbSchema.databaseNames ?? []) .concat(dbSchema.aliasNames ?? []) .map((databaseName) => ({ label: databaseName, kind: CompletionItemKind.Value, })), ]; } function completeSymbolicName({ candidateRule, dbSchema, parsingResult, }: CompletionHelperArgs): CompletionItem[] { // parameters are valid values in all cases of symbolic name const baseSuggestions = parameterCompletions( dbSchema, inferExpectedParameterTypeFromContext(candidateRule), ); const rulesCreatingNewUserOrRole = [ CypherParser.RULE_createUser, CypherParser.RULE_createRole, ]; const previousToken = findPreviousNonSpace( parsingResult.tokens, candidateRule.startTokenIndex, ); const afterToToken = previousToken.type === CypherParser.TO; const ruleList = candidateRule.ruleList; // avoid suggesting existing user names or role names when creating a new one if ( rulesCreatingNewUserOrRole.some((rule) => ruleList.includes(rule)) || // We are suggesting an user as target for the renaming // RENAME USER existing TO target // so target should be non-existent (ruleList.includes(CypherParser.RULE_renameUser) && afterToToken) ) { return baseSuggestions; } const rulesThatAcceptExistingUsers = [ CypherParser.RULE_dropUser, CypherParser.RULE_renameUser, CypherParser.RULE_alterUser, CypherParser.RULE_userNames, ]; if (rulesThatAcceptExistingUsers.some((rule) => ruleList.includes(rule))) { const result = [ ...baseSuggestions, ...(dbSchema?.userNames ?? []).map((userName) => ({ label: userName, kind: CompletionItemKind.Value, })), ]; return result; } const rulesThatAcceptExistingRoles = [ CypherParser.RULE_roleNames, CypherParser.RULE_dropRole, CypherParser.RULE_renameRole, ]; if (rulesThatAcceptExistingRoles.some((rule) => ruleList.includes(rule))) { return [ ...baseSuggestions, ...(dbSchema?.roleNames ?? []).map((roleName) => ({ label: roleName, kind: CompletionItemKind.Value, })), ]; } return []; }