diff --git a/flowquery-py/src/parsing/parser.py b/flowquery-py/src/parsing/parser.py index 0f0a79e..6b1a338 100644 --- a/flowquery-py/src/parsing/parser.py +++ b/flowquery-py/src/parsing/parser.py @@ -683,12 +683,7 @@ def _parse_operand(self, expression: Expression) -> bool: return True elif ( self.token.is_left_parenthesis() - and self.peek() is not None - and ( - self.peek().is_identifier_or_keyword() - or self.peek().is_colon() - or self.peek().is_right_parenthesis() - ) + and self._looks_like_node_pattern() ): # Possible graph pattern expression pattern = self._parse_pattern_expression() @@ -779,6 +774,34 @@ def _parse_expression(self) -> Optional[Expression]: return expression return None + def _looks_like_node_pattern(self) -> bool: + """Peek ahead from a left parenthesis to determine whether the + upcoming tokens form a graph-node pattern (e.g. (n:Label), (n), + (:Label), ()) rather than a parenthesised expression (e.g. + (variable.property), (a + b)). + """ + saved_index = self._token_index + self.set_next_token() # skip '(' + self._skip_whitespace_and_comments() + + if self.token.is_colon() or self.token.is_right_parenthesis(): + self._token_index = saved_index + return True + + if self.token.is_identifier_or_keyword(): + self.set_next_token() # skip identifier + self._skip_whitespace_and_comments() + result = ( + self.token.is_colon() + or self.token.is_opening_brace() + or self.token.is_right_parenthesis() + ) + self._token_index = saved_index + return result + + self._token_index = saved_index + return False + def _parse_is_operator(self) -> ASTNode: """Parse IS or IS NOT operator.""" # Current token is IS. Look ahead for NOT to produce IS NOT. diff --git a/flowquery-py/tests/parsing/test_parser.py b/flowquery-py/tests/parsing/test_parser.py index 118d7d1..f85a4b7 100644 --- a/flowquery-py/tests/parsing/test_parser.py +++ b/flowquery-py/tests/parsing/test_parser.py @@ -1032,3 +1032,61 @@ def test_where_with_not_ends_with(self): "--- Reference (s)" ) assert ast.print() == expected + + def test_parenthesized_expression_with_addition(self): + """Test that (variable + number) is parsed as a parenthesized expression, not a node.""" + parser = Parser() + ast = parser.parse("WITH 1 AS n RETURN (n + 2)") + expected = ( + "ASTNode\n" + "- With\n" + "-- Expression (n)\n" + "--- Number (1)\n" + "- Return\n" + "-- Expression\n" + "--- Expression\n" + "---- Add\n" + "----- Reference (n)\n" + "----- Number (2)" + ) + assert ast.print() == expected + + def test_parenthesized_expression_with_property_access(self): + """Test that (obj.property) is parsed as a parenthesized expression, not a node.""" + parser = Parser() + ast = parser.parse("WITH {a: 1} AS obj RETURN (obj.a)") + expected = ( + "ASTNode\n" + "- With\n" + "-- Expression (obj)\n" + "--- AssociativeArray\n" + "---- KeyValuePair\n" + "----- String (a)\n" + "----- Expression\n" + "------ Number (1)\n" + "- Return\n" + "-- Expression\n" + "--- Expression\n" + "---- Lookup\n" + "----- Identifier (a)\n" + "----- Reference (obj)" + ) + assert ast.print() == expected + + def test_parenthesized_expression_with_multiplication(self): + """Test that (variable * number) is parsed as a parenthesized expression, not a node.""" + parser = Parser() + ast = parser.parse("WITH 5 AS x RETURN (x * 3)") + expected = ( + "ASTNode\n" + "- With\n" + "-- Expression (x)\n" + "--- Number (5)\n" + "- Return\n" + "-- Expression\n" + "--- Expression\n" + "---- Multiply\n" + "----- Reference (x)\n" + "----- Number (3)" + ) + assert ast.print() == expected diff --git a/src/parsing/parser.ts b/src/parsing/parser.ts index 49b274e..c1c5b39 100644 --- a/src/parsing/parser.ts +++ b/src/parsing/parser.ts @@ -798,12 +798,7 @@ class Parser extends BaseParser { expression.addNode(lookup); return true; } - } else if ( - this.token.isLeftParenthesis() && - (this.peek()?.isIdentifierOrKeyword() || - this.peek()?.isColon() || - this.peek()?.isRightParenthesis()) - ) { + } else if (this.token.isLeftParenthesis() && this.looksLikeNodePattern()) { // Possible graph pattern expression const pattern = this.parsePatternExpression(); if (pattern !== null) { @@ -865,6 +860,42 @@ class Parser extends BaseParser { return false; } + /** + * Peeks ahead from a left parenthesis to determine whether the + * upcoming tokens form a graph-node pattern (e.g. (n:Label), (n), + * (:Label), ()) rather than a parenthesised expression (e.g. + * (variable.property), (a + b)). + * + * The heuristic is: + * • ( followed by `:` or `)` → node pattern + * • ( identifier, then `:` or `{` or `)` → node pattern + * • anything else → parenthesised expression + */ + private looksLikeNodePattern(): boolean { + const savedIndex = this.tokenIndex; + this.setNextToken(); // skip '(' + this.skipWhitespaceAndComments(); + + if (this.token.isColon() || this.token.isRightParenthesis()) { + this.tokenIndex = savedIndex; + return true; + } + + if (this.token.isIdentifierOrKeyword()) { + this.setNextToken(); // skip identifier + this.skipWhitespaceAndComments(); + const result = + this.token.isColon() || + this.token.isOpeningBrace() || + this.token.isRightParenthesis(); + this.tokenIndex = savedIndex; + return result; + } + + this.tokenIndex = savedIndex; + return false; + } + private parseExpression(): Expression | null { const expression = new Expression(); while (true) { diff --git a/tests/parsing/parser.test.ts b/tests/parsing/parser.test.ts index 2fc1452..0ae7bb9 100644 --- a/tests/parsing/parser.test.ts +++ b/tests/parsing/parser.test.ts @@ -1098,3 +1098,61 @@ test("Test WHERE with NOT ENDS WITH", () => { "--- Reference (s)" ); }); + +test("Test parenthesized expression with addition", () => { + const parser = new Parser(); + const ast = parser.parse("WITH 1 AS n RETURN (n + 2)"); + // prettier-ignore + expect(ast.print()).toBe( + "ASTNode\n" + + "- With\n" + + "-- Expression (n)\n" + + "--- Number (1)\n" + + "- Return\n" + + "-- Expression\n" + + "--- Expression\n" + + "---- Add\n" + + "----- Reference (n)\n" + + "----- Number (2)" + ); +}); + +test("Test parenthesized expression with property access", () => { + const parser = new Parser(); + const ast = parser.parse("WITH {a: 1} AS obj RETURN (obj.a)"); + // prettier-ignore + expect(ast.print()).toBe( + "ASTNode\n" + + "- With\n" + + "-- Expression (obj)\n" + + "--- AssociativeArray\n" + + "---- KeyValuePair\n" + + "----- String (a)\n" + + "----- Expression\n" + + "------ Number (1)\n" + + "- Return\n" + + "-- Expression\n" + + "--- Expression\n" + + "---- Lookup\n" + + "----- Identifier (a)\n" + + "----- Reference (obj)" + ); +}); + +test("Test parenthesized expression with multiplication", () => { + const parser = new Parser(); + const ast = parser.parse("WITH 5 AS x RETURN (x * 3)"); + // prettier-ignore + expect(ast.print()).toBe( + "ASTNode\n" + + "- With\n" + + "-- Expression (x)\n" + + "--- Number (5)\n" + + "- Return\n" + + "-- Expression\n" + + "--- Expression\n" + + "---- Multiply\n" + + "----- Reference (x)\n" + + "----- Number (3)" + ); +});