diff --git a/CHANGELOG.md b/CHANGELOG.md index 00e999c..539be27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- feat(res-to-affine): partial-port mode #488 slice 2 — `--partial` now desugars ReScript pipe-first `->` (`a->f(b)` → `f(a, b)`, chained left-to-right), and translates `if`/`else` and blocks with `let` statements (Refs #488) - feat(res-to-affine): partial-port mode (#488) — new `--partial` flag renders module-top-level functions as AffineScript `fn` skeletons with `switch`→`match` and best-effort expression translation (literals / idents / calls / binary ops with float-op + identity-equality normalisation / `++` / member + qualified access / ternary / variant + tuple + literal patterns); un-translatable forms become `() /* TODO */` / `_ /* TODO */` holes. Output deliberately does NOT type-check but parses (verified). Distinct model from `--translate` (Refs #488) - feat(res-to-affine): Phase 3 slice 3 — `--translate` now also lowers module-level `let = ` (int/float/string/bool) to a typed `const name: T = value;`; call / `ref(...)` / destructuring bindings are skipped (not compile-time constants); every emitted form verified compilable via `main.exe check`. `switch`→`match` and qualified-path resolution remain out of the standalone-type-check scope (Refs #57) - feat(res-to-affine): Phase 3 slice 2 — `--translate` now also handles record types (→ `struct`) and generics (type params `'a` → `[A]`) across aliases / sums / records; `mutable`/optional-`?` records, qualified paths, and nested generics are still skipped (never guessed); every emitted form verified compilable via `main.exe check` (Refs #57) diff --git a/tools/res-to-affine/README.md b/tools/res-to-affine/README.md index 19ba2a2..a23e990 100644 --- a/tools/res-to-affine/README.md +++ b/tools/res-to-affine/README.md @@ -222,16 +222,22 @@ AffineScript `fn` skeleton: | `let area = (w, h) => w *. h` | `fn area(w: _, h: _) -> _ { w * h }` | | `let classify = x => switch x { \| Some(n) => n + 1 \| None => 0 }` | `fn classify(x: _) -> _ { match x { Some(n) => n + 1, None => 0, } }` | | `let greet = name => "hi " ++ name` | `fn greet(name: _) -> _ { "hi " ++ name }` | +| `let piped = x => x->doStuff(1)` | `fn piped(x: _) -> _ { doStuff(x, 1) }` | +| `let chain = x => x->f->g(2)` | `fn chain(x: _) -> _ { g(f(x), 2) }` | +| `let clamp = x => if x > 0 { x } else { 0 }` | `fn clamp(x: _) -> _ { if x > 0 { x } else { 0 } }` | +| `let scaled = x => { let y = x + 1; y * 2 }` | `fn scaled(x: _) -> _ { let y = x + 1; y * 2 }` | It translates literals, identifiers, calls, binary operators (normalising ReScript's float ops `+.`/`*.` → `+`/`*` and `===`/`!==` → `==`/`!=`), string -concat `++`, member/qualified access, ternaries, and `switch`→`match` with -variant/tuple/literal patterns. Anything else (pipe-first `->`, records, etc.) -becomes a `() /* TODO */` hole. The output is a partial port to finish by -hand: it **parses** but is not expected to type-check (verified — the -generated skeletons reach resolution/type-checking without a parse error). -First slice; the next steps (pipe desugaring `a->f(b)` → `f(a, b)`, `if`/block -bodies, combining with `--translate`) continue under #488. +concat `++`, member/qualified access, ternaries, **`if`/`else`**, **blocks +with `let` statements**, **pipe-first `->`** (`a->f(b)` → `f(a, b)`, chained +left-to-right), and `switch`→`match` with variant/tuple/literal patterns. +Anything else (records, arrays, objects, …) becomes a `() /* TODO */` hole. +The output is a partial port to finish by hand: it **parses** but is not +expected to type-check (verified — the generated skeletons reach +resolution/type-checking without a parse error). Continuing under #488: +record/array/object literals, labelled args, combining `--partial` with +`--translate`, and module-qualified-reference *resolution*. ## Corpus run diff --git a/tools/res-to-affine/test/fixtures/partial1.res b/tools/res-to-affine/test/fixtures/partial1.res index 8c552b2..33463d7 100644 --- a/tools/res-to-affine/test/fixtures/partial1.res +++ b/tools/res-to-affine/test/fixtures/partial1.res @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT // #488 partial-port fixture: module-top-level functions -> `fn` skeletons -// with switch->match and best-effort expression translation. Output is NOT -// expected to type-check; it must parse, with un-translatable forms as -// `() /* TODO */` holes. +// with switch->match, pipe desugaring, if/else, blocks, and best-effort +// expression translation. Output is NOT expected to type-check; it must +// parse, with un-translatable forms (e.g. an array literal) as TODO holes. let classify = x => switch x { | Some(n) => n + 1 @@ -15,5 +15,17 @@ let greet = name => "hi " ++ name let log2 = msg => Js.log(msg) -// pipe-first has no AffineScript equivalent -> must become a TODO hole. +// pipe-first desugars: x->doStuff(1) -> doStuff(x, 1) let piped = x => x->doStuff(1) + +// pipe chain desugars left-to-right: x->f->g(2) -> g(f(x), 2) +let chain = x => x->f->g(2) + +// if/else +let clamp = x => if x > 0 { x } else { 0 } + +// block with a let statement +let scaled = x => { let y = x + 1; y * 2 } + +// array literal has no handler yet -> must become a TODO hole. +let pair = x => [x, x] diff --git a/tools/res-to-affine/test/test_walker.ml b/tools/res-to-affine/test/test_walker.ml index d727be4..50b8f41 100644 --- a/tools/res-to-affine/test/test_walker.ml +++ b/tools/res-to-affine/test/test_walker.ml @@ -401,8 +401,26 @@ let partial1_blob () = let test_partial_count () = skip_unless_ready (); Alcotest.(check int) - "five module-top-level functions -> fn skeletons" - 5 (List.length (translate_partial1 ())) + "nine module-top-level functions -> fn skeletons" + 9 (List.length (translate_partial1 ())) + +let test_partial_pipe () = + skip_unless_ready (); + let blob = partial1_blob () in + (* x->doStuff(1) -> doStuff(x, 1); chain x->f->g(2) -> g(f(x), 2) *) + Alcotest.(check bool) "pipe-first desugars, including left-nested chains" + true (contains blob "doStuff(x, 1)" && contains blob "g(f(x), 2)") + +let test_partial_if () = + skip_unless_ready (); + Alcotest.(check bool) "if/else translated" + true (contains (partial1_blob ()) "if x > 0 { x } else { 0 }") + +let test_partial_block () = + skip_unless_ready (); + let blob = partial1_blob () in + Alcotest.(check bool) "block body with a let statement translated" + true (contains blob "let y = x + 1" && contains blob "y * 2") let test_partial_switch_to_match () = skip_unless_ready (); @@ -503,7 +521,7 @@ let () = ] ); ( "walker-488-partial", [ - Alcotest.test_case "five functions -> fn skeletons" + Alcotest.test_case "nine functions -> fn skeletons" `Quick test_partial_count; Alcotest.test_case "switch -> match + patterns + arm bodies" `Quick test_partial_switch_to_match; @@ -513,5 +531,11 @@ let () = `Quick test_partial_concat_and_call; Alcotest.test_case "untranslatable form -> TODO hole" `Quick test_partial_todo_hole; + Alcotest.test_case "pipe-first desugars (incl. chains)" + `Quick test_partial_pipe; + Alcotest.test_case "if/else translated" + `Quick test_partial_if; + Alcotest.test_case "block with let statement translated" + `Quick test_partial_block; ] ); ] diff --git a/tools/res-to-affine/walker.ml b/tools/res-to-affine/walker.ml index 0f644b3..d79058c 100644 --- a/tools/res-to-affine/walker.ml +++ b/tools/res-to-affine/walker.ml @@ -994,13 +994,14 @@ let rec translate_expr ~source n = match List.filter (fun c -> c.ntype <> "comment") n.children with | [ inner ] -> Printf.sprintf "(%s)" (translate_expr ~source inner) | _ -> todo_expr ~source n) - | "sequence_expression" | "block" -> ( + | "pipe_expression" -> translate_pipe ~source n + | "if_expression" -> translate_if ~source n + | "block" -> translate_block ~source n + | "sequence_expression" -> ( match List.filter (fun c -> c.ntype <> "comment") n.children with | [] -> "()" | [ single ] -> translate_expr ~source single - | many -> - Printf.sprintf "{ %s }" - (String.concat "; " (List.map (translate_expr ~source) many))) + | _ -> translate_block ~source n) | "ternary_expression" -> ( match ( child_with_field "condition" n, @@ -1089,6 +1090,91 @@ and translate_switch ~source sw = in Printf.sprintf "match %s {\n%s\n}" scrutinee (String.concat "\n" arms) +(* ReScript pipe-first: `a -> f(b)` desugars to `f(a, b)`, `a -> f` to `f(a)`. + Pipes are left-nested so chains fall out of the recursion on [left]. *) +and translate_pipe ~source n = + match List.filter (fun c -> c.ntype <> "comment") n.children with + | [ left; right ] -> ( + let lhs = translate_expr ~source left in + match right.ntype with + | "call_expression" -> + let fn = + match child_with_field "function" right with + | Some f -> translate_expr ~source f + | None -> "todo_fn" + in + let rest = + match child_with_field "arguments" right with + | None -> [] + | Some a -> + List.filter_map + (fun c -> + match c.ntype with + | "type_annotation" -> None + | "labeled_argument" -> + Some (translate_labeled_arg ~source c) + | _ -> Some (translate_expr ~source c)) + a.children + in + Printf.sprintf "%s(%s)" fn (String.concat ", " (lhs :: rest)) + | _ -> Printf.sprintf "%s(%s)" (translate_expr ~source right) lhs) + | _ -> todo_expr ~source n + +and translate_if ~source n = + (* positional children: condition, then-block, optional else_clause. *) + match List.filter (fun c -> c.ntype <> "comment") n.children with + | cond :: then_blk :: rest -> + let else_part = + match rest with + | ec :: _ when ec.ntype = "else_clause" -> ( + match List.filter (fun c -> c.ntype <> "comment") ec.children with + | [ b ] -> " else " ^ translate_as_block ~source b + | _ -> "") + | _ -> "" + in + Printf.sprintf "if %s %s%s" (translate_expr ~source cond) + (translate_as_block ~source then_blk) else_part + | _ -> todo_expr ~source n + +(* Render a node as an AffineScript braced block (if/else branches require it). *) +and translate_as_block ~source n = + match n.ntype with + | "block" -> translate_block ~source n + | "if_expression" -> translate_if ~source n (* else-if chain *) + | _ -> Printf.sprintf "{ %s }" (translate_expr ~source n) + +and translate_block ~source n = + Printf.sprintf "{ %s }" (translate_block_inner ~source n) + +(* The statements of a block, `;`-joined, WITHOUT the surrounding braces (so a + function body can place them directly inside the `fn { … }`). *) +and translate_block_inner ~source n = + String.concat "; " + (List.filter_map + (fun c -> + match c.ntype with + | "comment" -> None + | "let_declaration" -> Some (translate_block_let ~source c) + | _ -> Some (translate_expr ~source c)) + n.children) + +and translate_block_let ~source ld = + let one lb = + let name = + match child_with_field "pattern" lb with + | Some p -> translate_pattern ~source p + | None -> "_" + in + let v = + match child_with_field "body" lb with + | Some b -> translate_expr ~source b + | None -> "()" + in + Printf.sprintf "let %s = %s" name v + in + String.concat "; " + (List.map one (List.filter (fun c -> c.ntype = "let_binding") ld.children)) + let translate_param ~source p = match p.ntype with | "value_identifier" -> node_text ~source p ^ ": _" @@ -1119,6 +1205,8 @@ let partial_function ~source ~name fn = in let body = match child_with_field "body" fn with + (* a block body's statements go directly inside the fn braces (no nesting) *) + | Some b when b.ntype = "block" -> translate_block_inner ~source b | Some b -> translate_expr ~source b | None -> "()" in