Predicator.Parser (predicator v8.0.0)

Copy Markdown View Source

Recursive descent parser for predicator expressions.

The parser converts a stream of tokens from the lexer into an Abstract Syntax Tree (AST) with comprehensive error reporting including exact position information.

Grammar

The parser implements this grammar with proper operator precedence:

program       statement ( ";" statement )* ( ";" )?
statement     if_statement | while_statement | assignment | expression
if_statement  "if" expression block ( "else" ( block | if_statement ) )?
while_statement  "while" expression block
block         "{" ( statement ( ";" statement )* ( ";" )? )? "}"
assignment    location "=" expression
location      IDENTIFIER ( "." IDENTIFIER | "[" expression "]" )*
expression    logical_or
logical_or    logical_and ( "OR" | "||" logical_and )*
logical_and   logical_not ( "AND" | "&&" logical_not )*
logical_not   "NOT" | "!" logical_not | comparison
comparison    addition ( ( ">" | "<" | ">=" | "<=" | "==" | "!=" | "===" | "!==" | "in" | "contains" ) addition )?
addition      multiplication ( ( "+" | "-" ) multiplication )*
multiplication  unary ( ( "*" | "/" | "%" ) unary )*
unary         ( "-" | "!" ) unary | postfix
postfix       primary ( "[" expression "]" | "." IDENTIFIER | "::" TYPE_NAME )*
TYPE_NAME     "integer" | "float" | "string" | "boolean" | "date" | "datetime" | "duration"
primary       NUMBER | FLOAT | STRING | BOOLEAN | DATE | DATETIME | IDENTIFIER | duration | relative_date | function_call | list | object | "(" expression ")"
function_call  FUNCTION_NAME "(" ( expression ( "," expression )* )? ")"
list          "[" ( expression ( "," expression )* )? "]"
object        "{" ( object_entry ( "," object_entry )* )? "}"
object_entry  object_key ":" expression
object_key    IDENTIFIER | STRING
duration      NUMBER UNIT+
relative_date  duration "ago" | duration "from" "now" | "next" duration | "last" duration

TYPE_NAME is matched contextually against an IDENTIFIER token rather than lexed as its own keyword, so the seven names remain usable as variables, properties, and object keys everywhere else (ADR-0011).

Two entry points reach those two grammars. parse/2 parses the expression production alone and rejects a top-level =; parse_program/2 parses the program production and is the only place assignment is legal. The mode - expression or statement - belongs to the entry point rather than to anything in the token stream, which is the same split docs/isa.md draws between Predicator.evaluate/2,3 and Predicator.execute/2.

= is assignment, not equality: it is valid only at the start of a statement and only with an assignable left side. A bare = in expression position is a parse error naming == as the fix. == and === are the only equality operators.

Source positions

Every AST node carries a trailing {line, column} giving the 1-based position of the token that defines it. Leaves point at their own token; operators point at the operator token, so the arithmetic node in a * true reports column 3.

A node built by a caller rather than parsed carries nil in that slot. The slot is not optional: there is one AST shape, and the visitors match on it.

Source spans

Pass spans: true to parse/2 and the same trailing slot carries a Predicator.Types.span/0 instead - the source text the node covers, which is what a diagnostic underlines.

# default
{:arithmetic, :multiply, {:identifier, "a", {1, 1}}, {:literal, true, {1, 5}}, {1, 3}}

# spans: true
{:arithmetic, :multiply,
  {:identifier, "a", {{1, 1}, {1, 2}}},
  {:literal, true, {{1, 5}, {1, 9}}},
  {{1, 1}, {1, 9}}}

A span's end is exclusive - one past the last character - so on a single line end_column - start_column is the length, matching LSP ranges. One parse produces one kind of metadata throughout; positions and spans are never mixed in a single tree.

A parenthesized expression's span widens to include its parentheses: (a + b) gives the arithmetic node the span of (a + b), not just a + b, so the span always reads as balanced. Nesting composes to the outermost pair - ((a)) gives the identifier node the span of ((a)) - and a parenthesized leaf widens the same way a parenthesized compound expression does.

Examples

iex> {:ok, tokens} = Predicator.Lexer.tokenize("score > 85")
iex> Predicator.Parser.parse(tokens)
{:ok, {:comparison, :gt, {:identifier, "score", {1, 1}}, {:literal, 85, {1, 9}}, {1, 7}}}

iex> {:ok, tokens} = Predicator.Lexer.tokenize("(age >= 18)")
iex> Predicator.Parser.parse(tokens)
{:ok, {:comparison, :gte, {:identifier, "age", {1, 2}}, {:literal, 18, {1, 9}}, {1, 6}}}

iex> {:ok, tokens} = Predicator.Lexer.tokenize("score > 85 AND age >= 18")
iex> Predicator.Parser.parse(tokens)
{:ok, {:logical_and, {:comparison, :gt, {:identifier, "score", {1, 1}}, {:literal, 85, {1, 9}}, {1, 7}}, {:comparison, :gte, {:identifier, "age", {1, 16}}, {:literal, 18, {1, 23}}, {1, 20}}, {1, 12}}}

Summary

Types

Arithmetic operators in the AST.

An assignment statement: location "=" expression.

Abstract Syntax Tree node types.

A brace-delimited statement sequence: "{" ( statement (";" statement)* (";")? )? "}".

Comparison operators in the AST.

An if statement: "if" expression block ( "else" ( block | if_statement ) )?.

Membership operators in the AST.

An object entry (key-value pair) in an object literal.

A key in an object literal.

How an object key was written: bare ({name: 1}), double-quoted ({"name": 1}), or single-quoted ({'name': 1}).

Internal parser state for tracking position and tokens.

A node's trailing source metadata.

A parsed statement sequence: statement (";" statement)* [";"].

Program parser result.

Relative date directions in the AST.

Parser result - either success with AST or error with details.

One statement: an if statement, a while statement, an assignment, or a bare expression.

Unary operators in the AST.

A value that can appear in literals, including the undefined and null literals.

Anything a visitor accepts: a whole program, any single statement, or the block an if statement holds. Wider than ast/0, which is the expression layer only.

A while statement: "while" expression block.

Functions

Parses a list of tokens into an Abstract Syntax Tree.

Parses a list of tokens into a statement sequence.

Types

arithmetic_op()

@type arithmetic_op() :: :add | :subtract | :multiply | :divide | :modulo

Arithmetic operators in the AST.

assignment()

@type assignment() :: {:assignment, ast(), ast(), position()}

An assignment statement: location "=" expression.

location is the raw access chain the parser built - an {:identifier, ...} optionally wrapped in any number of {:property_access, ...} and {:bracket_access, ...} nodes. It is kept as a chain rather than flattened to a path because a bracket key may be an arbitrary expression, resolvable only against a context; Predicator.ContextLocation.resolve/2 does that at runtime. The chain's depth is the segment count ["store", n] needs (ADR-0001).

ast()

@type ast() ::
  {:literal, value(), position()}
  | {:string_literal, binary(), :double | :single, position()}
  | {:identifier, binary(), position()}
  | {:comparison, comparison_op(), ast(), ast(), position()}
  | {:arithmetic, arithmetic_op(), ast(), ast(), position()}
  | {:unary, unary_op(), ast(), position()}
  | {:membership, membership_op(), ast(), ast(), position()}
  | {:logical_and, ast(), ast(), position()}
  | {:logical_or, ast(), ast(), position()}
  | {:logical_not, ast(), position()}
  | {:list, [ast()], position()}
  | {:object, [object_entry()], position()}
  | {:function_call, binary(), [ast()], position()}
  | {:bracket_access, ast(), ast(), position()}
  | {:property_access, ast(), binary(), position()}
  | {:cast, ast(), binary(), position()}
  | {:duration, [{integer(), binary()}], position()}
  | {:relative_date, ast(), relative_direction(), position()}

Abstract Syntax Tree node types.

Every node carries a trailing source position - the {line, column} of the token that defines it, or nil for a node built by a caller rather than parsed.

  • {:literal, value, pos} - A literal value (number, boolean, list, date, datetime, duration)
  • {:string_literal, value, quote_type, pos} - A string literal with quote type information
  • {:identifier, name, pos} - A variable reference
  • {:comparison, operator, left, right, pos} - A comparison expression (including equality)
  • {:arithmetic, operator, left, right, pos} - An arithmetic expression (+, -, *, /, %)
  • {:unary, operator, operand, pos} - A unary expression (-, !)
  • {:logical_and, left, right, pos} - A logical AND expression
  • {:logical_or, left, right, pos} - A logical OR expression
  • {:logical_not, operand, pos} - A logical NOT expression
  • {:list, elements, pos} - A list literal
  • {:object, entries, pos} - An object literal whose keys are object_key/0
  • {:membership, operator, left, right, pos} - A membership operation (in/contains)
  • {:function_call, name, arguments, pos} - A function call with arguments
  • {:bracket_access, object, key, pos} - A bracket access expression (obj[key])
  • {:property_access, object, property, pos} - A property access expression (obj.prop)
  • {:cast, expression, type_name, pos} - a postfix type cast (expr::integer); type_name is one of the seven scalar ISA type names, validated at parse time
  • {:duration, units, pos} - A duration literal (e.g., 3d8h)
  • {:relative_date, duration, direction, pos} - A relative date expression (e.g., 3d ago, next 2w)

block()

@type block() :: {:block, [statement()], position()}

A brace-delimited statement sequence: "{" ( statement (";" statement)* (";")? )? "}".

Produced only as the then/else slot of an if_statement/0 or the body slot of a while_statement/0 - a block has no meaning without the statement that holds it. Not a member of ast/0, the same as program/0 and assignment/0.

comparison_op()

@type comparison_op() ::
  :gt | :lt | :gte | :lte | :eq | :equal_equal | :ne | :strict_eq | :strict_ne

Comparison operators in the AST.

if_statement()

@type if_statement() :: {:if, ast(), block(), block() | nil, position()}

An if statement: "if" expression block ( "else" ( block | if_statement ) )?.

else_block is nil when there is no else and a block/0 when there is

  • including for else { }, whose empty block stays distinguishable from an absent one. else if c { B } desugars to an else_block of {:block, [{:if, c, ..., ...}], pos}, with no separate chain node in the AST (ADR-0013). Not a member of ast/0.

membership_op()

@type membership_op() :: :in | :contains

Membership operators in the AST.

object_entry()

@type object_entry() :: {object_key(), ast()}

An object entry (key-value pair) in an object literal.

The key can be either an identifier or a string literal.

object_key()

@type object_key() :: {:object_key, binary(), object_key_style(), position()}

A key in an object literal.

Object keys have their own tag rather than reusing the expression node tags, so no consumer has to tell a key from an expression by tuple arity. style records how the key was written - bare, or quoted with which character - so Predicator.Visitors.StringVisitor can render it back as the author wrote it.

object_key_style()

@type object_key_style() :: :identifier | :double | :single

How an object key was written: bare ({name: 1}), double-quoted ({"name": 1}), or single-quoted ({'name': 1}).

parser_state()

@type parser_state() :: %{
  tokens: [Predicator.Lexer.token()],
  position: non_neg_integer(),
  spans?: boolean()
}

Internal parser state for tracking position and tokens.

spans? records whether the caller asked for spans; it selects what loc/3 puts in each node's trailing slot.

position()

@type position() :: Predicator.Types.position() | Predicator.Types.span() | nil

A node's trailing source metadata.

A Predicator.Types.position/0 by default - the {line, column} of the token that defines the node - or a Predicator.Types.span/0 when the AST was parsed with spans: true, or nil when the node was not produced by the parser. One parse produces one kind throughout; the two are never mixed in a single tree.

program()

@type program() :: {:program, [statement()], position()}

A parsed statement sequence: statement (";" statement)* [";"].

Produced only by parse_program/2. A program is never an ast/0: the expression entry point cannot return one, and an expression consumer never has to handle one.

program_result()

@type program_result() ::
  {:ok, program()}
  | {:error, binary(), pos_integer(), pos_integer(), Predicator.Types.span()}

Program parser result.

relative_direction()

@type relative_direction() :: :ago | :future | :next | :last

Relative date directions in the AST.

result()

@type result() ::
  {:ok, ast()}
  | {:error, binary(), pos_integer(), pos_integer(), Predicator.Types.span()}

Parser result - either success with AST or error with details.

statement()

@type statement() :: assignment() | if_statement() | while_statement() | ast()

One statement: an if statement, a while statement, an assignment, or a bare expression.

unary_op()

@type unary_op() :: :minus | :bang

Unary operators in the AST.

value()

@type value() ::
  boolean()
  | integer()
  | binary()
  | [value()]
  | Date.t()
  | DateTime.t()
  | Predicator.Types.duration()
  | :undefined
  | nil

A value that can appear in literals, including the undefined and null literals.

visitable()

@type visitable() :: program() | statement() | block()

Anything a visitor accepts: a whole program, any single statement, or the block an if statement holds. Wider than ast/0, which is the expression layer only.

while_statement()

@type while_statement() :: {:while, ast(), block(), position()}

A while statement: "while" expression block.

Statement-position only, on the same terms as if_statement/0: braces are mandatory and the body block opens no scope of its own (ADR-0013). Not a member of ast/0.

Functions

parse(tokens, opts \\ [])

@spec parse(
  [Predicator.Lexer.token()],
  keyword()
) :: result()

Parses a list of tokens into an Abstract Syntax Tree.

Parameters

  • tokens - List of tokens from the lexer
  • opts - Options:
    • :spans - when true, each node's trailing slot carries a Predicator.Types.span/0 covering the source text the node spans, instead of the {line, column} of the token that defines it. Defaults to false.

Returns

  • {:ok, ast} - Successfully parsed expression
  • {:error, message, line, column} - Parse error with position

Examples

iex> {:ok, tokens} = Predicator.Lexer.tokenize("score > 85")
iex> Predicator.Parser.parse(tokens)
{:ok, {:comparison, :gt, {:identifier, "score", {1, 1}}, {:literal, 85, {1, 9}}, {1, 7}}}

iex> {:ok, tokens} = Predicator.Lexer.tokenize("name == \"John\"")
iex> Predicator.Parser.parse(tokens)
{:ok, {:comparison, :equal_equal, {:identifier, "name", {1, 1}}, {:string_literal, "John", :double, {1, 9}}, {1, 6}}}

iex> {:ok, tokens} = Predicator.Lexer.tokenize("active == true")
iex> Predicator.Parser.parse(tokens)
{:ok, {:comparison, :equal_equal, {:identifier, "active", {1, 1}}, {:literal, true, {1, 11}}, {1, 8}}}

iex> {:ok, tokens} = Predicator.Lexer.tokenize("a * true")
iex> Predicator.Parser.parse(tokens, spans: true)
{:ok, {:arithmetic, :multiply, {:identifier, "a", {{1, 1}, {1, 2}}}, {:literal, true, {{1, 5}, {1, 9}}}, {{1, 1}, {1, 9}}}}

parse_program(tokens, opts \\ [])

@spec parse_program(
  [Predicator.Lexer.token()],
  keyword()
) :: program_result()

Parses a list of tokens into a statement sequence.

Implements program := statement (";" statement)* [";"], where a statement is either an assignment (location "=" expression) or an ordinary expression. parse/2 and parse_program/2 are separate entry points because the mode - expression or statement - belongs to the entry point, not to the token stream (docs/isa.md section 2): parse/2 never returns a program/0, and parse_program/2 always returns one, even for a single statement.

Parameters

  • tokens - List of tokens from the lexer
  • opts - Options: :spans, with the same meaning as parse/2

Returns

  • {:ok, program} - Successfully parsed statement sequence
  • {:error, message, line, column} - Parse error with position

Examples

iex> {:ok, tokens} = Predicator.Lexer.tokenize("a = 1; b = a + 1")
iex> {:ok, {:program, statements, _pos}} = Predicator.Parser.parse_program(tokens)
iex> statements
[
  {:assignment, {:identifier, "a", {1, 1}}, {:literal, 1, {1, 5}}, {1, 3}},
  {:assignment, {:identifier, "b", {1, 8}}, {:arithmetic, :add, {:identifier, "a", {1, 12}}, {:literal, 1, {1, 16}}, {1, 14}}, {1, 10}}
]

iex> {:ok, tokens} = Predicator.Lexer.tokenize("42 = 1")
iex> Predicator.Parser.parse_program(tokens)
{:error, "Left side of '=' must be an assignable location - an identifier, a property access, or a bracket access.", 1, 4, {{1, 4}, {1, 5}}}