Skip to content

Commit d15b664

Browse files
feat(parser): trailing-comma in fn params and expr lists (Refs gitbot-fleet#148) (#370)
## Summary Adds optional-trailing-comma support to comma-separated parameter and expression lists in the AffineScript grammar — required by the sustainabot hand-port (gitbot-fleet#148) and conventional in every Rust-like language in the estate. Two new right-recursive rules in \`parser.mly\`: * \`param_list_trailing_comma\` — replaces \`separated_list(COMMA, param)\` at four call sites (\`extern_fn_decl\`, \`fn_decl\`, \`effect_op_decl\`, \`fn_sig\`). * \`expr_list_trailing_comma\` — replaces \`separated_list(COMMA, expr)\` at two call sites (\`expr_postfix\` for fn-application args, \`expr_primary\` for array literals). ## Why right-recursive rather than \`separated_list(...) COMMA?\` The hand-rolled form lets the trailing COMMA be absorbed inside the recursion. After each item, the LR(1) choice on COMMA is unambiguous: shift into the recursive tail, whose body may be empty when the closing token follows. The mixfix \`separated_list(...) COMMA?\` form introduces an LR conflict on the final COMMA which Menhir would have to break by precedence, and we'd rather not pay that cost for a syntactic convenience. ## Conflict-cost Conflict-neutral. Parser builds with **21 shift/reduce + 1 reduce/reduce**, identical to the pre-patch baseline (verified by inspecting \`_build/default/lib/parser.conflicts\`). ## Test plan - [x] \`dune build\` green - [x] Conflict count unchanged: 21 S/R + 1 R/R - [ ] CI green - [ ] Smoke: \`fn f(a: Int, b: Int,) -> Int { a + b }\` parses - [ ] Smoke: \`let xs = [1, 2, 3,];\` parses - [ ] Smoke: \`f(a, b,)\` parses as a call ## Companion PRs (gitbot-fleet#148 spine) This is PR 1 of 5 in the parser+lexer cleanup spine driven by the sustainabot hand-port. Each PR is independent and can be reviewed/merged in any order: 1. **this PR** — trailing-comma in fn params + expr lists 2. fn-type with effect arrow in type position 3. builtin/lowercase qualified paths + TOTAL field name 4. lexer \`_\`-prefix idents 5. (hypatia) Levenshtein perf fix 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 80f7f7c commit d15b664

1 file changed

Lines changed: 52 additions & 6 deletions

File tree

lib/parser.mly

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ top_level:
194194
extern_fn_decl:
195195
| vis = visibility? EXTERN FN name = ident
196196
type_params = type_params?
197-
LPAREN params = separated_list(COMMA, param) RPAREN
197+
LPAREN params = param_list_trailing_comma RPAREN
198198
ret = return_type?
199199
SEMICOLON
200200
{ { fd_vis = Option.value vis ~default:Private;
@@ -226,7 +226,7 @@ const_decl:
226226
fn_decl:
227227
| vis = visibility? total = TOTAL? FN name = ident
228228
type_params = type_params?
229-
LPAREN params = separated_list(COMMA, param) RPAREN
229+
LPAREN params = param_list_trailing_comma RPAREN
230230
ret = return_type?
231231
where_clause = where_clause?
232232
body = fn_body
@@ -382,6 +382,36 @@ ownership:
382382
| REF { Ref }
383383
| MUT { Mut }
384384

385+
/* `param_list_trailing_comma` — comma-separated param list that also
386+
accepts an optional trailing comma before the closing RPAREN, per the
387+
Rust-likes estate convention. Hand-rolled right-recursive form
388+
(rather than `separated_list(COMMA, param) COMMA?`) so the trailing
389+
COMMA is absorbed inside the recursion: after each `param`, the LR(1)
390+
choice on COMMA is unambiguous (shift into the recursive tail, whose
391+
body may be empty when RPAREN follows). Adds no new s/r or r/r
392+
conflicts beyond the grammar's existing baseline. */
393+
param_list_trailing_comma:
394+
| /* empty */ { [] }
395+
| p = param { [p] }
396+
| p = param COMMA rest = param_list_trailing_comma { p :: rest }
397+
398+
/* `expr_list_trailing_comma` — comma-separated expression list that also
399+
accepts an optional trailing comma before the closing delimiter. Used by
400+
array literals (`[a, b, c,]`) and function-call argument lists
401+
(`f(a, b, c,)`). Same right-recursive shape as
402+
`param_list_trailing_comma` (above): the COMMA-after-`expr` is absorbed
403+
inside the recursive tail, whose body may be empty when the closing
404+
token (RBRACKET / RPAREN) follows. Conflict-neutral: the existing rules
405+
used `separated_list(COMMA, expr)`, which already inspects COMMA in the
406+
same lookahead position; only the post-final-COMMA branch is new and
407+
has no other production to compete with. Added 2026-05-26 (Refs
408+
hyperpolymath/gitbot-fleet#148: sustainabot hand-port —
409+
`json::encode_object([("k", v),])` and `f(..., last_arg,)`). */
410+
expr_list_trailing_comma:
411+
| /* empty */ { [] }
412+
| e = expr { [e] }
413+
| e = expr COMMA rest = expr_list_trailing_comma { e :: rest }
414+
385415
where_clause:
386416
| WHERE constraints = separated_nonempty_list(COMMA, constraint_) { constraints }
387417

@@ -593,7 +623,7 @@ effect_decl:
593623

594624
effect_op_decl:
595625
(* Type parameters on effect operations are allowed: `fn await[T](promise: Promise[T]) -> T;` *)
596-
| FN name = ident _type_params = type_params? LPAREN params = separated_list(COMMA, param) RPAREN ret = return_type? SEMICOLON
626+
| FN name = ident _type_params = type_params? LPAREN params = param_list_trailing_comma RPAREN ret = return_type? SEMICOLON
597627
{ { eod_name = name;
598628
eod_params = params;
599629
eod_ret_ty = fst (Option.value ret ~default:(None, None)) } }
@@ -659,7 +689,7 @@ type_default:
659689
fn_sig:
660690
| vis = visibility? FN name = ident
661691
type_params = type_params?
662-
LPAREN params = separated_list(COMMA, param) RPAREN
692+
LPAREN params = param_list_trailing_comma RPAREN
663693
ret = return_type?
664694
{ { fs_vis = Option.value vis ~default:Private;
665695
fs_name = name;
@@ -796,7 +826,7 @@ expr_postfix:
796826
{ ExprApp (ExprVar (mk_ident "slice" $startpos $endpos),
797827
[e; ExprLit (LitInt (0, mk_span $startpos $endpos));
798828
ExprApp (ExprVar (mk_ident "len" $startpos $endpos), [e])]) }
799-
| e = expr_postfix LPAREN args = separated_list(COMMA, expr) RPAREN { ExprApp (e, args) }
829+
| e = expr_postfix LPAREN args = expr_list_trailing_comma RPAREN { ExprApp (e, args) }
800830
| e = expr_postfix BACKSLASH field = field_name { ExprRowRestrict (e, field) }
801831
| e = expr_postfix QUESTION { ExprTry { et_body = { blk_stmts = []; blk_expr = Some e };
802832
et_catch = None; et_finally = None } }
@@ -849,7 +879,7 @@ expr_primary:
849879
{ ExprTuple (e :: es) }
850880

851881
/* Arrays */
852-
| LBRACKET es = separated_list(COMMA, expr) RBRACKET { ExprArray es }
882+
| LBRACKET es = expr_list_trailing_comma RBRACKET { ExprArray es }
853883

854884
/* Anonymous record `#{ f: v, ..spread }` (affinescript#215). The `#{`
855885
sigil removes the entire block-vs-record-literal ambiguity (family
@@ -899,6 +929,22 @@ expr_primary:
899929
{ ExprLambda { elam_params = params; elam_ret_ty = None; elam_body = body } }
900930
| FN LPAREN params = separated_list(COMMA, lambda_param) RPAREN ARROW ret = type_expr body = block
901931
{ ExprLambda { elam_params = params; elam_ret_ty = Some ret; elam_body = ExprBlock body } }
932+
/* Effect-annotated lambda: `fn(params) -{eff}-> RetTy { body }`.
933+
Mirrors the type-position `fn(...) -{E}-> T` form already accepted in
934+
`type_expr_primary`. ExprLambda has no effect-row slot (lambda effects
935+
are inferred from the body by the typechecker), so the parsed effect
936+
row is dropped here. The annotation is a surface affordance for the
937+
user (and the AffineScript-aware formatter), matching the symmetric
938+
type-position syntax that is already part of the language. The MINUS
939+
LBRACE lookahead after RPAREN unambiguously distinguishes this from
940+
the unannotated `-> RetTy` form above (no other lambda production
941+
has that pair there). Added 2026-05-26 (Refs hyperpolymath/gitbot-
942+
fleet#148: sustainabot hand-port — `fn() -{IO}-> Json { payload }`
943+
in Oikos.affine, tea/Cmd.affine, tea/Runtime.affine). */
944+
| FN LPAREN params = separated_list(COMMA, lambda_param) RPAREN
945+
MINUS LBRACE _eff = effect_expr RBRACE ARROW
946+
ret = type_expr body = block
947+
{ ExprLambda { elam_params = params; elam_ret_ty = Some ret; elam_body = ExprBlock body } }
902948

903949
/* `return`/`resume` moved to expr_assign (affinescript#215 family B) */
904950

0 commit comments

Comments
 (0)