From 3ccb4d8e6a2b43e4a7e60f04a21a4bbb64191da5 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 18 May 2026 17:32:22 +0100 Subject: [PATCH 1/2] =?UTF-8?q?wip(#218):=20Rust-like=20record=20syntax=20?= =?UTF-8?q?=E2=80=94=20grammar=20+=20token=20bridges=20+=206=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ATOMIC PR IN PROGRESS — DO NOT MERGE (15/257 still failing). Contains: - Grammar: bare { = block, records #{ } (token.ml/lexer.ml/parser.mly, from stage-c/pc-brace-disambig). Conflicts 72->68 S/R, 10->7 R/R. - Token-bridge completeness: added HASH_LBRACE arm to BOTH lib/parse.ml AND lib/parse_driver.ml (same non-exhaustive-match class as #219 EXTERN — warning-8 demoted, Match_failure at runtime; these were the ONLY two Token->Parser bridges). - Migrated 6 regression .affine fixtures (expression-position record literals {..}->#{..}; type-position records unchanged). Progress: 21 -> 15 failures. Remaining 15 are inline AffineScript source embedded as string literals in the OCaml test suite (E2E TEA counter/titlescreen, LSP Phase B hover, full_pipeline, AOT test 17, etc.) — those test inputs still use old record syntax and need #{ migration in the test .ml fixtures, not .affine files. Refs #218 Co-Authored-By: Claude Opus 4.7 (1M context) --- conformance/valid/011_rows.affine | 2 +- examples/rows.affine | 2 +- examples/typecheck_complete_test.affine | 4 ++-- lib/lexer.ml | 3 +++ lib/parse.ml | 1 + lib/parse_driver.ml | 1 + lib/parser.mly | 21 ++++++++++++--------- lib/token.ml | 2 ++ test/e2e/fixtures/row_polymorphism.affine | 2 +- test/e2e/fixtures/type_decls.affine | 4 ++-- test/golden/rows.affine | 2 +- 11 files changed, 27 insertions(+), 17 deletions(-) diff --git a/conformance/valid/011_rows.affine b/conformance/valid/011_rows.affine index e4904db..322af7d 100644 --- a/conformance/valid/011_rows.affine +++ b/conformance/valid/011_rows.affine @@ -9,7 +9,7 @@ fn get_name[..r](entity: {name: String, ..r}) -> String { } fn with_id[..r](record: {..r}, id: Int) -> {id: Int, ..r} { - { id: id, ..record } + #{ id: id, ..record } } type HasPosition[..r] = {x: Int, y: Int, ..r} diff --git a/examples/rows.affine b/examples/rows.affine index 54ac370..2f76f3d 100644 --- a/examples/rows.affine +++ b/examples/rows.affine @@ -15,5 +15,5 @@ struct Point3D { fn getX(p: Point2D) -> Int = p.x; fn mk_point(x: Int, y: Int) -> Point2D { - { x: x, y: y } + #{ x: x, y: y } } diff --git a/examples/typecheck_complete_test.affine b/examples/typecheck_complete_test.affine index 2b16316..7e14109 100644 --- a/examples/typecheck_complete_test.affine +++ b/examples/typecheck_complete_test.affine @@ -15,11 +15,11 @@ enum Color { } fn make_point(x: Int, y: Int) -> Point { - { x: x, y: y } + #{ x: x, y: y } } fn origin() -> Point { - { x: 0, y: 0 } + #{ x: 0, y: 0 } } fn mutate_counter() -> Int { diff --git a/lib/lexer.ml b/lib/lexer.ml index cc4f120..654aa8d 100644 --- a/lib/lexer.ml +++ b/lib/lexer.ml @@ -165,6 +165,9 @@ let rec token state buf = | "->" -> ARROW | "=>" -> FAT_ARROW | "::" -> COLONCOLON + (* Record-literal opener (affinescript#215): `#{` is the unambiguous + record/struct-literal sigil; bare `{` is always a block. *) + | "#{" -> HASH_LBRACE (* Row variable "..name" — must come before ".." so sedlex prefers the longer match *) | "..", lower_ident -> let s = Sedlexing.Utf8.lexeme buf in diff --git a/lib/parse.ml b/lib/parse.ml index f327389..9ecd93b 100644 --- a/lib/parse.ml +++ b/lib/parse.ml @@ -105,6 +105,7 @@ let next_token state () = | Token.LPAREN -> Parser.LPAREN | Token.RPAREN -> Parser.RPAREN | Token.LBRACE -> Parser.LBRACE + | Token.HASH_LBRACE -> Parser.HASH_LBRACE | Token.RBRACE -> Parser.RBRACE | Token.LBRACKET -> Parser.LBRACKET | Token.RBRACKET -> Parser.RBRACKET diff --git a/lib/parse_driver.ml b/lib/parse_driver.ml index 806d35b..7cad124 100644 --- a/lib/parse_driver.ml +++ b/lib/parse_driver.ml @@ -76,6 +76,7 @@ let to_menhir_token (tok : Token.t) : Parser.token = | Token.LPAREN -> Parser.LPAREN | Token.RPAREN -> Parser.RPAREN | Token.LBRACE -> Parser.LBRACE + | Token.HASH_LBRACE -> Parser.HASH_LBRACE | Token.RBRACE -> Parser.RBRACE | Token.LBRACKET -> Parser.LBRACKET | Token.RBRACKET -> Parser.RBRACKET diff --git a/lib/parser.mly b/lib/parser.mly index f020126..5017755 100644 --- a/lib/parser.mly +++ b/lib/parser.mly @@ -66,6 +66,7 @@ let rec effect_union_of_list = function /* Punctuation */ %token LPAREN RPAREN LBRACE RBRACE LBRACKET RBRACKET +%token HASH_LBRACE /* `#{` record-literal opener (affinescript#215) */ %token COMMA SEMICOLON COLON COLONCOLON DOT DOTDOT %token ARROW FAT_ARROW PIPE AT UNDERSCORE BACKSLASH QUESTION @@ -768,10 +769,11 @@ expr_primary: ordinary parameter binding named "self". */ | SELF_KW { ExprVar (mk_ident "self" $startpos $endpos) } | name = lower_ident { ExprVar (mk_ident name $startpos $endpos) } - /* Struct literal: `Point { x: v, y: w }`. Must come before the plain - upper_ident production so Menhir shifts LBRACE rather than reducing - upper_ident to ExprVar when the next token is LBRACE. */ - | _ty = upper_ident LBRACE b = expr_record_body RBRACE + /* Struct literal: `Point #{ x: v, y: w }` (affinescript#215). The `#{` + sigil makes this unambiguous against a bare block and removes the + Rust-style struct-literal-in-`if`/`match`-scrutinee hazard entirely; + no production-ordering hack needed any more. */ + | _ty = upper_ident HASH_LBRACE b = expr_record_body RBRACE { ExprRecord { er_fields = fst b; er_spread = snd b } } | name = upper_ident { ExprVar (mk_ident name $startpos $endpos) } | ty = upper_ident COLONCOLON variant = upper_ident @@ -787,11 +789,12 @@ expr_primary: /* Arrays */ | LBRACKET es = separated_list(COMMA, expr) RBRACKET { ExprArray es } - /* Records — use a recursive rule (expr_record_body / expr_record_rest) to - avoid the LALR(1) greedy-separator conflict that arises when a ROW_VAR - spread like `..record` follows a COMMA that `separated_list` has already - consumed expecting another record_field. */ - | LBRACE b = expr_record_body RBRACE + /* Anonymous record `#{ f: v, ..spread }` (affinescript#215). The `#{` + sigil removes the entire block-vs-record-literal ambiguity (family + C+D) by construction — bare `{` is now unconditionally a block. + expr_record_body / expr_record_rest stay recursive to avoid the + ROW_VAR greedy-separator conflict on `..spread` after a COMMA. */ + | HASH_LBRACE b = expr_record_body RBRACE { ExprRecord { er_fields = fst b; er_spread = snd b } } /* Block */ diff --git a/lib/token.ml b/lib/token.ml index 993e69b..a8b71f2 100644 --- a/lib/token.ml +++ b/lib/token.ml @@ -73,6 +73,7 @@ type t = | RPAREN | LBRACE | RBRACE + | HASH_LBRACE (** #{ — record-literal opener (affinescript#215) *) | LBRACKET | RBRACKET | COMMA @@ -187,6 +188,7 @@ let to_string = function | RPAREN -> ")" | LBRACE -> "{" | RBRACE -> "}" + | HASH_LBRACE -> "#{" | LBRACKET -> "[" | RBRACKET -> "]" | COMMA -> "," diff --git a/test/e2e/fixtures/row_polymorphism.affine b/test/e2e/fixtures/row_polymorphism.affine index 6a2b2bf..c84209e 100644 --- a/test/e2e/fixtures/row_polymorphism.affine +++ b/test/e2e/fixtures/row_polymorphism.affine @@ -16,5 +16,5 @@ struct Point3D { fn getX(p: Point2D) -> Int = p.x; fn mk_point(x: Int, y: Int) -> Point2D { - { x: x, y: y } + #{ x: x, y: y } } diff --git a/test/e2e/fixtures/type_decls.affine b/test/e2e/fixtures/type_decls.affine index 4f65ed9..6c8e1a9 100644 --- a/test/e2e/fixtures/type_decls.affine +++ b/test/e2e/fixtures/type_decls.affine @@ -27,9 +27,9 @@ enum Result[T, E] { } fn make_point(x: Int, y: Int) -> Point { - { x: x, y: y } + #{ x: x, y: y } } fn origin() -> Point { - { x: 0, y: 0 } + #{ x: 0, y: 0 } } diff --git a/test/golden/rows.affine b/test/golden/rows.affine index bacbcab..f22574f 100644 --- a/test/golden/rows.affine +++ b/test/golden/rows.affine @@ -4,5 +4,5 @@ fn getX[..r](p: {x: Int, ..r}) -> Int { } fn addY[..r](p: {..r}) -> {y: Int, ..r} { - {y: 0, ..p} + #{y: 0, ..p} } From e8bb327711afa90f2fcdfb076c9e5e69cb398e01 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 18 May 2026 17:45:50 +0100 Subject: [PATCH 2/2] =?UTF-8?q?feat(#218)!:=20complete=20Rust-like=20recor?= =?UTF-8?q?d=20migration=20=E2=80=94=20257/257=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrated the remaining expression-position record literals to #{ } (compiler-as-oracle, iterated to green): - test/e2e/fixtures/counter.affine (tea_run arg record) - test/e2e/fixtures/full_pipeline.affine (fn-return + Rect(#{}) arg) - test/e2e/fixtures/titlescreen.affine (title_init body + 4 match-arm records + tea_run arg) - examples/comprehensive_test.affine (fn-return + Rect(#{}) arg) - stdlib/testing.affine (benchmark-result record — the ONE stdlib record literal; earlier survey missed it, parse-gated) Type-position records, struct/type declarations, blocks, and match-arm blocks left unchanged (different grammar rules — verified). Full dune gate now GREEN: 257/257, 0 failures. Conflicts 72->68 S/R, 10->7 R/R (residual families tracked on #215). Breaking syntax change — review-gated, do not auto-merge. Refs #218 #215 Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/comprehensive_test.affine | 4 ++-- stdlib/testing.affine | 2 +- test/e2e/fixtures/counter.affine | 2 +- test/e2e/fixtures/full_pipeline.affine | 4 ++-- test/e2e/fixtures/titlescreen.affine | 12 ++++++------ 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/examples/comprehensive_test.affine b/examples/comprehensive_test.affine index 99556c8..80448d0 100644 --- a/examples/comprehensive_test.affine +++ b/examples/comprehensive_test.affine @@ -12,7 +12,7 @@ enum Shape { } fn vec2_add(a: Vec2, b: Vec2) -> Vec2 { - { x: a.x + b.x, y: a.y + b.y } + #{ x: a.x + b.x, y: a.y + b.y } } fn area(s: Shape) -> Int { @@ -30,7 +30,7 @@ fn larger_area(s1: Shape, s2: Shape) -> Int = max(area(s1), area(s2)); fn main() -> () { let circle = Circle(5); - let rect = Rect({ x: 3, y: 4 }); + let rect = Rect(#{ x: 3, y: 4 }); let result = larger_area(circle, rect); (); } diff --git a/stdlib/testing.affine b/stdlib/testing.affine index 30b8b5e..4621cb9 100644 --- a/stdlib/testing.affine +++ b/stdlib/testing.affine @@ -303,7 +303,7 @@ fn bench(f: () -> (), iterations: Int) -> BenchResult { } let tot = time_now() - start; - { + #{ iterations: iterations, total_time: tot, avg_time: tot / float(iterations) diff --git a/test/e2e/fixtures/counter.affine b/test/e2e/fixtures/counter.affine index e6f7219..26a3160 100644 --- a/test/e2e/fixtures/counter.affine +++ b/test/e2e/fixtures/counter.affine @@ -68,7 +68,7 @@ fn counter_subs(model: Int) -> String { // Wire up the TEA runtime and hand off to the interpreter loop. fn main() -> () { - tea_run({ + tea_run(#{ init: counter_init, update: counter_update, view: counter_view, diff --git a/test/e2e/fixtures/full_pipeline.affine b/test/e2e/fixtures/full_pipeline.affine index 118608b..592772a 100644 --- a/test/e2e/fixtures/full_pipeline.affine +++ b/test/e2e/fixtures/full_pipeline.affine @@ -13,7 +13,7 @@ enum Shape { } fn vec2_add(a: Vec2, b: Vec2) -> Vec2 { - { x: a.x + b.x, y: a.y + b.y } + #{ x: a.x + b.x, y: a.y + b.y } } fn area(s: Shape) -> Int { @@ -31,7 +31,7 @@ fn larger_area(s1: Shape, s2: Shape) -> Int = max(area(s1), area(s2)); fn main() -> () { let circle = Circle(5); - let rect = Rect({ x: 3, y: 4 }); + let rect = Rect(#{ x: 3, y: 4 }); let result = larger_area(circle, rect); (); } diff --git a/test/e2e/fixtures/titlescreen.affine b/test/e2e/fixtures/titlescreen.affine index 66483fc..873a6e3 100644 --- a/test/e2e/fixtures/titlescreen.affine +++ b/test/e2e/fixtures/titlescreen.affine @@ -44,7 +44,7 @@ struct TitleModel { // ── init ───────────────────────────────────────────────────────────────────── fn title_init() -> TitleModel { - { + #{ screen_w: 1280, screen_h: 720, bgm_playing: 0, @@ -58,10 +58,10 @@ fn title_init() -> TitleModel { fn title_update(msg: TitleMsg, model: TitleModel) -> TitleModel { match msg { - NewGame => { screen_w: model.screen_w, screen_h: model.screen_h, bgm_playing: model.bgm_playing, selected: "new_game" }, - LoadGame => { screen_w: model.screen_w, screen_h: model.screen_h, bgm_playing: model.bgm_playing, selected: "load_game" }, - Settings => { screen_w: model.screen_w, screen_h: model.screen_h, bgm_playing: model.bgm_playing, selected: "settings" }, - Credits => { screen_w: model.screen_w, screen_h: model.screen_h, bgm_playing: model.bgm_playing, selected: "credits" } + NewGame => #{ screen_w: model.screen_w, screen_h: model.screen_h, bgm_playing: model.bgm_playing, selected: "new_game" }, + LoadGame => #{ screen_w: model.screen_w, screen_h: model.screen_h, bgm_playing: model.bgm_playing, selected: "load_game" }, + Settings => #{ screen_w: model.screen_w, screen_h: model.screen_h, bgm_playing: model.bgm_playing, selected: "settings" }, + Credits => #{ screen_w: model.screen_w, screen_h: model.screen_h, bgm_playing: model.bgm_playing, selected: "credits" } } } @@ -84,7 +84,7 @@ fn title_subs(model: TitleModel) -> String { // ── main ────────────────────────────────────────────────────────────────────── fn main() -> () { - tea_run({ + tea_run(#{ init: title_init, update: title_update, view: title_view,