Skip to content

Commit 3e53f1d

Browse files
feat(parser): accept slash-effect-row in type-expression position (closes #547) (#550)
## Summary Closes #547. The slash-effect-row form `fn(...) -> T / { E1, E2 }` (and its single-effect short form `fn(...) -> T / E`) parses today only in fn-decl signatures (`return_type`, lines 271-282); it parse-errors in record-field and type-alias positions, forcing every effectful function-typed record field to fall back to the older `-{E}->` arrow form. This is a parser asymmetry, not a language-design question. Surfaced during the post-#228 idaptik port batch in [`hyperpolymath/idaptik#153`](https://github.com/hyperpolymath/idaptik/pull/153). ## Surface-grammar diff ``` type_expr_arrow: | T -> R (already) | T -{E}-> R (already) + | T -> R / { E1, E2, ... } (this PR) + | T -> R / E (this PR) type_expr_primary (FN form, fn-as-type): | fn(A, ...) -> R (already) | fn(A, ...) -{E}-> R (already) + | fn(A, ...) -> R / { E1, ... } (this PR) + | fn(A, ...) -> R / E (this PR) ``` Both spellings remain accepted. `-{E}->` semantics are unchanged. ## Verification - `dune build bin/main.exe` — clean - `dune runtest` — **376/376 PASS** (368 baseline + 8 new) - **Conflict count unchanged** (21 s/r, 1 r/r — same as pre-PR) - Headline probes (all `Type checking passed`): ``` pub type AsyncCall = fn(Int) -> Int / { Async }; pub type H = { call: fn(Int) -> Int / { Async } } pub type H = { call: fn(Int) -> Int / Async } // single short pub type Multi = fn(Int, String) -> Bool / { IO, Async }; pub type R = Int -> Int / { Mut }; // arrow-as-type pub type R = fn() -> () / { IO }; // zero-arg ``` ## Semantic note (which arrow the effect attaches to) - In `type_expr_arrow` (no `fn(...)` wrapper), the slash effect attaches to the **outermost** arrow — the position of the slash itself. This is the natural reading of `A -> B / { E }`. - In the FN-form (`fn(A, B) -> R / { E }`), the effect attaches to the **innermost** arrow (the call site), matching the established prefix-row `-{E}->` FN-form precedent (lines 555-582). This is what consumers expect when porting `fn(A, B) -{E}-> R`. For users who want inner-arrow effect attachment in arrow-as-type positions, the existing `-{E}->` form continues to express that. ## Refs - Closes #547 - Refs #228 (sibling parser-asymmetry gap, closed in #241 / #447 / #523) - Refs #229 (estate-wide ReScript-surface elimination) - Refs #548 (sibling gap from the same port batch — module-level mutable bindings, PR #549) - Refs hyperpolymath/idaptik#153 (the port batch that surfaced this) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3608ff9 commit 3e53f1d

3 files changed

Lines changed: 141 additions & 1 deletion

File tree

.github/workflows/secret-scanner.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ jobs:
1515
secrets: inherit
1616
trufflehog:
1717
runs-on: ubuntu-latest
18+
timeout-minutes: 10
1819
steps:
19-
- uses: actions/checkout@v4
20+
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
2021
with:
2122
fetch-depth: 0
2223
- name: TruffleHog Secret Scan

lib/parser.mly

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,22 @@ type_expr_arrow:
505505
{ TyArrow (arg, None, ret, None) }
506506
| arg = type_expr_primary MINUS LBRACE eff = effect_expr RBRACE ARROW ret = type_expr_arrow
507507
{ TyArrow (arg, None, ret, Some eff) }
508+
/* #547: slash-effect-row trailing form `T -> R / { E1, ... }` and
509+
single-effect `T -> R / E`. Mirrors the return_type slash forms
510+
in fn-decl signatures (lines 271-282) one level up to type-
511+
expression position, closing the parser asymmetry surfaced in
512+
#547: until this PR the `fn(...) -> R / { E }` syntax accepted
513+
in fn-decl signatures parse-errored in record-field / type-alias
514+
positions, forcing every effectful function-typed record field
515+
to fall back to the older `-{E}->` arrow form. Both spellings
516+
remain accepted; the slash form is the canonical effect-row
517+
spelling from the Frontier Guide. Effect attaches to the
518+
outermost arrow (the position of the slash); inner-arrow
519+
attachment is still expressible via `-{E}->`. */
520+
| arg = type_expr_primary ARROW ret = type_expr_arrow SLASH LBRACE effs = separated_nonempty_list(COMMA, effect_term) RBRACE
521+
{ TyArrow (arg, None, ret, Some (effect_union_of_list effs)) }
522+
| arg = type_expr_primary ARROW ret = type_expr_arrow SLASH single = effect_term
523+
{ TyArrow (arg, None, ret, Some single) }
508524
/* `(A, B, ...) -> R` lowers to the curried arrow `A -> B -> ... -> R`
509525
so user source can write multi-arg fn types without manual currying.
510526
The existing tuple-as-type rule (LPAREN ty COMMA tys RPAREN) still
@@ -566,6 +582,39 @@ type_expr_primary:
566582
{ match params with
567583
| [] -> TyArrow (TyTuple [], None, ret, None)
568584
| _ -> List.fold_right (fun p acc -> TyArrow (p, None, acc, None)) params ret }
585+
/* #547: slash-effect-row form for fn-type, single + braced. Mirrors
586+
the `MINUS LBRACE ... ARROW` variant immediately below it. The
587+
effect attaches to the innermost arrow (the one whose result is
588+
ret) to match the existing prefix-row semantics. */
589+
| FN LPAREN params = separated_list(COMMA, type_expr) RPAREN ARROW
590+
ret = type_expr_arrow SLASH LBRACE effs = separated_nonempty_list(COMMA, effect_term) RBRACE
591+
{ let eff = effect_union_of_list effs in
592+
match params with
593+
| [] -> TyArrow (TyTuple [], None, ret, Some eff)
594+
| _ ->
595+
let rev_params = List.rev params in
596+
(match rev_params with
597+
| [] -> assert false
598+
| last :: earlier_rev ->
599+
let innermost = TyArrow (last, None, ret, Some eff) in
600+
List.fold_left
601+
(fun acc p -> TyArrow (p, None, acc, None))
602+
innermost
603+
earlier_rev) }
604+
| FN LPAREN params = separated_list(COMMA, type_expr) RPAREN ARROW
605+
ret = type_expr_arrow SLASH single = effect_term
606+
{ match params with
607+
| [] -> TyArrow (TyTuple [], None, ret, Some single)
608+
| _ ->
609+
let rev_params = List.rev params in
610+
(match rev_params with
611+
| [] -> assert false
612+
| last :: earlier_rev ->
613+
let innermost = TyArrow (last, None, ret, Some single) in
614+
List.fold_left
615+
(fun acc p -> TyArrow (p, None, acc, None))
616+
innermost
617+
earlier_rev) }
569618
/* Effect-row variant: `fn(A, B) -{E}-> C`. Mirrors the prefix-row arrow
570619
already accepted in `type_expr_arrow` and in `return_type`, and is
571620
required by hand-ports such as `fn(Http::Request) -{IO}-> Http::Response`

test/test_slash_effect_row.ml

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
(* SPDX-License-Identifier: MPL-2.0 *)
2+
(* Copyright (c) 2026 Jonathan D.A. Jewell <jonathan.jewell@open.ac.uk> *)
3+
4+
(** #547 — slash-effect-row `fn(...) -> T / { E }` in type-expression
5+
position.
6+
7+
Until this PR the `fn(...) -> T / { E }` syntax accepted in fn-decl
8+
return-type position parse-errored in record-field and type-alias
9+
positions, forcing every effectful function-typed record field to
10+
fall back to the older `-{E}->` arrow form. This test module pins
11+
down the parser slice that closes the asymmetry. *)
12+
13+
open Affinescript
14+
15+
let parse_ok (src : string) : (unit, string) result =
16+
try
17+
let _prog = Parse_driver.parse_string ~file:"<test_slash_effect_row>" src in
18+
Ok ()
19+
with
20+
| Parse_driver.Parse_error (m, sp) ->
21+
Error (Printf.sprintf "Parse error at %s: %s" (Span.show sp) m)
22+
| e -> Error (Printf.sprintf "Unexpected: %s" (Printexc.to_string e))
23+
24+
(* Slash-row in a type alias — the headline case from the issue body. *)
25+
let alias_braced_single_effect () =
26+
let src = "pub type AsyncCall = fn(Int) -> Int / { Async };\n" in
27+
match parse_ok src with
28+
| Ok () -> ()
29+
| Error m -> Alcotest.fail ("expected ok, got: " ^ m)
30+
31+
(* Slash-row in a record field — the load-bearing case (effectful
32+
handler stored in a record). *)
33+
let record_field_braced_effect () =
34+
let src = "pub type H = { call: fn(Int) -> Int / { Async } }\n" in
35+
match parse_ok src with
36+
| Ok () -> ()
37+
| Error m -> Alcotest.fail ("expected ok, got: " ^ m)
38+
39+
(* Single-effect short form (no braces). *)
40+
let record_field_single_effect_short () =
41+
let src = "pub type H = { call: fn(Int) -> Int / Async }\n" in
42+
match parse_ok src with
43+
| Ok () -> ()
44+
| Error m -> Alcotest.fail ("expected ok, got: " ^ m)
45+
46+
(* Multi-effect braced row — Frontier Guide `/{IO, Async}` shape. *)
47+
let multi_effect_row () =
48+
let src = "pub type Multi = fn(Int, String) -> Bool / { IO, Async };\n" in
49+
match parse_ok src with
50+
| Ok () -> ()
51+
| Error m -> Alcotest.fail ("expected ok, got: " ^ m)
52+
53+
(* Arrow-as-type form (no `fn(...)` wrapper) with slash row. *)
54+
let arrow_type_with_braced_row () =
55+
let src = "pub type R = Int -> Int / { Mut };\n" in
56+
match parse_ok src with
57+
| Ok () -> ()
58+
| Error m -> Alcotest.fail ("expected ok, got: " ^ m)
59+
60+
(* Zero-arg fn with effect row. *)
61+
let zero_arg_fn_with_effect () =
62+
let src = "pub type R = fn() -> () / { IO };\n" in
63+
match parse_ok src with
64+
| Ok () -> ()
65+
| Error m -> Alcotest.fail ("expected ok, got: " ^ m)
66+
67+
(* Regression: existing `-{E}->` arrow form still parses. *)
68+
let arrow_form_still_works () =
69+
let src = "pub type H = { call: Int -{Async}-> Int }\n" in
70+
match parse_ok src with
71+
| Ok () -> ()
72+
| Error m -> Alcotest.fail ("expected ok, got: " ^ m)
73+
74+
(* Regression: plain (no effect) fn type still parses. *)
75+
let plain_fn_type_still_works () =
76+
let src = "pub type H = { call: fn(Int) -> Int }\n" in
77+
match parse_ok src with
78+
| Ok () -> ()
79+
| Error m -> Alcotest.fail ("expected ok, got: " ^ m)
80+
81+
let tests = [
82+
Alcotest.test_case "type alias with `/{Effect}` braced single" `Quick alias_braced_single_effect;
83+
Alcotest.test_case "record field with `/{Effect}` braced" `Quick record_field_braced_effect;
84+
Alcotest.test_case "record field with `/Effect` single short" `Quick record_field_single_effect_short;
85+
Alcotest.test_case "multi-effect `/{IO, Async}`" `Quick multi_effect_row;
86+
Alcotest.test_case "arrow-as-type with braced row" `Quick arrow_type_with_braced_row;
87+
Alcotest.test_case "zero-arg fn with effect" `Quick zero_arg_fn_with_effect;
88+
Alcotest.test_case "regression: `-{E}->` arrow form" `Quick arrow_form_still_works;
89+
Alcotest.test_case "regression: plain fn type" `Quick plain_fn_type_still_works;
90+
]

0 commit comments

Comments
 (0)