From 8cc77c60ae597c13f0b83c6efbb714c46872f4b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Damkj=C3=A6r?= Date: Wed, 7 Feb 2024 11:10:18 +0100 Subject: [PATCH] Console command support (:use, :params, :clear, history) (#165) * create gramamr for client commands * some basic commands * consider * add test file * mellan * rename * add unit tests * cleanuptodos * todos * fix syntax highlighting for console commands * add tests * fix completions * add errors when cmd disabled * semantic analysis handles multiple queries * comment * self review * unused variable * fix unit tests * self review * rename parser * rename rules * rephrase comment * fix crash and add tests * remove todo * mellan * properly merge main * improve error messages for console commands * add changeset * fix bad merge --- .changeset/hungry-beans-eat.md | 7 + packages/language-server/src/linting.ts | 10 +- packages/language-support/package.json | 2 +- .../src/antlr-grammar/CypherCmdLexer.g4 | 9 + .../src/antlr-grammar/CypherCmdParser.g4 | 28 ++ .../completionCoreCompletions.ts | 81 ++- packages/language-support/src/helpers.ts | 8 +- .../syntaxColouring/syntaxColouring.ts | 45 +- .../syntaxColouring/syntaxColouringHelpers.ts | 2 +- .../syntaxValidation/completionCoreErrors.ts | 43 +- .../syntaxValidation/syntaxValidation.ts | 157 +++--- .../syntaxValidationHelpers.ts | 2 +- packages/language-support/src/index.ts | 11 +- packages/language-support/src/lexerSymbols.ts | 10 +- .../language-support/src/parserWrapper.ts | 185 ++++++- .../language-support/src/signatureHelp.ts | 6 +- .../src/tests/consoleCommands.test.ts | 466 ++++++++++++++++++ .../semanticValidation.test.ts | 222 ++++++++- .../syntacticValidation.test.ts | 29 ++ .../language-support/src/tests/lexer.test.ts | 4 +- packages/react-codemirror/src/icons.ts | 3 + .../src/lang-cypher/autocomplete.ts | 3 +- .../src/lang-cypher/constants.ts | 14 +- .../src/lang-cypher/create-cypher-theme.ts | 4 + .../src/lang-cypher/lang-cypher.ts | 6 +- .../src/lang-cypher/syntax-validation.ts | 20 +- packages/react-codemirror/src/themes.ts | 2 + 27 files changed, 1232 insertions(+), 147 deletions(-) create mode 100644 .changeset/hungry-beans-eat.md create mode 100644 packages/language-support/src/antlr-grammar/CypherCmdLexer.g4 create mode 100644 packages/language-support/src/antlr-grammar/CypherCmdParser.g4 create mode 100644 packages/language-support/src/tests/consoleCommands.test.ts diff --git a/.changeset/hungry-beans-eat.md b/.changeset/hungry-beans-eat.md new file mode 100644 index 00000000..55588132 --- /dev/null +++ b/.changeset/hungry-beans-eat.md @@ -0,0 +1,7 @@ +--- +'@neo4j-cypher/language-support': patch +'@neo4j-cypher/react-codemirror': patch +'@neo4j-cypher/language-server': patch +--- + +Add support for console commands diff --git a/packages/language-server/src/linting.ts b/packages/language-server/src/linting.ts index 88982411..3691cf64 100644 --- a/packages/language-server/src/linting.ts +++ b/packages/language-server/src/linting.ts @@ -1,8 +1,4 @@ -import { - findEndPosition, - parserWrapper, - validateSyntax, -} from '@neo4j-cypher/language-support'; +import { validateSyntax } from '@neo4j-cypher/language-support'; import debounce from 'lodash.debounce'; import { join } from 'path'; import { Diagnostic, TextDocumentChangeEvent } from 'vscode-languageserver'; @@ -42,9 +38,7 @@ async function rawLintDocument( lastSemanticJob = proxyWorker.validateSemantics(query); const result = await lastSemanticJob; - sendDiagnostics( - result.map((el) => findEndPosition(el, parserWrapper.parsingResult)), - ); + sendDiagnostics(result); } catch (err) { if (!(err instanceof workerpool.Promise.CancellationError)) { console.error(err); diff --git a/packages/language-support/package.json b/packages/language-support/package.json index f1220afa..8eababb9 100644 --- a/packages/language-support/package.json +++ b/packages/language-support/package.json @@ -47,7 +47,7 @@ "vscode-languageserver-types": "^3.17.3" }, "scripts": { - "gen-parser": "antlr4 -Dlanguage=TypeScript -visitor src/antlr-grammar/CypherParser.g4 src/antlr-grammar/CypherLexer.g4 -o src/generated-parser/ -Xexact-output-dir", + "gen-parser": "antlr4 -Dlanguage=TypeScript -visitor src/antlr-grammar/CypherCmdLexer.g4 src/antlr-grammar/CypherCmdParser.g4 -o src/generated-parser/ -Xexact-output-dir", "build": "npm run gen-parser && concurrently 'npm:build-types' 'npm:build-esm' 'npm:build-commonjs'", "build-types": "tsc --emitDeclarationOnly --outDir dist/types", "build-esm": "esbuild ./src/index.ts --bundle --format=esm --sourcemap --outfile=dist/esm/index.mjs", diff --git a/packages/language-support/src/antlr-grammar/CypherCmdLexer.g4 b/packages/language-support/src/antlr-grammar/CypherCmdLexer.g4 new file mode 100644 index 00000000..11f9af11 --- /dev/null +++ b/packages/language-support/src/antlr-grammar/CypherCmdLexer.g4 @@ -0,0 +1,9 @@ +lexer grammar CypherCmdLexer; + +import CypherLexer; + +PARAM : P A R A M S?; + +CLEAR: C L E A R; + +HISTORY: H I S T O R Y; diff --git a/packages/language-support/src/antlr-grammar/CypherCmdParser.g4 b/packages/language-support/src/antlr-grammar/CypherCmdParser.g4 new file mode 100644 index 00000000..154c31cb --- /dev/null +++ b/packages/language-support/src/antlr-grammar/CypherCmdParser.g4 @@ -0,0 +1,28 @@ +parser grammar CypherCmdParser; + +import CypherParser; + +options { tokenVocab = CypherCmdLexer; } + +statementsOrCommands: statementOrCommand (SEMICOLON statementOrCommand)* SEMICOLON? EOF; + +statementOrCommand: (statement | consoleCommand); + +consoleCommand: COLON (clearCmd | historyCmd | useCmd | paramsCmd); + +paramsCmd: PARAM paramsArgs?; + +paramsArgs: (CLEAR | listCompletionRule | map | lambda); + +lambda: unescapedSymbolicNameString EQ GT expression; + +clearCmd: CLEAR; + +historyCmd: HISTORY; + +useCmd: useCompletionRule symbolicAliasName?; + +// These rules are needed to distinguish cypher <-> commands, for exapmle `USE` and `:use` in autocompletion +listCompletionRule: LIST; + +useCompletionRule: USE; diff --git a/packages/language-support/src/autocompletion/completionCoreCompletions.ts b/packages/language-support/src/autocompletion/completionCoreCompletions.ts index 853fb45f..18919099 100644 --- a/packages/language-support/src/autocompletion/completionCoreCompletions.ts +++ b/packages/language-support/src/autocompletion/completionCoreCompletions.ts @@ -6,10 +6,10 @@ import { CompletionItemKind, } from 'vscode-languageserver-types'; import { DbSchema } from '../dbSchema'; -import CypherLexer from '../generated-parser/CypherLexer'; +import CypherLexer from '../generated-parser/CypherCmdLexer'; import CypherParser, { Expression2Context, -} from '../generated-parser/CypherParser'; +} from '../generated-parser/CypherCmdParser'; import { rulesDefiningVariables } from '../helpers'; import { CypherTokenType, @@ -17,7 +17,11 @@ import { lexerSymbols, tokenNames, } from '../lexerSymbols'; -import { EnrichedParsingResult, ParsingResult } from '../parserWrapper'; +import { + consoleCommandEnabled, + EnrichedParsingResult, + ParsingResult, +} from '../parserWrapper'; const uniq = (arr: T[]) => Array.from(new Set(arr)); @@ -125,17 +129,27 @@ const namespacedCompletion = ( } }; -function getTokenCandidates( +function getTokenCompletions( candidates: CandidatesCollection, ignoredTokens: Set, -) { +): CompletionItem[] { const tokenEntries = candidates.tokens.entries(); - const tokenCandidates = Array.from(tokenEntries).flatMap((value) => { + const completions = Array.from(tokenEntries).flatMap((value) => { const [tokenNumber, followUpList] = value; if (!ignoredTokens.has(tokenNumber)) { - const firstToken = tokenNames[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), @@ -151,21 +165,28 @@ function getTokenCandidates( if (firstToken === undefined) { return []; } else if (followUpString === '') { - return [firstToken]; + return [{ label: firstToken, kind }]; } else { - const followUp = firstToken + ' ' + followUpString; + const followUp = + firstToken + + ' ' + + (isConsoleCommand ? followUpString.toLowerCase() : followUpString); + if (followUpList.optional) { - return [firstToken, followUp]; + return [ + { label: firstToken, kind }, + { label: followUp, kind }, + ]; } - return [followUp]; + return [{ label: followUp, kind }]; } } else { return []; } }); - return tokenCandidates; + return completions; } const parameterCompletions = ( @@ -306,6 +327,15 @@ export function completionCoreCompletion( CypherParser.RULE_propertyKeyName, CypherParser.RULE_variable, + // 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 @@ -317,7 +347,11 @@ export function completionCoreCompletion( // Keep only keywords as suggestions const ignoredTokens = new Set( Object.entries(lexerSymbols) - .filter(([, type]) => type !== CypherTokenType.keyword) + .filter( + ([, type]) => + type !== CypherTokenType.keyword && + type !== CypherTokenType.consoleCommand, + ) .map(([token]) => Number(token)), ); @@ -427,17 +461,24 @@ export function completionCoreCompletion( ]; } } + + // 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 }]; + } + return []; }, ); - const tokenCandidates = getTokenCandidates(candidates, ignoredTokens); - const tokenCompletions: CompletionItem[] = tokenCandidates.map((t) => ({ - label: t, - kind: CompletionItemKind.Keyword, - })); - - return [...ruleCompletions, ...tokenCompletions]; + return [ + ...ruleCompletions, + ...getTokenCompletions(candidates, ignoredTokens), + ]; } type CompletionHelperArgs = { diff --git a/packages/language-support/src/helpers.ts b/packages/language-support/src/helpers.ts index cf7b7deb..27d93b36 100644 --- a/packages/language-support/src/helpers.ts +++ b/packages/language-support/src/helpers.ts @@ -6,12 +6,12 @@ import antlrDefaultExport, { ParseTree, Token, } from 'antlr4'; -import CypherLexer from './generated-parser/CypherLexer'; +import CypherLexer from './generated-parser/CypherCmdLexer'; import CypherParser, { NodePatternContext, RelationshipPatternContext, - StatementsContext, -} from './generated-parser/CypherParser'; + StatementsOrCommandsContext, +} from './generated-parser/CypherCmdParser'; import { ParsingResult } from './parserWrapper'; /* In antlr we have @@ -31,7 +31,7 @@ export type EnrichedParseTree = ParseTree & { parentCtx: ParserRuleContext | undefined; }; -export function findStopNode(root: StatementsContext) { +export function findStopNode(root: StatementsOrCommandsContext) { let children = root.children; let current: ParserRuleContext = root; diff --git a/packages/language-support/src/highlighting/syntaxColouring/syntaxColouring.ts b/packages/language-support/src/highlighting/syntaxColouring/syntaxColouring.ts index bae99863..3619a707 100644 --- a/packages/language-support/src/highlighting/syntaxColouring/syntaxColouring.ts +++ b/packages/language-support/src/highlighting/syntaxColouring/syntaxColouring.ts @@ -5,6 +5,7 @@ import { AnyExpressionContext, ArrowLineContext, BooleanLiteralContext, + ConsoleCommandContext, FunctionNameContext, KeywordLiteralContext, LabelNameContext, @@ -14,6 +15,7 @@ import { NoneExpressionContext, NumberLiteralContext, ParameterContext, + ParamsArgsContext, ProcedureNameContext, ProcedureResultItemContext, PropertyKeyNameContext, @@ -23,14 +25,15 @@ import { StringsLiteralContext, StringTokenContext, SymbolicNameStringContext, + UseCompletionRuleContext, VariableContext, -} from '../../generated-parser/CypherParser'; +} from '../../generated-parser/CypherCmdParser'; import { SemanticTokensLegend, SemanticTokenTypes, } from 'vscode-languageserver-types'; -import CypherParserListener from '../../generated-parser/CypherParserListener'; +import CypherParserListener from '../../generated-parser/CypherCmdParserListener'; import { CypherTokenType } from '../../lexerSymbols'; import { parserWrapper } from '../../parserWrapper'; import { @@ -74,6 +77,7 @@ export function mapCypherToSemanticTokenIndex( [CypherTokenType.label]: SemanticTokenTypes.type, [CypherTokenType.variable]: SemanticTokenTypes.variable, [CypherTokenType.symbolicName]: SemanticTokenTypes.variable, + [CypherTokenType.consoleCommand]: SemanticTokenTypes.macro, }; const semanticTokenType = tokenMappings[cypherTokenType]; @@ -229,6 +233,43 @@ class SyntaxHighlighter extends CypherParserListener { exitSymbolicNameString = (ctx: SymbolicNameStringContext) => { this.addToken(ctx.start, CypherTokenType.symbolicName, ctx.getText()); }; + + // Fix coloring of colon in console commands (operator -> consoleCommand) + exitConsoleCommand = (ctx: ConsoleCommandContext) => { + const colon = ctx.COLON(); + this.addToken( + colon.symbol, + CypherTokenType.consoleCommand, + colon.getText(), + ); + }; + + // console commands that clash with cypher keywords + exitUseCompletionRule = (ctx: UseCompletionRuleContext) => { + const use = ctx.USE(); + + this.addToken(use.symbol, CypherTokenType.consoleCommand, use.getText()); + }; + + exitParamsArgs = (ctx: ParamsArgsContext) => { + const clear = ctx.CLEAR(); + if (clear) { + this.addToken( + clear.symbol, + CypherTokenType.consoleCommand, + clear.getText(), + ); + } + + const list = ctx.listCompletionRule()?.LIST(); + if (list) { + this.addToken( + list.symbol, + CypherTokenType.consoleCommand, + list.getText(), + ); + } + }; } function colourLexerTokens(tokens: Token[]) { diff --git a/packages/language-support/src/highlighting/syntaxColouring/syntaxColouringHelpers.ts b/packages/language-support/src/highlighting/syntaxColouring/syntaxColouringHelpers.ts index 4e5ff350..9b43401b 100644 --- a/packages/language-support/src/highlighting/syntaxColouring/syntaxColouringHelpers.ts +++ b/packages/language-support/src/highlighting/syntaxColouring/syntaxColouringHelpers.ts @@ -2,7 +2,7 @@ import { SemanticTokenTypes } from 'vscode-languageserver-types'; import { Token } from 'antlr4'; -import CypherLexer from '../../generated-parser/CypherLexer'; +import CypherLexer from '../../generated-parser/CypherCmdLexer'; import { CypherTokenType, lexerSymbols } from '../../lexerSymbols'; diff --git a/packages/language-support/src/highlighting/syntaxValidation/completionCoreErrors.ts b/packages/language-support/src/highlighting/syntaxValidation/completionCoreErrors.ts index 298523ab..36200a29 100644 --- a/packages/language-support/src/highlighting/syntaxValidation/completionCoreErrors.ts +++ b/packages/language-support/src/highlighting/syntaxValidation/completionCoreErrors.ts @@ -2,9 +2,15 @@ import { Token } from 'antlr4'; import type { ParserRuleContext } from 'antlr4-c3'; import { CodeCompletionCore } from 'antlr4-c3'; import { distance } from 'fastest-levenshtein'; -import CypherLexer from '../../generated-parser/CypherLexer'; -import CypherParser from '../../generated-parser/CypherParser'; -import { keywordNames, tokenNames } from '../../lexerSymbols'; +import CypherLexer from '../../generated-parser/CypherCmdLexer'; +import CypherParser from '../../generated-parser/CypherCmdParser'; +import { + CypherTokenType, + keywordNames, + lexerSymbols, + tokenNames, +} from '../../lexerSymbols'; +import { consoleCommandEnabled } from '../../parserWrapper'; /* We ask for 0.7 similarity (number between 0 and 1) for @@ -28,7 +34,7 @@ export function completionCoreErrormessage( const codeCompletion = new CodeCompletionCore(parser); const caretIndex = currentToken.tokenIndex; - const rulesOfInterest: Record = { + const rulesOfInterest: Record = { [CypherParser.RULE_expression9]: 'an expression', [CypherParser.RULE_labelExpression2]: 'a node label / rel type', [CypherParser.RULE_labelExpression2Is]: 'a node label / rel type', @@ -39,6 +45,14 @@ export function completionCoreErrormessage( [CypherParser.RULE_parameter]: 'a parameter', [CypherParser.RULE_symbolicNameString]: 'an identifier', [CypherParser.RULE_symbolicAliasName]: 'a database name', + // Either enable the helper rules for lexer clashes, + // or collect all console commands like below with symbolicNameString + ...(consoleCommandEnabled() + ? { + [CypherParser.RULE_useCompletionRule]: 'use', + [CypherParser.RULE_listCompletionRule]: 'list', + } + : { [CypherParser.RULE_consoleCommand]: null }), }; codeCompletion.preferredRules = new Set( @@ -62,7 +76,18 @@ export function completionCoreErrormessage( const tokenEntries = candidates.tokens.entries(); const tokenCandidates = Array.from(tokenEntries).flatMap(([tokenNumber]) => { - const tokenName = tokenNames[tokenNumber]; + const isConsoleCommand = + lexerSymbols[tokenNumber] === CypherTokenType.consoleCommand; + + const tokenName = isConsoleCommand + ? tokenNames[tokenNumber].toLowerCase() + : tokenNames[tokenNumber]; + + // We don't want to suggest the ":" of console commands as it's not helpful even + // when console commands are available + if (caretIndex === 0 && tokenNumber === CypherLexer.COLON) { + return []; + } switch (tokenNumber) { case CypherLexer.DECIMAL_DOUBLE: @@ -102,6 +127,14 @@ export function completionCoreErrormessage( ]; if (options.length === 0) { + // options length is 0 should only happen when RULE_consoleCommand is hit and there are no other options + if ( + ruleCandidates.find( + (ruleNumber) => ruleNumber === CypherParser.RULE_consoleCommand, + ) + ) { + return 'Console commands are unsupported in this environment.'; + } return undefined; } diff --git a/packages/language-support/src/highlighting/syntaxValidation/syntaxValidation.ts b/packages/language-support/src/highlighting/syntaxValidation/syntaxValidation.ts index 82455f5e..600e35ca 100644 --- a/packages/language-support/src/highlighting/syntaxValidation/syntaxValidation.ts +++ b/packages/language-support/src/highlighting/syntaxValidation/syntaxValidation.ts @@ -6,6 +6,7 @@ import { EnrichedParsingResult, LabelOrRelType, LabelType, + ParsedCypherCmd, parserWrapper, } from '../../parserWrapper'; import { @@ -82,60 +83,76 @@ function warnOnUndeclaredLabels( return warnings; } -export function findEndPosition( - e: SemanticAnalysisElement, - parsingResult: EnrichedParsingResult, -): SyntaxDiagnostic { - let token: Token | undefined = undefined; - - const start = Position.create(e.position.line - 1, e.position.column - 1); - const startOffset = e.position.offset; - - const line = start.line + 1; - const column = start.character; - const toExplore: ParseTree[] = [parsingResult.result]; - - while (toExplore.length > 0 && !token) { - const current: ParseTree = toExplore.pop(); - - if (current instanceof ParserRuleContext) { - const startToken = current.start; - if (startToken.line === line && startToken.column === column) { - token = current.stop; - } - if (current.children) { - current.children.forEach((child) => toExplore.push(child)); +type FixSemanticPositionsArgs = { + semanticElements: SemanticAnalysisElement[]; + cmd: ParsedCypherCmd; + parseResult: EnrichedParsingResult; +}; + +function fixSemanticAnalysisPositions({ + semanticElements, + cmd, + parseResult, +}: FixSemanticPositionsArgs): SyntaxDiagnostic[] { + return semanticElements.map((e) => { + let token: Token | undefined = undefined; + + const start = Position.create( + e.position.line - 1 + cmd.start.line - 1, + e.position.column - 1 + (e.position.line === 1 ? cmd.start.column : 0), + ); + + const startOffset = e.position.offset + cmd.start.start; + + const line = start.line + 1; + const column = start.character; + const toExplore: ParseTree[] = [parseResult.result]; + + while (toExplore.length > 0 && !token) { + const current: ParseTree = toExplore.pop(); + + if (current instanceof ParserRuleContext) { + const startToken = current.start; + if (startToken.line === line && startToken.column === column) { + token = current.stop; + } + if (current.children) { + current.children.forEach((child) => toExplore.push(child)); + } } } - } - if (token === undefined) { - return { - severity: e.severity, - message: e.message, - range: { - start: start, - end: start, - }, - offsets: { - start: startOffset, - end: startOffset, - }, - }; - } else { - return { - severity: e.severity, - message: e.message, - range: { - start: start, - end: Position.create(token.line - 1, token.column + token.text.length), - }, - offsets: { - start: startOffset, - end: token.stop + 1, - }, - }; - } + if (token === undefined) { + return { + severity: e.severity, + message: e.message, + range: { + start: start, + end: start, + }, + offsets: { + start: startOffset, + end: startOffset, + }, + }; + } else { + return { + severity: e.severity, + message: e.message, + range: { + start: start, + end: Position.create( + token.line - 1, + token.column + token.text.length, + ), + }, + offsets: { + start: startOffset, + end: token.stop + 1, + }, + }; + } + }); } export function sortByPosition(a: SyntaxDiagnostic, b: SyntaxDiagnostic) { @@ -154,11 +171,8 @@ export function lintCypherQuery( if (syntaxErrors.length > 0) { return syntaxErrors; } - const cachedParse = parserWrapper.parse(wholeFileText); - return validateSemantics(wholeFileText) - .map((el) => findEndPosition(el, cachedParse)) - .sort(sortByPosition); + return validateSemantics(wholeFileText); } export function validateSyntax( @@ -176,15 +190,30 @@ export function validateSyntax( return []; } -/** - * Assumes the provided query has no parse errors - */ -export function validateSemantics(query: string): SemanticAnalysisElement[] { - if (query.length > 0) { - const { notifications, errors } = wrappedSemanticAnalysis(query); - - return notifications.concat(errors); +export function validateSemantics(wholeFileText: string): SyntaxDiagnostic[] { + const reparse = parserWrapper.parse(wholeFileText); + if (reparse.diagnostics.length > 0) { + return []; } - return []; + /* + Semantic analysis can only handle one cypher statement at a time and naturally only supports cypher. + We work around these limitations by breaking the file into statements, then run semantic analysis + on each individual cypher statement and map the positions back to the original query. + */ + return reparse.collectedCommands + .flatMap((cmd) => { + if (cmd.type === 'cypher' && cmd.query.length > 0) { + const { notifications, errors } = wrappedSemanticAnalysis(cmd.query); + + return fixSemanticAnalysisPositions({ + cmd, + semanticElements: notifications.concat(errors), + parseResult: reparse, + }); + } + + return []; + }) + .sort(sortByPosition); } diff --git a/packages/language-support/src/highlighting/syntaxValidation/syntaxValidationHelpers.ts b/packages/language-support/src/highlighting/syntaxValidation/syntaxValidationHelpers.ts index e5794f30..a531323e 100644 --- a/packages/language-support/src/highlighting/syntaxValidation/syntaxValidationHelpers.ts +++ b/packages/language-support/src/highlighting/syntaxValidation/syntaxValidationHelpers.ts @@ -10,7 +10,7 @@ import { DiagnosticSeverity, Position, } from 'vscode-languageserver-types'; -import CypherParser from '../../generated-parser/CypherParser'; +import CypherParser from '../../generated-parser/CypherCmdParser'; import { completionCoreErrormessage } from './completionCoreErrors'; export type SyntaxDiagnostic = Diagnostic & { diff --git a/packages/language-support/src/index.ts b/packages/language-support/src/index.ts index 293bbcf5..955d3749 100644 --- a/packages/language-support/src/index.ts +++ b/packages/language-support/src/index.ts @@ -9,17 +9,20 @@ export { } from './highlighting/syntaxColouring/syntaxColouring'; export type { ParsedCypherToken } from './highlighting/syntaxColouring/syntaxColouringHelpers'; export { - findEndPosition, lintCypherQuery, validateSemantics, validateSyntax, } from './highlighting/syntaxValidation/syntaxValidation'; export type { SyntaxDiagnostic } from './highlighting/syntaxValidation/syntaxValidationHelpers'; export { CypherTokenType, lexerSymbols } from './lexerSymbols'; -export { parse, parserWrapper } from './parserWrapper'; +export { + parse, + parserWrapper, + setConsoleCommandsEnabled, +} from './parserWrapper'; export { signatureHelp } from './signatureHelp'; export { testData } from './tests/testData'; export { CypherLexer, CypherParser }; -import CypherLexer from './generated-parser/CypherLexer'; -import CypherParser from './generated-parser/CypherParser'; +import CypherLexer from './generated-parser/CypherCmdLexer'; +import CypherParser from './generated-parser/CypherCmdParser'; diff --git a/packages/language-support/src/lexerSymbols.ts b/packages/language-support/src/lexerSymbols.ts index b39d9896..51693b5d 100644 --- a/packages/language-support/src/lexerSymbols.ts +++ b/packages/language-support/src/lexerSymbols.ts @@ -1,4 +1,4 @@ -import CypherLexer from './generated-parser/CypherLexer'; +import CypherLexer from './generated-parser/CypherCmdLexer'; export enum CypherTokenType { comment = 'comment', @@ -22,6 +22,7 @@ export enum CypherTokenType { separator = 'separator', punctuation = 'punctuation', none = 'none', + consoleCommand = 'consoleCommand', } export const lexerOperators = [ @@ -355,6 +356,12 @@ export const lexerKeywords = [ CypherLexer.PROFILE, ]; +export const lexerConsoleCmds = [ + CypherLexer.HISTORY, + CypherLexer.PARAM, + CypherLexer.CLEAR, +]; + function toTokentypeObject(arr: number[], tokenType: CypherTokenType) { return arr.reduce>( (acc, curr) => ({ ...acc, [curr]: tokenType }), @@ -373,6 +380,7 @@ export const lexerSymbols: Record = { ...toTokentypeObject(lexerSeparators, CypherTokenType.separator), ...toTokentypeObject(lexerStringLiteral, CypherTokenType.stringLiteral), ...toTokentypeObject(identifier, CypherTokenType.variable), + ...toTokentypeObject(lexerConsoleCmds, CypherTokenType.consoleCommand), }; export const hasIncorrectSymbolicName: Record = { diff --git a/packages/language-support/src/parserWrapper.ts b/packages/language-support/src/parserWrapper.ts index b5301aff..8747a51e 100644 --- a/packages/language-support/src/parserWrapper.ts +++ b/packages/language-support/src/parserWrapper.ts @@ -1,16 +1,17 @@ import type { ParserRuleContext, Token } from 'antlr4'; import { CharStreams, CommonTokenStream, ParseTreeListener } from 'antlr4'; -import CypherLexer from './generated-parser/CypherLexer'; +import CypherLexer from './generated-parser/CypherCmdLexer'; +import { DiagnosticSeverity, Position } from 'vscode-languageserver-types'; import CypherParser, { ClauseContext, LabelNameContext, LabelNameIsContext, LabelOrRelTypeContext, - StatementsContext, + StatementsOrCommandsContext, VariableContext, -} from './generated-parser/CypherParser'; +} from './generated-parser/CypherCmdParser'; import { findParent, findStopNode, @@ -29,7 +30,7 @@ export interface ParsingResult { query: string; parser: CypherParser; tokens: Token[]; - result: StatementsContext; + result: StatementsOrCommandsContext; } export enum LabelType { @@ -72,6 +73,7 @@ export interface EnrichedParsingResult extends ParsingResult { stopNode: ParserRuleContext; collectedLabelOrRelTypes: LabelOrRelType[]; collectedVariables: string[]; + collectedCommands: ParsedCommand[]; } export interface ParsingScaffolding { @@ -96,7 +98,7 @@ export function createParsingScaffolding(query: string): ParsingScaffolding { export function parse(cypher: string) { const parser = createParsingScaffolding(cypher).parser; - return parser.statements(); + return parser.statementsOrCommands(); } export function createParsingResult( @@ -105,7 +107,7 @@ export function createParsingResult( const query = parsingScaffolding.query; const parser = parsingScaffolding.parser; const tokenStream = parsingScaffolding.tokenStream; - const result = parser.statements(); + const result = parser.statementsOrCommands(); const parsingResult: ParsingResult = { query: query, @@ -214,6 +216,150 @@ class VariableCollector implements ParseTreeListener { } } +type CypherCmd = { type: 'cypher'; query: string }; +type RuleTokens = { + start: Token; + stop: Token; +}; + +export type ParsedCypherCmd = CypherCmd & RuleTokens; +export type ParsedCommandNoPosition = + | { type: 'cypher'; query: string } + | { type: 'use'; database?: string /* missing implies default db */ } + | { type: 'clear' } + | { type: 'history' } + | { + type: 'set-parameters'; + parameters: { name: string; expression: string }[]; + } + | { type: 'list-parameters' } + | { type: 'clear-parameters' } + | { type: 'parse-error' }; + +export type ParsedCommand = ParsedCommandNoPosition & RuleTokens; + +function parseToCommands(stmts: StatementsOrCommandsContext): ParsedCommand[] { + return stmts.statementOrCommand_list().map((stmt) => { + const { start, stop } = stmt; + + const cypherStmt = stmt.statement(); + if (cypherStmt) { + // we get the original text input to preserve whitespace + const inputstream = cypherStmt.start.getInputStream(); + const query = inputstream.getText(start.start, stop.stop); + + return { type: 'cypher', query, start, stop }; + } + + const consoleCmd = stmt.consoleCommand(); + if (consoleCmd) { + const useCmd = consoleCmd.useCmd(); + if (useCmd) { + return { + type: 'use', + database: useCmd.symbolicAliasName()?.getText(), + start, + stop, + }; + } + + const clearCmd = consoleCmd.clearCmd(); + if (clearCmd) { + return { type: 'clear', start, stop }; + } + + const historyCmd = consoleCmd.historyCmd(); + if (historyCmd) { + return { type: 'history', start, stop }; + } + + const param = consoleCmd.paramsCmd(); + const paramArgs = param?.paramsArgs(); + + if (param && !paramArgs) { + // no argument provided -> list parameters + return { type: 'list-parameters', start, stop }; + } + + if (paramArgs) { + const cypherMap = paramArgs.map(); + if (cypherMap) { + const names = cypherMap + ?.symbolicNameString_list() + .map((name) => name.getText()); + const expressions = cypherMap + ?.expression_list() + .map((expr) => expr.getText()); + + if (names && expressions && names.length === expressions.length) { + return { + type: 'set-parameters', + parameters: names.map((name, index) => ({ + name, + expression: expressions[index], + })), + start, + stop, + }; + } + } + + const lambda = paramArgs.lambda(); + const name = lambda?.unescapedSymbolicNameString()?.getText(); + const expression = lambda?.expression()?.getText(); + if (name && expression) { + return { + type: 'set-parameters', + parameters: [{ name, expression }], + start, + stop, + }; + } + + const clear = paramArgs.CLEAR(); + if (clear) { + return { type: 'clear-parameters', start, stop }; + } + + const list = paramArgs.listCompletionRule()?.LIST(); + if (list) { + return { type: 'list-parameters', start, stop }; + } + } + + return { type: 'parse-error', start, stop }; + } + return { type: 'parse-error', start, stop }; + }); +} + +function translateTokensToRange( + start: Token, + stop: Token, +): Pick { + return { + range: { + start: Position.create(start.line - 1, start.column), + end: Position.create(stop.line - 1, stop.column + stop.text.length), + }, + offsets: { + start: start.start, + end: stop.stop + 1, + }, + }; +} +function errorOnNonCypherCommands(commands: ParsedCommand[]) { + return commands + .filter((cmd) => cmd.type !== 'cypher' && cmd.type !== 'parse-error') + .map( + ({ start, stop }): SyntaxDiagnostic => ({ + message: 'Console commands are unsupported in this environment.', + severity: DiagnosticSeverity.Error, + ...translateTokensToRange(start, stop), + }), + ); +} + class ParserWrapper { parsingResult?: EnrichedParsingResult; @@ -236,15 +382,24 @@ class ParserWrapper { const result = createParsingResult(parsingScaffolding).result; + const diagnostics = errorListener.errors; + + const collectedCommands = parseToCommands(result); + + if (!consoleCommandEnabled()) { + diagnostics.push(...errorOnNonCypherCommands(collectedCommands)); + } + const parsingResult: EnrichedParsingResult = { query: query, parser: parser, tokens: getTokens(tokenStream), - diagnostics: errorListener.errors, + diagnostics, result: result, stopNode: findStopNode(result), collectedLabelOrRelTypes: labelsCollector.labelOrRelTypes, collectedVariables: variableFinder.variables, + collectedCommands, }; this.parsingResult = parsingResult; @@ -257,4 +412,20 @@ class ParserWrapper { } } +/* + Because the parserWrapper is done as a single-ton global variable, the setting for + console commands was also easiest to do as a global variable as it avoid messing with the cache + +It would make sense for the client to initialize and own the ParserWrapper, then each editor can have +it's own cache and preference on if console commands are enabled or not. + +*/ +let enableConsoleCommands = false; +export function setConsoleCommandsEnabled(enabled: boolean) { + enableConsoleCommands = enabled; +} +export function consoleCommandEnabled() { + return enableConsoleCommands; +} + export const parserWrapper = new ParserWrapper(); diff --git a/packages/language-support/src/signatureHelp.ts b/packages/language-support/src/signatureHelp.ts index fac0317c..64233984 100644 --- a/packages/language-support/src/signatureHelp.ts +++ b/packages/language-support/src/signatureHelp.ts @@ -8,11 +8,11 @@ import CypherParser, { CallClauseContext, ExpressionContext, FunctionInvocationContext, -} from './generated-parser/CypherParser'; +} from './generated-parser/CypherCmdParser'; import { Token } from 'antlr4-c3'; import { DbSchema } from './dbSchema'; -import CypherParserListener from './generated-parser/CypherParserListener'; +import CypherCmdParserListener from './generated-parser/CypherCmdParserListener'; import { isDefined } from './helpers'; import { parserWrapper } from './parserWrapper'; @@ -48,7 +48,7 @@ function toSignatureHelp( return signatureHelp; } -class SignatureHelper extends CypherParserListener { +class SignatureHelper extends CypherCmdParserListener { result: ParsedMethod; constructor(private tokens: Token[], private caretToken: Token) { super(); diff --git a/packages/language-support/src/tests/consoleCommands.test.ts b/packages/language-support/src/tests/consoleCommands.test.ts new file mode 100644 index 00000000..851afb9e --- /dev/null +++ b/packages/language-support/src/tests/consoleCommands.test.ts @@ -0,0 +1,466 @@ +import { autocomplete } from '../autocompletion/autocompletion'; +import { applySyntaxColouring } from '../highlighting/syntaxColouring/syntaxColouring'; +import { + ParsedCommandNoPosition, + parserWrapper, + setConsoleCommandsEnabled, +} from '../parserWrapper'; + +function expectParsedCommands( + query: string, + toEqual: ParsedCommandNoPosition[], +) { + const result = parserWrapper.parse(query); + expect(result.diagnostics).toEqual([]); + expect( + result.collectedCommands.map((cmd) => { + const copy = { ...cmd }; + // These data structures are recursive, so .toEqual doesn't work. + // We test the positions work properly in with the error position tests + delete copy.start; + delete copy.stop; + return copy; + }), + ).toEqual(toEqual); +} + +function expectErrorMessage(query: string, msg: string) { + const result = parserWrapper.parse(query); + expect(result.diagnostics.map((e) => e.message)).toContain(msg); +} + +describe('sanity checks', () => { + beforeAll(() => { + setConsoleCommandsEnabled(true); + }); + afterAll(() => { + setConsoleCommandsEnabled(false); + }); + + test('parses simple commands without args ', () => { + expectParsedCommands(':clear', [{ type: 'clear' }]); + expectParsedCommands(':history', [{ type: 'history' }]); + }); + + test('properly highlights simple commands', () => { + expect(applySyntaxColouring(':clear')).toEqual([ + { + length: 1, + position: { + line: 0, + startCharacter: 0, + startOffset: 0, + }, + token: ':', + tokenType: 'consoleCommand', + }, + { + length: 5, + position: { + line: 0, + startCharacter: 1, + startOffset: 1, + }, + token: 'clear', + tokenType: 'consoleCommand', + }, + ]); + expect(applySyntaxColouring(':history')).toEqual([ + { + length: 1, + position: { + line: 0, + startCharacter: 0, + startOffset: 0, + }, + token: ':', + tokenType: 'consoleCommand', + }, + { + length: 7, + position: { + line: 0, + startCharacter: 1, + startOffset: 1, + }, + token: 'history', + tokenType: 'consoleCommand', + }, + ]); + }); + + test('completes basic console cmds on :', () => { + expect(autocomplete(':', {})).toEqual([ + { kind: 23, label: 'use' }, + { kind: 23, label: 'param' }, + { kind: 23, label: 'history' }, + { kind: 23, label: 'clear' }, + ]); + }); + + test('accepts trailing ; ', () => { + expectParsedCommands(':history;', [{ type: 'history' }]); + }); + + test('parses multiple cmds', () => { + expectParsedCommands(':history;:history;:clear', [ + { type: 'history' }, + { type: 'history' }, + { type: 'clear' }, + ]); + }); + + test('accepts upper case', () => { + expectParsedCommands(':HISTORY;', [{ type: 'history' }]); + }); + + test('accepts mixed case', () => { + expectParsedCommands(':cLeaR;', [{ type: 'clear' }]); + }); + + test('handles misspelled or non-existing command', () => { + expectErrorMessage(':foo', 'Expected any of param, history, clear or use'); + + expectErrorMessage(':clea', 'Unexpected token. Did you mean clear?'); + }); +}); + +describe(':use', () => { + beforeAll(() => { + setConsoleCommandsEnabled(true); + }); + afterAll(() => { + setConsoleCommandsEnabled(false); + }); + test('parses without arg', () => { + expectParsedCommands(':use', [{ type: 'use' }]); + }); + test('parses with arg', () => { + expectParsedCommands(':use foo', [{ type: 'use', database: 'foo' }]); + }); + + test('completes database & alias names', () => { + expect( + autocomplete(':use ', { databaseNames: ['foo'], aliasNames: ['bar'] }), + ).toEqual([ + { kind: 12, label: 'foo' }, + { kind: 12, label: 'bar' }, + ]); + }); + + test('gives errors on incorrect usage of :use', () => { + expectErrorMessage(':use 123', "Expected ';' or a database name"); + expectErrorMessage(':use foo bar', "Expected ';' or a database name"); + }); + + test('highlights properly', () => { + expect(applySyntaxColouring(':use')).toEqual([ + { + length: 1, + position: { line: 0, startCharacter: 0, startOffset: 0 }, + token: ':', + tokenType: 'consoleCommand', + }, + { + length: 3, + position: { line: 0, startCharacter: 1, startOffset: 1 }, + token: 'use', + tokenType: 'consoleCommand', + }, + ]); + expect(applySyntaxColouring(':use foo')).toEqual([ + { + length: 1, + position: { line: 0, startCharacter: 0, startOffset: 0 }, + token: ':', + tokenType: 'consoleCommand', + }, + { + length: 3, + position: { line: 0, startCharacter: 1, startOffset: 1 }, + token: 'use', + tokenType: 'consoleCommand', + }, + { + length: 3, + position: { line: 0, startCharacter: 5, startOffset: 5 }, + token: 'foo', + tokenType: 'symbolicName', + }, + ]); + }); +}); + +describe('parameters', () => { + beforeAll(() => { + setConsoleCommandsEnabled(true); + }); + afterAll(() => { + setConsoleCommandsEnabled(false); + }); + test('basic param usage', () => { + expectParsedCommands(':param', [{ type: 'list-parameters' }]); + expectParsedCommands(':params ', [{ type: 'list-parameters' }]); + expectParsedCommands(':params list', [{ type: 'list-parameters' }]); + expectParsedCommands(':params clear', [{ type: 'clear-parameters' }]); + }); + + test('allows setting parameters', () => { + expectParsedCommands(':param foo => bar', [ + { + type: 'set-parameters', + parameters: [{ name: 'foo', expression: 'bar' }], + }, + ]); + + expectParsedCommands(':param {a: 2, b: rand()}', [ + { + type: 'set-parameters', + parameters: [ + { name: 'a', expression: '2' }, + { name: 'b', expression: 'rand()' }, + ], + }, + ]); + }); + + test('autocompletes expressions', () => { + const arrowCompletions = autocomplete(':param foo => ', { + functionSignatures: { + 'duration.inSeconds': { label: 'duration.inSeconds' }, + }, + }); + const mapCompletions = autocomplete(':param {a: ', { + functionSignatures: { + 'duration.inSeconds': { label: 'duration.inSeconds' }, + }, + }); + + const expected = [ + { detail: '(namespace)', kind: 3, label: 'duration' }, + { detail: '(function)', kind: 3, label: 'duration.inSeconds' }, + { kind: 14, label: 'TRUE' }, + { kind: 14, label: 'FALSE' }, + ]; + + expected.forEach((completion) => { + expect(arrowCompletions).toContainEqual(completion); + expect(mapCompletions).toContainEqual(completion); + }); + }); + + test('incorrect usage of :params', () => { + expectErrorMessage(':param x=21', "Expected '>'"); + expectErrorMessage(':param x=>', 'Expected an expression'); + expectErrorMessage( + ':param {a: 3', + "Expected any of '}', ',', AND, OR, XOR or an expression", + ); + expectErrorMessage(':param RETURN', "Expected '='"); + expectErrorMessage(':param RETURN b', "Expected '='"); + expectErrorMessage(':param b => ', 'Expected an expression'); + expectErrorMessage(':param {', "Expected '}' or an identifier"); + expectErrorMessage(':param {x}', "Expected ':'"); + expectErrorMessage(':param {x: ', 'Expected an expression'); + expectErrorMessage(':param {: 4} ', "Expected '}' or an identifier"); + }); + + test('highlights :params properly', () => { + expect(applySyntaxColouring(':param')).toEqual([ + { + length: 1, + position: { line: 0, startCharacter: 0, startOffset: 0 }, + token: ':', + tokenType: 'consoleCommand', + }, + { + length: 5, + position: { line: 0, startCharacter: 1, startOffset: 1 }, + token: 'param', + tokenType: 'consoleCommand', + }, + ]); + expect(applySyntaxColouring(':params')).toEqual([ + { + length: 1, + position: { line: 0, startCharacter: 0, startOffset: 0 }, + token: ':', + tokenType: 'consoleCommand', + }, + { + length: 6, + position: { line: 0, startCharacter: 1, startOffset: 1 }, + token: 'params', + tokenType: 'consoleCommand', + }, + ]); + expect(applySyntaxColouring(':params list')).toEqual([ + { + length: 1, + position: { line: 0, startCharacter: 0, startOffset: 0 }, + token: ':', + tokenType: 'consoleCommand', + }, + { + length: 6, + position: { line: 0, startCharacter: 1, startOffset: 1 }, + token: 'params', + tokenType: 'consoleCommand', + }, + { + length: 4, + position: { line: 0, startCharacter: 8, startOffset: 8 }, + token: 'list', + tokenType: 'consoleCommand', + }, + ]); + expect(applySyntaxColouring(':param clear')).toEqual([ + { + length: 1, + position: { line: 0, startCharacter: 0, startOffset: 0 }, + token: ':', + tokenType: 'consoleCommand', + }, + { + length: 5, + position: { line: 0, startCharacter: 1, startOffset: 1 }, + token: 'param', + tokenType: 'consoleCommand', + }, + { + length: 5, + position: { line: 0, startCharacter: 7, startOffset: 7 }, + token: 'clear', + tokenType: 'consoleCommand', + }, + ]); + expect(applySyntaxColouring(':param x => 324')).toEqual([ + { + length: 1, + position: { line: 0, startCharacter: 0, startOffset: 0 }, + token: ':', + tokenType: 'consoleCommand', + }, + { + length: 5, + position: { line: 0, startCharacter: 1, startOffset: 1 }, + token: 'param', + tokenType: 'consoleCommand', + }, + { + length: 1, + position: { line: 0, startCharacter: 7, startOffset: 7 }, + token: 'x', + tokenType: 'variable', + }, + { + length: 1, + position: { line: 0, startCharacter: 9, startOffset: 9 }, + token: '=', + tokenType: 'operator', + }, + { + length: 1, + position: { line: 0, startCharacter: 10, startOffset: 10 }, + token: '>', + tokenType: 'operator', + }, + { + length: 3, + position: { line: 0, startCharacter: 12, startOffset: 12 }, + token: '324', + tokenType: 'numberLiteral', + }, + ]); + expect(applySyntaxColouring(':params {d: true}')).toEqual([ + { + length: 1, + position: { line: 0, startCharacter: 0, startOffset: 0 }, + token: ':', + tokenType: 'consoleCommand', + }, + { + length: 6, + position: { line: 0, startCharacter: 1, startOffset: 1 }, + token: 'params', + tokenType: 'consoleCommand', + }, + { + bracketInfo: { bracketLevel: 0, bracketType: 'curly' }, + length: 1, + position: { line: 0, startCharacter: 8, startOffset: 8 }, + token: '{', + tokenType: 'bracket', + }, + { + length: 1, + position: { line: 0, startCharacter: 9, startOffset: 9 }, + token: 'd', + tokenType: 'symbolicName', + }, + { + length: 1, + position: { line: 0, startCharacter: 10, startOffset: 10 }, + token: ':', + tokenType: 'operator', + }, + { + length: 4, + position: { line: 0, startCharacter: 12, startOffset: 12 }, + token: 'true', + tokenType: 'booleanLiteral', + }, + { + bracketInfo: { bracketLevel: 0, bracketType: 'curly' }, + length: 1, + position: { line: 0, startCharacter: 16, startOffset: 16 }, + token: '}', + tokenType: 'bracket', + }, + ]); + }); +}); + +describe('command parser also handles cypher', () => { + beforeAll(() => { + setConsoleCommandsEnabled(true); + }); + afterAll(() => { + setConsoleCommandsEnabled(false); + }); + test('parses cypher', () => { + expectParsedCommands('MATCH (n) RETURN n', [ + { query: 'MATCH (n) RETURN n', type: 'cypher' }, + ]); + }); + + test('preserves original whitespace', () => { + expectParsedCommands('MATCH\n(n)\nRETURN n', [ + { query: 'MATCH\n(n)\nRETURN n', type: 'cypher' }, + ]); + }); + + test('can split cypher into statements', () => { + expectParsedCommands('CALL db.info(); RETURN 123; SHOW DATABASES', [ + { query: 'CALL db.info()', type: 'cypher' }, + { query: 'RETURN 123', type: 'cypher' }, + { query: 'SHOW DATABASES', type: 'cypher' }, + ]); + }); + + test('can weave cypher with cmds', () => { + expectParsedCommands( + ':use neo4j; :param x => 23;RETURN $x;:use system; SHOW DATABASES; ', + [ + { database: 'neo4j', type: 'use' }, + { + parameters: [{ name: 'x', expression: '23' }], + type: 'set-parameters', + }, + { query: 'RETURN $x', type: 'cypher' }, + { database: 'system', type: 'use' }, + { query: 'SHOW DATABASES', type: 'cypher' }, + ], + ); + }); +}); diff --git a/packages/language-support/src/tests/highlighting/syntaxValidation/semanticValidation.test.ts b/packages/language-support/src/tests/highlighting/syntaxValidation/semanticValidation.test.ts index 6ad94bbb..9756feff 100644 --- a/packages/language-support/src/tests/highlighting/syntaxValidation/semanticValidation.test.ts +++ b/packages/language-support/src/tests/highlighting/syntaxValidation/semanticValidation.test.ts @@ -1,3 +1,4 @@ +import { setConsoleCommandsEnabled } from '../../../parserWrapper'; import { getDiagnosticsForQuery } from './helpers'; describe('Semantic validation spec', () => { @@ -51,6 +52,53 @@ describe('Semantic validation spec', () => { ]); }); + test('Handles multiple statements in semantic analysis', () => { + const query = `MATCH (n) RETURN m; + + match (m) return + n + `; + + expect(getDiagnosticsForQuery({ query })).toEqual([ + { + message: 'Variable `m` not defined', + offsets: { + end: 18, + start: 17, + }, + range: { + end: { + character: 18, + line: 0, + }, + start: { + character: 17, + line: 0, + }, + }, + severity: 1, + }, + { + message: 'Variable `n` not defined', + offsets: { + end: 52, + start: 51, + }, + range: { + end: { + character: 5, + line: 3, + }, + start: { + character: 4, + line: 3, + }, + }, + severity: 1, + }, + ]); + }); + test('Surfaces notifications correctly', () => { const query = ` MATCH (shadowed) @@ -482,7 +530,7 @@ describe('Semantic validation spec', () => { expect(getDiagnosticsForQuery({ query })).toEqual([ { - message: 'A Collect Expression must end with a single return column.', + message: 'A Collect Expression cannot contain any updates', offsets: { end: 49, start: 23, @@ -500,7 +548,7 @@ describe('Semantic validation spec', () => { severity: 1, }, { - message: 'A Collect Expression cannot contain any updates', + message: 'A Collect Expression must end with a single return column.', offsets: { end: 49, start: 23, @@ -1229,4 +1277,174 @@ In this case, p is defined in the same \`MATCH\` clause as ((a)-[e]->(b {h: (nod }, ]); }); + + test('gives error on console commands when they are disabled', () => { + setConsoleCommandsEnabled(true); + expect( + getDiagnosticsForQuery({ query: 'RETURN a;:clear; RETURN b;:history;' }), + ).toEqual([ + { + message: 'Variable `a` not defined', + offsets: { + end: 8, + start: 7, + }, + range: { + end: { + character: 8, + line: 0, + }, + start: { + character: 7, + line: 0, + }, + }, + severity: 1, + }, + { + message: 'Variable `b` not defined', + offsets: { + end: 25, + start: 24, + }, + range: { + end: { + character: 25, + line: 0, + }, + start: { + character: 24, + line: 0, + }, + }, + severity: 1, + }, + ]); + setConsoleCommandsEnabled(false); + }); + + test('Handles multiple cypher statements in a single query', () => { + setConsoleCommandsEnabled(true); + expect(getDiagnosticsForQuery({ query: 'RETURN a; RETURN b;' })).toEqual([ + { + message: 'Variable `a` not defined', + offsets: { + end: 8, + start: 7, + }, + range: { + end: { + character: 8, + line: 0, + }, + start: { + character: 7, + line: 0, + }, + }, + severity: 1, + }, + { + message: 'Variable `b` not defined', + offsets: { + end: 18, + start: 17, + }, + range: { + end: { + character: 18, + line: 0, + }, + start: { + character: 17, + line: 0, + }, + }, + severity: 1, + }, + ]); + setConsoleCommandsEnabled(false); + }); + + test('Handles cypher mixed with client commands', () => { + setConsoleCommandsEnabled(true); + expect( + getDiagnosticsForQuery({ + query: ':clear;RETURN a;:clear; RETURN b;:history;', + }), + ).toEqual([ + { + message: 'Variable `a` not defined', + offsets: { + end: 15, + start: 14, + }, + range: { + end: { + character: 15, + line: 0, + }, + start: { + character: 14, + line: 0, + }, + }, + severity: 1, + }, + { + message: 'Variable `b` not defined', + offsets: { + end: 32, + start: 31, + }, + range: { + end: { + character: 32, + line: 0, + }, + start: { + character: 31, + line: 0, + }, + }, + severity: 1, + }, + ]); + setConsoleCommandsEnabled(false); + }); + + test('Handles cypher mixed with complex client command', () => { + setConsoleCommandsEnabled(true); + expect( + getDiagnosticsForQuery({ + query: ` + :param { + + d : 343 + + } + ;RETURN a;`, + }), + ).toEqual([ + { + message: 'Variable `a` not defined', + offsets: { + end: 57, + start: 56, + }, + range: { + end: { + character: 15, + line: 6, + }, + start: { + character: 14, + line: 6, + }, + }, + severity: 1, + }, + ]); + setConsoleCommandsEnabled(false); + }); }); diff --git a/packages/language-support/src/tests/highlighting/syntaxValidation/syntacticValidation.test.ts b/packages/language-support/src/tests/highlighting/syntaxValidation/syntacticValidation.test.ts index d05424cf..fece179a 100644 --- a/packages/language-support/src/tests/highlighting/syntaxValidation/syntacticValidation.test.ts +++ b/packages/language-support/src/tests/highlighting/syntaxValidation/syntacticValidation.test.ts @@ -947,4 +947,33 @@ describe('Syntactic validation spec', () => { }, ]); }); + + test('Syntax validation errors on incomplete console commands if console commands are not enabled', () => { + const query = `:`; + + expect( + getDiagnosticsForQuery({ + query, + }), + ).toEqual([ + { + message: 'Console commands are unsupported in this environment.', + offsets: { + end: 1, + start: 1, + }, + range: { + end: { + character: 1, + line: 0, + }, + start: { + character: 1, + line: 0, + }, + }, + severity: 1, + }, + ]); + }); }); diff --git a/packages/language-support/src/tests/lexer.test.ts b/packages/language-support/src/tests/lexer.test.ts index e64a3ac6..0b563d77 100644 --- a/packages/language-support/src/tests/lexer.test.ts +++ b/packages/language-support/src/tests/lexer.test.ts @@ -1,6 +1,6 @@ import { CharStreams, CommonTokenStream } from 'antlr4'; -import CypherLexer from '../generated-parser/CypherLexer'; -import CypherParser from '../generated-parser/CypherParser'; +import CypherLexer from '../generated-parser/CypherCmdLexer'; +import CypherParser from '../generated-parser/CypherCmdParser'; import { getTokens } from '../helpers'; import { CypherTokenType, lexerSymbols, tokenNames } from '../lexerSymbols'; diff --git a/packages/react-codemirror/src/icons.ts b/packages/react-codemirror/src/icons.ts index 2839b09a..ad4f7678 100644 --- a/packages/react-codemirror/src/icons.ts +++ b/packages/react-codemirror/src/icons.ts @@ -23,6 +23,7 @@ export type CompletionItemIcons = | 'Struct' | 'Event' | 'Operator' + | 'Console' | 'TypeParameter'; export function getIconForType(iconTypeString = 'Text', isDarkTheme = false) { @@ -51,6 +52,7 @@ export function getIconForType(iconTypeString = 'Text', isDarkTheme = false) { Struct: ` `, Event: ` `, Operator: ` `, + Console: ` `, TypeParameter: ` `, }; @@ -79,6 +81,7 @@ export function getIconForType(iconTypeString = 'Text', isDarkTheme = false) { Struct: ` `, Event: ` `, Operator: ` `, + Console: ` `, TypeParameter: ` `, }; const iconType = iconTypeString as CompletionItemIcons; diff --git a/packages/react-codemirror/src/lang-cypher/autocomplete.ts b/packages/react-codemirror/src/lang-cypher/autocomplete.ts index 85e1b142..bbe8e054 100644 --- a/packages/react-codemirror/src/lang-cypher/autocomplete.ts +++ b/packages/react-codemirror/src/lang-cypher/autocomplete.ts @@ -28,7 +28,8 @@ const completionKindToCodemirrorIcon = (c: CompletionItemKind) => { [CompletionItemKind.EnumMember]: 'EnumMember', [CompletionItemKind.Constant]: 'Constant', [CompletionItemKind.Struct]: 'Struct', - [CompletionItemKind.Event]: 'Event', + // we're missuing the enum here as there is no `Console` kind in the predefined list + [CompletionItemKind.Event]: 'Console', [CompletionItemKind.Operator]: 'Operator', [CompletionItemKind.TypeParameter]: 'TypeParameter', }; diff --git a/packages/react-codemirror/src/lang-cypher/constants.ts b/packages/react-codemirror/src/lang-cypher/constants.ts index d28a6c6f..c9d20bdf 100644 --- a/packages/react-codemirror/src/lang-cypher/constants.ts +++ b/packages/react-codemirror/src/lang-cypher/constants.ts @@ -31,14 +31,15 @@ export const cypherTokenTypeToNode = (facet: Facet) => ({ none: NodeType.define({ id: 19, name: 'none' }), separator: NodeType.define({ id: 20, name: 'separator' }), punctuation: NodeType.define({ id: 21, name: 'punctuation' }), + consoleCommand: NodeType.define({ id: 22, name: 'consoleCommand' }), // also include prism token types - 'class-name': NodeType.define({ id: 22, name: 'label' }), + 'class-name': NodeType.define({ id: 23, name: 'label' }), // this is escaped variables - identifier: NodeType.define({ id: 23, name: 'variable' }), - string: NodeType.define({ id: 24, name: 'stringLiteral' }), - relationship: NodeType.define({ id: 25, name: 'label' }), - boolean: NodeType.define({ id: 26, name: 'booleanLiteral' }), - number: NodeType.define({ id: 27, name: 'numberLiteral' }), + identifier: NodeType.define({ id: 24, name: 'variable' }), + string: NodeType.define({ id: 25, name: 'stringLiteral' }), + relationship: NodeType.define({ id: 26, name: 'label' }), + boolean: NodeType.define({ id: 27, name: 'booleanLiteral' }), + number: NodeType.define({ id: 28, name: 'numberLiteral' }), }); export type PrismSpecificTokenType = @@ -76,6 +77,7 @@ export const tokenTypeToStyleTag: Record = { bracket: tags.bracket, punctuation: tags.punctuation, separator: tags.separator, + consoleCommand: tags.macroName, }; export const parserAdapterNodeSet = (nodes: Record) => diff --git a/packages/react-codemirror/src/lang-cypher/create-cypher-theme.ts b/packages/react-codemirror/src/lang-cypher/create-cypher-theme.ts index b1d1c951..24a5ad93 100644 --- a/packages/react-codemirror/src/lang-cypher/create-cypher-theme.ts +++ b/packages/react-codemirror/src/lang-cypher/create-cypher-theme.ts @@ -80,6 +80,9 @@ export const createCypherTheme = ({ '& .cm-selectionMatch': { backgroundColor: settings.textMatchingSelection, }, + '& .cm-bold': { + fontWeight: 'bold', + }, '& .cm-panels': { backgroundColor: settings.searchPanel.background, fontFamily: 'Fira Code, Menlo, Monaco, Lucida Console, monospace', @@ -198,6 +201,7 @@ export const createCypherTheme = ({ ([token, color]: [HighlightedCypherTokenTypes, string]): TagStyle => ({ tag: tokenTypeToStyleTag[token], color, + class: token === 'consoleCommand' ? 'cm-bold' : undefined, }), ); const highlightStyle = HighlightStyle.define(styles); diff --git a/packages/react-codemirror/src/lang-cypher/lang-cypher.ts b/packages/react-codemirror/src/lang-cypher/lang-cypher.ts index a4fdfbda..031acd86 100644 --- a/packages/react-codemirror/src/lang-cypher/lang-cypher.ts +++ b/packages/react-codemirror/src/lang-cypher/lang-cypher.ts @@ -3,7 +3,10 @@ import { Language, LanguageSupport, } from '@codemirror/language'; -import type { DbSchema } from '@neo4j-cypher/language-support'; +import { + setConsoleCommandsEnabled, + type DbSchema, +} from '@neo4j-cypher/language-support'; import { cypherAutocomplete } from './autocomplete'; import { ParserAdapter } from './parser-adapter'; import { signatureHelpTooltip } from './signature-help'; @@ -22,6 +25,7 @@ export type CypherConfig = { }; export function cypher(config: CypherConfig) { + setConsoleCommandsEnabled(true); const parserAdapter = new ParserAdapter(facet, config); const cypherLanguage = new Language(facet, parserAdapter, [], 'cypher'); diff --git a/packages/react-codemirror/src/lang-cypher/syntax-validation.ts b/packages/react-codemirror/src/lang-cypher/syntax-validation.ts index d5797259..68cf510b 100644 --- a/packages/react-codemirror/src/lang-cypher/syntax-validation.ts +++ b/packages/react-codemirror/src/lang-cypher/syntax-validation.ts @@ -1,10 +1,6 @@ import { Diagnostic, linter } from '@codemirror/lint'; import { Extension } from '@codemirror/state'; -import { - findEndPosition, - parserWrapper, - validateSyntax, -} from '@neo4j-cypher/language-support'; +import { parserWrapper, validateSyntax } from '@neo4j-cypher/language-support'; import { DiagnosticSeverity } from 'vscode-languageserver-types'; import workerpool from 'workerpool'; import type { CypherConfig } from './lang-cypher'; @@ -72,17 +68,13 @@ export const semanticAnalysisLinter: (config: CypherConfig) => Extension = ( lastSemanticJob = proxyWorker.validateSemantics(query); const result = await lastSemanticJob; - return result.map((el) => { - const diagnostic = findEndPosition(el, parse); - + return result.map((diag) => { return { - from: diagnostic.offsets.start, - to: diagnostic.offsets.end, + from: diag.offsets.start, + to: diag.offsets.end, severity: - diagnostic.severity === DiagnosticSeverity.Error - ? 'error' - : 'warning', - message: diagnostic.message, + diag.severity === DiagnosticSeverity.Error ? 'error' : 'warning', + message: diag.message, }; }); } catch (err) { diff --git a/packages/react-codemirror/src/themes.ts b/packages/react-codemirror/src/themes.ts index f21bebec..d06c1114 100644 --- a/packages/react-codemirror/src/themes.ts +++ b/packages/react-codemirror/src/themes.ts @@ -60,6 +60,7 @@ export const lightThemeConstants: ThemeOptions = { paramDollar: light.syntax.regexp.hex(), paramValue: light.syntax.regexp.hex(), namespace: light.syntax.special.hex(), + consoleCommand: light.editor.fg.hex(), }, }; @@ -101,6 +102,7 @@ export const darkThemeConstants: ThemeOptions = { paramDollar: mirage.syntax.regexp.hex(), paramValue: mirage.syntax.regexp.hex(), namespace: mirage.syntax.special.hex(), + consoleCommand: mirage.editor.fg.hex(), }, };