plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
plum-tooling/tree-sitter-plum/grammar.js
const PREC = {
closure: -2,
conditional: -1,
parenthesized_expression: 1,
or: 10,
and: 11,
not: 12,
compare: 13,
bitwise_or: 14,
bitwise_and: 15,
xor: 16,
shift: 17,
plus: 18,
times: 19,
unary: 20,
power: 21,
constructor: 22,
call: 23,
// A bare `Name` inside an enum variant's payload parens (`Bar(Name)`) is
// ambiguous between "a positional payload type" and "a discriminant-value
// expression referencing a SCREAMING_CASE const" (a bare uppercase name
// lexes as `type_identifier` either way) — real usage only ever needs
// literal discriminant values (`ReadMin(10)`), never a bare-name one, so
// this always resolves in favor of "payload type".
variantType: 24,
};
const DEC_DIGITS = token(sep1(/[0-9]+/, /_+/));
const HEX_DIGITS = token(sep1(/[0-9a-fA-F]+/, /_+/));
const BIN_DIGITS = token(sep1(/[01]/, /_+/));
const REAL_EXPONENT = token(seq(/[eE]/, optional(/[+-]/), DEC_DIGITS));
module.exports = grammar({
name: "plum",
extras: $ => [
$.comment,
/[\s\f\uFEFF\u2060\u200B]|\r?\n/,
// $.line_continuation,
],
externals: $ => [
$._newline,
$._indent,
$._dedent,
$.string_start,
$._string_content,
$.escape_interpolation,
$.string_end,
// Mark comments as external tokens so that the external scanner is always
// invoked, even if no external token is expected. This allows for better
// error recovery, because the external scanner can maintain the overall
// structure by returning dedent tokens whenever a dedent occurs, even
// if no dedent is expected.
$.comment,
// Allow the external scanner to check for the validity of closing brackets
// so that it can avoid returning dedent tokens between brackets.
']',
')',
'}',
// A zero-width marker the scanner emits only when requested (from the
// guard_case-repeat position in `match`) and only when the next significant
// token starts a fresh line at exactly the enclosing block's indentation.
// Required between successive `guard_case` arms so an inline expression body
// can't be mistaken for a continuation across the line boundary into the next
// arm's leading `|` (which doubles as the bitwise-or operator) — see the
// ARM_BREAK comment in scanner.c for the full rationale.
$._arm_break,
],
// Conflict arises at the empty-argument-list state (e.g. `Foo()`): the parser cannot
// distinguish fn_argument_list from class_argument_list until it sees content or ')'.
// Declaring the conflict at the argument-list level (not the call level) resolves this.
conflicts: ($) => [
[$.fn_argument_list, $.class_argument_list],
// `cmp ? sum : bits` (ternary) vs `cmp?` (try-operator postfix) both start
// with `primary_expression "?"` — genuinely ambiguous with only 1 token of
// lookahead (a `:` may or may not follow much later); GLR resolves it by
// trying both and keeping whichever completes.
[$.expression, $.try_expression],
],
inline: ($) => [$.generic_type],
rules: {
source: ($) =>
seq(
optional($.module),
repeat($.import),
repeat(choice($.trait, $.enum, $.fn, $.const, $.test)),
),
module: ($) => seq("module", $.mod_identifier),
import: ($) => seq("import", $.url),
// Each path segment is its OWN named node (not a bare inline regex) so
// every byte of a `url` is owned by some child node — a bare regex used
// directly in a `seq` (as this used to be) leaves the text it matches
// with no node of its own, which Topiary's leaf-collection silently
// drops entirely (it only copies each CHILD's own span, never a parent's
// "gap" text) — `import std/os` formatted down to `import /`.
url: ($) => sep1($.url_segment, "/"),
url_segment: (_) => /[a-zA-Z_][a-zA-Z_0-9]*/,
generics: ($) => seq("[", commaSep1($.generic_type), "]"),
generic_type: ($) =>
seq($.generic, optional(seq(":", sep1($.type_identifier, "+")))),
type: ($) =>
choice(
// A bare `Name` (no `[...]` generics) is ambiguous with a
// `primary_expression` TypeName reference wherever both a `type`
// and an `expression` are legal in the same position (an enum
// variant's payload parens, which also accept discriminant-value
// expressions) — see `PREC.variantType`'s comment. Real discriminant
// values are always literals (`ReadMin(10)`), never a bare name, so
// this always resolves in favor of "payload type" there.
prec(PREC.variantType, seq(
$.type_identifier,
field(
"generics",
optional(seq("[", commaSep1($.type), "]")),
),
)),
$.generic,
// A slice type, e.g. `[]Byte` — the only slice element type the
// checker currently accepts is `Byte` (see plum-checker), but the
// grammar itself doesn't restrict the element type.
seq("[", "]", field("element", $.type)),
),
variadic_type: ($) => seq("...", $.type),
trait: ($) =>
seq(
"trait",
field("name", $.type_identifier),
field("generics", optional($.generics)),
"=",
$._indent,
field("fields", optional(repeat(alias($.trait_field, $.field)))),
$._dedent,
),
trait_field: ($) =>
seq(
field("name", $.fn_identifier),
field("params", seq("(", optional(commaSep1(choice($.self, $.param))), ")")),
field("returns", optional(seq("->", $.type))),
),
param: ($) =>
seq(
field("name", $.var_identifier),
":",
field("type", choice($.type, $.variadic_type, $.fn_value_type)),
optional(seq("=", field("value", $.expression))),
),
fn_value_type: ($) =>
seq(
"fn",
"(",
field("params", optional(commaSep1($.type))),
")",
optional(seq("->", field("returns", $.type))),
),
enum: ($) =>
seq(
"enum",
field("name", $.type_identifier),
field("generics", optional($.generics)),
// Mutually exclusive with each other (an enum is either a plain sum
// type possibly claiming trait conformance, OR a discriminant enum
// with shared per-variant values — never both) — disambiguated by
// content, not just position: `implements` holds bare type names
// (`ToStr`), `params` holds `name: Type` pairs (`n: Int`), and
// `var_identifier`/`type_identifier` are distinct tokens.
optional(choice(
field("implements", seq("(", commaSep1($.type_identifier), ")")),
field("params", seq("(", commaSep1($.enum_param), ")")),
)),
"=",
$._indent,
optional(repeat(alias($.enum_field, $.field))),
field("methods", optional(repeat($.fn))),
$._dedent,
),
enum_param: ($) =>
seq(field("name", $.var_identifier), ":", field("type", $.type)),
enum_field: ($) =>
seq(
"|",
field("name", $.type_identifier),
field(
"parameters",
optional(choice(
// Positional payload, e.g. `Some(Int)`/`Some(T)` — brackets are
// reserved for generics (a type/trait/enum's OWN declared
// params, and instantiating one, e.g. `List[Int]`); a variant's
// payload always uses parens, same as a class field's type.
seq("(", commaSep1($.type), ")"),
seq("(", commaSep1($.enum_named_field), ")"), // named payload fields, e.g. `Circle(radius: Int)`
seq("(", commaSep1($.expression), ")"), // discriminant value literals
)),
),
),
// A named payload field on an enum variant — `radius: Int` in `Circle(radius: Int)`.
// Distinct from `enum_param` (a shared param declared on the whole `enum`, e.g.
// `enum Foo(n: Int) = ...`) even though the two rules look identical — this one's
// scoped to a single variant's own payload instead.
enum_named_field: ($) => seq(field("name", $.var_identifier), ":", field("type", $.type)),
fn: ($) =>
prec.left(
seq(
// `extern fun foo(...)` (no `=` body at all) declares a function backed
// by a host-provided wasm import instead of a compiled Plum body — see
// `libs/std/os.plum`'s `printLn`. Whether the body is actually present
// vs. legitimately absent is enforced by `plum-checker` (a clear semantic
// error either way), not the grammar, to keep this rule simple.
field("externKw", optional("extern")),
"fun",
field("name", $.fn_identifier),
// A method's receiver is implicit from nesting the `fn` inside a
// `type`/`enum` body (see `class`/`enum` above) — there is no top-level
// `<Receiver>` annotation form. `self` may still appear as a bare first
// parameter to spell out that a nested `fn` is an instance method; it
// carries no type of its own, so the parser discards it rather than
// emitting a `param` — see `parse_fn`.
field("params", seq("(", optional(commaSep1(choice($.self, $.param))), ")")),
field("returns", optional(seq("->", $.type))),
optional(field("body", seq("=", choice($.expression, $.body)))),
)
),
body: ($) => seq($._indent, repeat($._statement), $._dedent),
// A `test` block is a top-level, name-carrying group of assertions, compiled
// only when running `plum test` (stripped from normal builds). Its body is
// the same indented statement block as `fn`/`if`/etc.
test: ($) =>
seq("test", field("name", $.string), field("body", $.body)),
_statement: ($) =>
choice(
$.assign,
$.break,
$.continue,
$.assert,
$.for,
$.while,
$.if,
$.match,
$.return,
$.todo,
$.expression
),
const: ($) =>
seq(
$.const_identifier,
"=",
$.expression,
),
field_target: ($) =>
seq(
field("object", $.primary_expression),
".",
field("member", $.fn_identifier),
),
assign: ($) =>
seq(
commaSep1(choice($.var_identifier, $.field_target)),
field("op", choice("=", ":=")),
commaSep1($.expression),
),
// Outside a `test` body, traps on failure. Inside one, desugars to a
// non-fatal recorded failure instead — the rest of the block keeps
// running (see `plum-wasm-codegen`'s `desugarTestsToFns`).
assert: ($) => seq("assert", $.expression),
return: ($) => prec.right(2, seq("return", optional($.expression))),
break: (_) => prec.left("break"),
continue: (_) => prec.left("continue"),
todo: (_) => prec.left("todo"),
if: ($) =>
seq(
"if",
field("condition", $.expression),
field("body", $.body),
repeat(field("alternative", $.else_if)),
optional(field("otherwise", $.else)),
),
else_if: ($) =>
seq("else if", field("condition", $.expression), field("body", $.body)),
else: ($) => seq("else", field("body", $.body)),
for: ($) =>
seq(
"for",
field("left", commaSep1($.var_identifier)),
":=",
"range",
field("right", $.primary_expression),
field("body", $.body),
),
while: ($) =>
seq("while", field("condition", $.expression), field("body", $.body)),
dotted_name: ($) => prec(1, sep1($.var_identifier, ".")),
// Match cases
match: ($) =>
prec.left(
choice(
seq(
"match",
commaSep1(field("subject", $.expression)), // remove comma use tuples (a, b) and match against tuples
$._indent,
repeat(field("case", $.case)),
$._dedent,
),
seq(
"match",
$._indent,
optional(
seq(
field("case", $.guard_case),
repeat(seq($._arm_break, field("case", $.guard_case))),
),
),
$._dedent,
),
),
),
case: ($) =>
seq(
commaSep1($.case_pattern),
// Optional guard: `Fantasy(b) when b.hasMythicalCreatures => ...`.
// Patterns must still match positionally for the guard to even run;
// a matching case whose guard evaluates false falls through to the
// NEXT case (same fallthrough compileCasePositions already gives a
// plain pattern mismatch), not to a different position of this case.
optional(seq("when", field("guard", $.expression))),
"=>",
field("body", choice($.expression, $.body)),
),
case_pattern: ($) =>
prec(
1,
choice(
$.class_pattern,
$.type_identifier,
$.string,
$.integer,
$.float,
$.dotted_name,
"_",
),
),
guard_case: ($) =>
seq(
"|",
field("guard", choice($.expression, "_")),
"=>",
field("body", choice($.expression, $.body)),
),
class_pattern: ($) =>
seq(
$.type_identifier,
"(",
optional(seq(commaSep1($.case_pattern), optional(","))),
")",
),
expression: ($) =>
choice(
$.comparison_operator,
$.not_operator,
$.boolean_operator,
$.closure,
$.primary_expression,
$.ternary_expression,
$.elvis_expression,
),
primary_expression: ($) =>
choice(
$.binary_operator,
$.self,
$.var_identifier,
$.type_identifier,
$.const_identifier,
$.string,
$.integer,
$.float,
$.unary_operator,
$.attribute,
$.try_expression,
$.safe_attribute,
$.fn_call,
$.class_call,
$.parenthesized_expression,
),
parenthesized_expression: ($) =>
prec(PREC.parenthesized_expression, seq("{", $.expression, "}")),
not_operator: ($) =>
prec(PREC.not, seq("!", field("argument", $.expression))),
boolean_operator: ($) =>
choice(
prec.left(
PREC.and,
seq(
field("left", $.expression),
field("operator", "&&"),
field("right", $.expression),
),
),
prec.left(
PREC.or,
seq(
field("left", $.expression),
field("operator", "||"),
field("right", $.expression),
),
),
),
binary_operator: ($) => {
const table = [
[prec.left, "+", PREC.plus],
[prec.left, "-", PREC.plus],
[prec.left, "*", PREC.times],
[prec.left, "/", PREC.times],
[prec.left, "%", PREC.times],
[prec.left, "|", PREC.bitwise_or],
[prec.left, "&", PREC.bitwise_and],
[prec.left, "^", PREC.xor],
[prec.left, "<<", PREC.shift],
[prec.left, ">>", PREC.shift],
];
// @ts-ignore
return choice(
...table.map(([cb, operator, precedence]) =>
cb(
precedence,
seq(
field("left", $.primary_expression),
field("operator", operator),
field("right", $.primary_expression),
),
),
),
);
},
unary_operator: ($) =>
prec(
PREC.unary,
seq(
field("operator", choice("+", "-")),
field("argument", $.primary_expression),
),
),
comparison_operator: ($) =>
prec.left(
PREC.compare,
seq(
$.primary_expression,
field("operator", choice("<", "<=", "==", "!=", ">=", ">", "<>")),
$.primary_expression,
),
),
closure: ($) =>
// Lower than every other expression's precedence (including
// `ternary_expression`'s own, deliberately low, `PREC.conditional`) so
// that an inline ternary body (`|n| cond ? a : b`) is resolved as the
// TERNARY EXTENDING the closure's own body, not as an outer ternary
// wrapping the whole (already-complete) closure as its condition —
// without this, the parser preferred reducing `closure` (default
// precedence 0, higher than ternary_expression's -1) the moment it saw
// `?`, producing `(|n| cond) ? a : b` instead of `|n| (cond ? a : b)`.
prec(
PREC.closure,
seq(
"|",
field("parameters", optional(commaSep1($.var_identifier))),
"|",
// A closure body is either a single inline expression (`|v| v`, usable as an
// ordinary call argument like `each(|v| v)`) or an indented block — the same
// `choice($.expression, $.body)` shape already used by `fn` and `case` bodies.
field("body", choice($.expression, $.body)),
),
),
// The member name always lexes as `fn_identifier` (a superset of `var_identifier`,
// since plain snake_case names are valid camelCase too) so the parser never has to
// pick between two identifier tokens that could both match the same text — that
// choice was ambiguous and broke `object.method(args)` parsing.
attribute: ($) =>
prec(
PREC.call,
seq(
field("object", $.primary_expression),
".",
field("member", $.fn_identifier),
field("arguments", optional($.fn_argument_list)),
),
),
// Rust-style error-propagation postfix: `expr?` — unwraps a `Result`'s `Ok`
// or an `Option`'s `Some`, or exits the enclosing function early with the
// `Err`/`None` value as-is otherwise. Same postfix shape/precedence as
// `attribute` (`.`) just above.
try_expression: ($) =>
seq(field("value", $.primary_expression), "?"),
// Groovy/Kotlin-style safe navigation: `obj?.member` / `obj?.method(args)`
// on a `Result`/`Option` value — same shape as `attribute` just above, but
// with a `?.` token in place of `.`. `?.` is its own literal token, so the
// lexer's usual longest-match rule already picks it over a bare `?` (the
// `try_expression` token) with no extra grammar conflict needed, the same
// way `elvis_expression`'s `?:` needed none. Desugars entirely in
// `parser.rs` (into `.map(|v| v.member(...))`, reusing Option/Result's
// existing generic `.map`) — nothing past the parser ever sees this node.
safe_attribute: ($) =>
prec(
PREC.call,
seq(
field("object", $.primary_expression),
"?.",
field("member", $.fn_identifier),
field("arguments", optional($.fn_argument_list)),
),
),
// The callee name lexes as `var_identifier` (widened to a superset of
// `fn_identifier`'s charset below) rather than `fn_identifier` — using two
// different identifier tokens here was ambiguous for any all-lowercase,
// no-underscore callee (e.g. `factorial(...)`), since that text matches both
// token rules and the lexer would sometimes commit to the wrong one before
// the parser could see the following `(`.
fn_call: ($) =>
prec(PREC.call, seq(
field("function", choice($.var_identifier, $.type_identifier)),
field(
"arguments",
$.fn_argument_list,
),
)),
fn_argument_list: ($) =>
seq(
"(",
optional(
commaSep1(choice($.expression, $.keyword_argument, $.pair_argument)),
),
optional(","),
")",
),
keyword_argument: ($) =>
seq(field("name", $.var_identifier), "=", field("value", $.expression)),
pair_argument: ($) =>
seq(field("name", $.string), "=>", field("value", $.expression)),
class_call: ($) =>
prec(PREC.call, seq(
field("type", $.type_identifier),
// Explicit type arguments (`List[Int](...)`) — needed whenever a
// generic class's fields alone can't pin down its type params (e.g.
// constructing an EMPTY `List` with no element anywhere in the call
// to infer `T` from).
field("generics", optional(seq("[", commaSep1($.type), "]"))),
field(
"arguments",
$.class_argument_list,
),
)),
class_argument_list: ($) =>
seq(
"(",
optional(seq(
commaSep1(choice($.spread_argument, $.field_argument)),
optional(","),
)),
")",
),
field_argument: ($) =>
seq(field("name", $.var_identifier), ":", field("value", $.expression)),
// Gleam-style functional-update entry: `Point(..p, x: 10)` — fields not
// named after it are read from `p` instead of being required. Only ever
// valid as the FIRST argument — enforced by `plum-core`'s parser, not here.
spread_argument: ($) =>
seq("..", field("value", $.expression)),
ternary_expression: ($) =>
prec.right(
PREC.conditional,
seq($.expression, "?", $.expression, ":", $.expression),
),
// Elvis / null-coalescing: `opt ?: default` — if `left` is `Ok(v)`/`Some(v)`,
// the value is `v`; if it's `Err(_)`/`None`, the value is `right`. A single
// atomic `"?:"` token (maximal munch out-munches the bare `"?"` ternary/
// try-operator tokens at the same position, so — unlike `try_expression`,
// which genuinely shares ternary's leading `?` and needs the `conflicts`
// entry above — this needs no special conflict handling).
elvis_expression: ($) =>
prec.right(
PREC.conditional,
seq($.expression, "?:", $.expression),
),
// ==========
// Literals
// ==========
string: $ => seq(
$.string_start,
repeat(choice($.interpolation, $.string_content)),
$.string_end,
),
string_content: $ => prec.right(repeat1(
choice(
$.escape_interpolation,
$.escape_sequence,
$._not_escape_sequence,
$._string_content,
))),
interpolation: $ => seq(
'{',
$.expression,
'}',
),
escape_sequence: _ => token.immediate(prec(1, seq(
'\\',
choice(
/u[a-fA-F\d]{4}/,
/U[a-fA-F\d]{8}/,
/x[a-fA-F\d]{2}/,
/\d{1,3}/,
/\r?\n/,
/['"abfrntv\\]/,
/N\{[^}]+\}/,
),
))),
_not_escape_sequence: _ => token.immediate('\\'),
float: ($) =>
token(
choice(
seq(
choice(
seq(DEC_DIGITS, REAL_EXPONENT),
seq(
optional(DEC_DIGITS),
".",
DEC_DIGITS,
optional(REAL_EXPONENT),
),
),
optional(/[fF]/),
),
seq(DEC_DIGITS, /[fF]/),
),
),
integer: ($) =>
choice(
token(seq(optional(/[1-9]/), DEC_DIGITS)),
token(seq("0", /[xX]/, HEX_DIGITS)),
token(seq("0", /[bB]/, BIN_DIGITS)),
),
self: (_) => /self/,
comment: _ => token(seq('#', /.*/)),
identifier: (_) => /[_a-z][_a-zA-Z0-9]*/,
generic: (_) => /[A-Z]/, // any single uppercase letter — reserved, illegal as a type_identifier
mod_identifier: () => /[a-z][a-z0-9]*(_[a-z0-9]+)*/, // lower snake case
const_identifier: (_) => /[A-Z][A-Z0-9]*(_[A-Z0-9]+)*/, // upper snake case
// Superset of fn_identifier's charset (adds "_") so fn_call's callee can share
// this single token instead of forcing the lexer to pick between two rules.
var_identifier: (_) => /[a-z][a-zA-Z0-9]*(_[a-zA-Z0-9]+)*/, // lower snake case (or camelCase, when used as a callee)
fn_identifier: (_) => /[a-z][a-zA-Z0-9]*/, // camel case
type_identifier: (_) => /[A-Z][a-zA-Z0-9]+/, // capital case, 2+ chars (single uppercase letters are reserved for `generic`)
},
});
function commaSep1(rule) {
return sep1(rule, ",");
}
function newlineSep1(rule) {
return sep1(rule, $._newline);
}
function sep1(rule, separator) {
return seq(rule, repeat(seq(separator, rule)));
}