plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
43e5250
— Peter John
2026-09-08T15:22:00+05:30
feat: add Groovy/Kotlin-style safe navigation operator (?.)
- README.md +10 -1
- plum-core/src/parser.rs +48 -0
- plum-examples/safe_navigation.plum +58 -0
- plum-tooling/tree-sitter-plum/grammar.js +20 -0
- plum-tooling/tree-sitter-plum/queries/plum/highlights.scm +2 -0
- plum-tooling/tree-sitter-plum/src/grammar.json +0 -0
- plum-tooling/tree-sitter-plum/src/node-types.json +0 -0
- plum-tooling/tree-sitter-plum/src/parser.c +0 -0
- plum-tooling/tree-sitter-plum/test/corpus/safe_attribute.txt +142 -0
- plum-tooling/vscode-plum/syntaxes/plum.tmLanguage.json +6 -1
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",
|