plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


43e5250Peter John 2026-09-08T15:22:00+05:30
feat: add Groovy/Kotlin-style safe navigation operator (?.)
README.md CHANGED
@@ -82,7 +82,7 @@ From tightest to loosest binding:
82
82
 
83
83
  | Precedence | Operators | Notes |
84
84
  |---|---|---|
85
- | highest | `.` | attribute access / method call |
85
+ | highest | `.` `?.` | attribute access / method call, safe navigation |
86
86
  | | `+` `-` | unary |
87
87
  | | `*` `/` `%` | |
88
88
  | | `+` `-` | |
@@ -340,6 +340,15 @@ fun sumOrZero(a: Str, b: Str) -> Int =
340
340
  x + y
341
341
  ```
342
342
 
343
+ `obj?.field` / `obj?.method(args)` (safe navigation, Groovy/Kotlin-style) reaches into a `Result`/`Option` value without unwrapping it — `Some`/`Ok` maps the field or method access over the inner value and re-wraps it, `None`/`Err` passes through untouched:
344
+
345
+ ```plum
346
+ fun cityName(person: Option[Person]) -> Option[Str] =
347
+ person?.city?.name
348
+ ```
349
+
350
+ It's sugar for `obj.map(|v| v.field)` — nothing more than that; chain it as many times as needed.
351
+
343
352
  ## Standard library highlights
344
353
 
345
354
  - **`Bool`** (`import std/Bool`) — ordinary `enum Bool = | True | False`
plum-core/src/parser.rs CHANGED
@@ -672,6 +672,7 @@ impl<'a> AstParser<'a> {
672
672
  "unary_operator" => self.parseUnary(node),
673
673
  "attribute" => self.parseAttribute(node),
674
674
  "try_expression" => self.parseTryExpression(node),
675
+ "safe_attribute" => self.parseSafeAttribute(node),
675
676
  "fn_call" => Expr::FnCall(self.parseFnCall(node)),
676
677
  "class_call" => Expr::ClassCall(self.parseClassCall(node)),
677
678
  "parenthesized_expression" => {
@@ -824,6 +825,52 @@ impl<'a> AstParser<'a> {
824
825
  Expr::Try(Box::new(value))
825
826
  }
826
827
 
828
+ /// safe_attribute: primary_expression "?." fn_identifier fn_argument_list?
829
+ ///
830
+ /// Desugars entirely here into `object.map(|v| v.member(...))` — reusing
831
+ /// Option/Result's existing generic `.map` method for the actual
832
+ /// unwrap/transform/rewrap, so nothing past the parser (checker,
833
+ /// monomorphizer, codegen, formatters) needs to know `?.` exists at all.
834
+ /// The synthesized param name can't collide with a real variable in scope:
835
+ /// the closure body only ever references this one param, never anything
836
+ /// from the surrounding scope, so shadowing an outer name of the same
837
+ /// text (if that ever happened) would still be correct.
838
+ fn parseSafeAttribute(&self, node: Node) -> Expr {
839
+ const SAFE_NAV_PARAM: &str = "__safe_nav";
840
+
841
+ let named: Vec<Node> = self.namedChildren(node);
842
+ let object = named.first()
843
+ .map(|n| { let u = self.unwrapExprNode(*n); self.parsePrimaryExpression(u) })
844
+ .unwrap_or(Expr::Int(0));
845
+ let member = named.get(1).map(|n| self.text(*n)).unwrap_or_default();
846
+ let attr = match named.get(2) {
847
+ Some(args_node) => {
848
+ let mut acursor = args_node.walk();
849
+ let args = args_node.named_children(&mut acursor)
850
+ .map(|n| self.parseArg(n))
851
+ .collect();
852
+ AttrKind::Method(FnCall { name: member, args })
853
+ }
854
+ None => AttrKind::Field(member),
855
+ };
856
+
857
+ let inner = Expr::Attribute(Box::new(AttributeExpr {
858
+ object: Expr::Var(SAFE_NAV_PARAM.to_string()),
859
+ attr,
860
+ }));
861
+ let closure = Closure {
862
+ params: vec![SAFE_NAV_PARAM.to_string()],
863
+ body: Block { stmts: vec![Stmt::Expr(inner)] },
864
+ };
865
+ Expr::Attribute(Box::new(AttributeExpr {
866
+ object,
867
+ attr: AttrKind::Method(FnCall {
868
+ name: "map".to_string(),
869
+ args: vec![Arg::Positional(Expr::Closure(Box::new(closure)))],
870
+ }),
871
+ }))
872
+ }
873
+
827
874
  fn parseFnCall(&self, node: Node) -> FnCall {
828
875
  // fn_call: var_identifier fn_argument_list (the callee lexes as var_identifier
829
876
  // to avoid an identifier-token tie with all-lowercase, no-underscore names)
@@ -1076,6 +1123,7 @@ fn isExpressionKind(kind: &str) -> bool {
1076
1123
  | "elvis_expression"
1077
1124
  | "attribute"
1078
1125
  | "try_expression"
1126
+ | "safe_attribute"
1079
1127
  | "fn_call"
1080
1128
  | "class_call"
1081
1129
  | "parenthesized_expression"
plum-examples/safe_navigation.plum ADDED
@@ -0,0 +1,58 @@
1
+ import std/Option
2
+ import std/Result
3
+ import std/Bool
4
+ import std/Number
5
+ import std/Str
6
+ # `obj?.field` / `obj?.method(args)` — Groovy/Kotlin-style safe navigation.
7
+ # Reaches into a `Result`'s `Ok`/an `Option`'s `Some` without unwrapping it,
8
+ # mapping the field/method access over the inner value and re-wrapping the
9
+ # result; `Err`/`None` passes through untouched. Pure sugar for
10
+ # `obj.map(|v| v.field)` — desugared entirely in the parser (see
11
+ # `plum-core`'s `parseSafeAttribute`), so nothing past it needs to know this
12
+ # syntax exists.
13
+
14
+ enum Person =
15
+ | Person(name: Str, age: Int)
16
+ fun birthday(self) -> Int =
17
+ self.age + 1
18
+
19
+ fun personName(p: Option[Person]) -> Option[Str] =
20
+ p?.name
21
+
22
+ fun personBirthday(p: Option[Person]) -> Option[Int] =
23
+ p?.birthday()
24
+
25
+ fun personNameResult(p: Result[Person, Str]) -> Result[Str, Str] =
26
+ p?.name
27
+
28
+ fun somePerson() -> Option[Person] =
29
+ Some(Person(name: "Ann", age: 3))
30
+
31
+ fun noPerson() -> Option[Person] =
32
+ None
33
+
34
+ fun okPerson() -> Result[Person, Str] =
35
+ Ok(Person(name: "Ann", age: 3))
36
+
37
+ fun errPerson() -> Result[Person, Str] =
38
+ Err("missing")
39
+
40
+ test "safe navigation maps a field access over Some, passes None through unchanged"
41
+ a := personName(somePerson())
42
+ assert a.isSome()
43
+ assert a.unwrap() == "Ann"
44
+ b := personName(noPerson())
45
+ assert b.isNone()
46
+
47
+ test "safe navigation maps a method call over Some"
48
+ a := personBirthday(somePerson())
49
+ assert a.isSome()
50
+ assert a.unwrap() == 4
51
+
52
+ test "safe navigation maps a field access over Ok, passes Err through unchanged"
53
+ a := personNameResult(okPerson())
54
+ assert a.isOk()
55
+ assert a.unwrap() == "Ann"
56
+ b := personNameResult(errPerson())
57
+ assert b.isErr()
58
+ assert b.unwrapErr() == "missing"
plum-tooling/tree-sitter-plum/grammar.js CHANGED
@@ -403,6 +403,7 @@ module.exports = grammar({
403
403
  $.unary_operator,
404
404
  $.attribute,
405
405
  $.try_expression,
406
+ $.safe_attribute,
406
407
  $.fn_call,
407
408
  $.class_call,
408
409
  $.parenthesized_expression,
@@ -525,6 +526,25 @@ module.exports = grammar({
525
526
  try_expression: ($) =>
526
527
  seq(field("value", $.primary_expression), "?"),
527
528
 
529
+ // Groovy/Kotlin-style safe navigation: `obj?.member` / `obj?.method(args)`
530
+ // on a `Result`/`Option` value — same shape as `attribute` just above, but
531
+ // with a `?.` token in place of `.`. `?.` is its own literal token, so the
532
+ // lexer's usual longest-match rule already picks it over a bare `?` (the
533
+ // `try_expression` token) with no extra grammar conflict needed, the same
534
+ // way `elvis_expression`'s `?:` needed none. Desugars entirely in
535
+ // `parser.rs` (into `.map(|v| v.member(...))`, reusing Option/Result's
536
+ // existing generic `.map`) — nothing past the parser ever sees this node.
537
+ safe_attribute: ($) =>
538
+ prec(
539
+ PREC.call,
540
+ seq(
541
+ field("object", $.primary_expression),
542
+ "?.",
543
+ field("member", $.fn_identifier),
544
+ field("arguments", optional($.fn_argument_list)),
545
+ ),
546
+ ),
547
+
528
548
  // The callee name lexes as `var_identifier` (widened to a superset of
529
549
  // `fn_identifier`'s charset below) rather than `fn_identifier` — using two
530
550
  // different identifier tokens here was ambiguous for any all-lowercase,
plum-tooling/tree-sitter-plum/queries/plum/highlights.scm CHANGED
@@ -66,6 +66,8 @@
66
66
 
67
67
  (elvis_expression "?:" @operator)
68
68
 
69
+ (safe_attribute "?." @operator)
70
+
69
71
  [
70
72
  "->"
71
73
  "=>"
plum-tooling/tree-sitter-plum/src/grammar.json CHANGED
Binary file
plum-tooling/tree-sitter-plum/src/node-types.json CHANGED
Binary file
plum-tooling/tree-sitter-plum/src/parser.c CHANGED
Binary file
plum-tooling/tree-sitter-plum/test/corpus/safe_attribute.txt ADDED
@@ -0,0 +1,142 @@
1
+ ================================================================================
2
+ safe navigation operator - field access on an Option/Result value
3
+ ================================================================================
4
+
5
+ fun greet(person: Option[Person]) -> Option[Str] =
6
+ person?.name
7
+
8
+ --------------------------------------------------------------------------------
9
+
10
+ (source
11
+ (fn
12
+ name: (fn_identifier)
13
+ params: (param
14
+ name: (var_identifier)
15
+ type: (type
16
+ (type_identifier)
17
+ generics: (type
18
+ (type_identifier))))
19
+ returns: (type
20
+ (type_identifier)
21
+ generics: (type
22
+ (type_identifier)))
23
+ body: (body
24
+ (expression
25
+ (primary_expression
26
+ (safe_attribute
27
+ object: (primary_expression
28
+ (var_identifier))
29
+ member: (fn_identifier)))))))
30
+
31
+ ================================================================================
32
+ safe navigation operator - method call on an Option/Result value
33
+ ================================================================================
34
+
35
+ fun greetCall(person: Option[Person]) -> Option[Str] =
36
+ person?.greeting("hi")
37
+
38
+ --------------------------------------------------------------------------------
39
+
40
+ (source
41
+ (fn
42
+ name: (fn_identifier)
43
+ params: (param
44
+ name: (var_identifier)
45
+ type: (type
46
+ (type_identifier)
47
+ generics: (type
48
+ (type_identifier))))
49
+ returns: (type
50
+ (type_identifier)
51
+ generics: (type
52
+ (type_identifier)))
53
+ body: (body
54
+ (expression
55
+ (primary_expression
56
+ (safe_attribute
57
+ object: (primary_expression
58
+ (var_identifier))
59
+ member: (fn_identifier)
60
+ arguments: (fn_argument_list
61
+ (expression
62
+ (primary_expression
63
+ (string
64
+ (string_start)
65
+ (string_content)
66
+ (string_end)))))))))))
67
+
68
+ ================================================================================
69
+ safe navigation operator - coexists with ternary, elvis, and try, all sharing `?`-prefixed tokens
70
+ ================================================================================
71
+
72
+ fun classify(n: Int) -> Int =
73
+ a = n > 0 ? n : 0
74
+ b = n ?: 0
75
+ c = n?
76
+ d = opt?.field
77
+ a + b + c
78
+
79
+ --------------------------------------------------------------------------------
80
+
81
+ (source
82
+ (fn
83
+ name: (fn_identifier)
84
+ params: (param
85
+ name: (var_identifier)
86
+ type: (type
87
+ (type_identifier)))
88
+ returns: (type
89
+ (type_identifier))
90
+ body: (body
91
+ (assign
92
+ (var_identifier)
93
+ (expression
94
+ (ternary_expression
95
+ (expression
96
+ (comparison_operator
97
+ (primary_expression
98
+ (var_identifier))
99
+ (primary_expression
100
+ (integer))))
101
+ (expression
102
+ (primary_expression
103
+ (var_identifier)))
104
+ (expression
105
+ (primary_expression
106
+ (integer))))))
107
+ (assign
108
+ (var_identifier)
109
+ (expression
110
+ (elvis_expression
111
+ (expression
112
+ (primary_expression
113
+ (var_identifier)))
114
+ (expression
115
+ (primary_expression
116
+ (integer))))))
117
+ (assign
118
+ (var_identifier)
119
+ (expression
120
+ (primary_expression
121
+ (try_expression
122
+ value: (primary_expression
123
+ (var_identifier))))))
124
+ (assign
125
+ (var_identifier)
126
+ (expression
127
+ (primary_expression
128
+ (safe_attribute
129
+ object: (primary_expression
130
+ (var_identifier))
131
+ member: (fn_identifier)))))
132
+ (expression
133
+ (primary_expression
134
+ (binary_operator
135
+ left: (primary_expression
136
+ (binary_operator
137
+ left: (primary_expression
138
+ (var_identifier))
139
+ right: (primary_expression
140
+ (var_identifier))))
141
+ right: (primary_expression
142
+ (var_identifier))))))))
plum-tooling/vscode-plum/syntaxes/plum.tmLanguage.json CHANGED
@@ -63,10 +63,15 @@
63
63
  "name": "keyword.operator.elvis.plum",
64
64
  "match": "\\?:"
65
65
  },
66
+ {
67
+ "comment": "safe navigation (Result/Option `.map` sugar) — checked before the bare `?` try-operator below, since `?.` must win the longer match",
68
+ "name": "keyword.operator.safenav.plum",
69
+ "match": "\\?\\."
70
+ },
66
71
  {
67
72
  "comment": "postfix try-operator (Result/Option unwrap-or-early-return) — matched only when `?` glues directly to the preceding token with no space, `plum format`'s own convention for this postfix operator (unlike ternary's `?`, always spaced); ternary's own `?`/`:` are deliberately left unstyled here too, matching the tree-sitter highlights.scm, since a regex can't reliably tell them apart",
68
73
  "name": "keyword.operator.try.plum",
69
- "match": "(?<=[A-Za-z0-9_\\)\\]])\\?(?!:)"
74
+ "match": "(?<=[A-Za-z0-9_\\)\\]])\\?(?!:)(?!\\.)"
70
75
  },
71
76
  {
72
77
  "name": "keyword.operator.logical.plum",