plum

#treesitter#compiler#wasm

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)));
}