diff --git a/CHANGELOG.md b/CHANGELOG.md index bfd6568..00e999c 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) — 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) - feat(res-to-affine): Phase 3 slice 1 — `--translate` renders fully-structural type declarations (primitive aliases + simple sum types) into compilable AffineScript; conservative (generics / qualified paths / records / non-primitive payloads are skipped, never guessed); walker-only (Refs #57) diff --git a/tools/res-to-affine/README.md b/tools/res-to-affine/README.md index e9aae1c..19ba2a2 100644 --- a/tools/res-to-affine/README.md +++ b/tools/res-to-affine/README.md @@ -24,10 +24,15 @@ dune exec tools/res-to-affine/main.exe -- path/to/Foo.res # or write to a file dune exec tools/res-to-affine/main.exe -- path/to/Foo.res -o Foo.affine -# Phase 3 (slice 1): also translate fully-structural type declarations -# (primitive aliases + simple sum types) into compilable AffineScript +# --translate: render self-contained top-level declarations (type aliases, +# sums, structs, generics, literal `let`->`const`) as compilable AffineScript dune exec tools/res-to-affine/main.exe -- --translate path/to/Foo.res +# --partial (#488): render module-top-level functions as `fn` skeletons with +# switch->match + best-effort bodies. Output is a partial port that does NOT +# type-check (un-inferable types/exprs become `_` / `() /* TODO */` holes). +dune exec tools/res-to-affine/main.exe -- --partial path/to/Foo.res + # opt back into the Phase-1 line-regex scanner (no grammar required) dune exec tools/res-to-affine/main.exe -- --engine=scanner path/to/Foo.res ``` @@ -186,23 +191,47 @@ names are capitalised (`color` → `Color`) and type variables are mapped position as a type *variable*, not a constructor. Translation is walker-only (it needs the AST); with `--engine=scanner` the flag is a no-op. -**Scope boundary — what `--translate` will *not* do.** The guarantee is -"every emitted form type-checks standalone", which is why translation is -limited to self-contained top-level *declarations* (types, structs, -literal consts). Two forms are out of that scope by construction: - -- **`switch`→`match`** is an *expression*, only meaningful inside a - function body. Emitting a type-checkable `match` means translating the - whole enclosing function — but ReScript function bindings are usually - un-annotated (`let f = x => …`), and AffineScript `fn` requires parameter - and return types, so the result wouldn't type-check. It belongs to a - future *partial-port* mode (translate-with-TODO-holes) that drops the - standalone-type-check guarantee, not to this declaration translator. -- **module-qualified references** now *parse* (the +**Scope boundary.** `--translate` keeps the "every emitted form type-checks +standalone" guarantee, which is why it is limited to self-contained top-level +*declarations* (types, structs, literal consts). Forms that can't meet that +guarantee live in a separate mode or remain deferred: + +- **`switch`→`match` + function bodies** — landed under **`--partial`** + ([#488](https://github.com/hyperpolymath/affinescript/issues/488)), a + distinct partial-port model. A `match` is an *expression*, only meaningful + inside a function, and ReScript bindings are usually un-annotated + (`let f = x => …`) while AffineScript `fn` requires param/return types — so + `--partial` emits a `fn` skeleton with `_` type holes + `switch`→`match` + + best-effort expression translation, and its output **deliberately does not + type-check**. Un-translatable expressions/patterns become `() /* TODO */` / + `_ /* TODO */` islands; the result still *parses*. See the `--partial` + section below. +- **module-qualified references** in *type* position now *parse* (the [#228](https://github.com/hyperpolymath/affinescript/issues/228) grammar gap closed), but a faithful `Belt.Map.t` → `Belt::Map::T` would not *resolve* against a target module that doesn't exist yet — it waits for a - module-mapping story. + module-mapping story (tracked in #488). + +### `--partial` — partial-port mode (#488, landed) + +Renders each module-top-level function `let f = (params) => body` into an +AffineScript `fn` skeleton: + +| ReScript | AffineScript (`--partial`) | +|---|---| +| `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 }` | + +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. ## Corpus run @@ -230,10 +259,12 @@ The fixture under `test/fixtures/sample.res` is synthetic and exercises every Phase-1 anti-pattern; `test/fixtures/phase2c.res` exercises the two anti-patterns that are walker-only by construction (`inline-callback-record`, `oversized-function`); `test/fixtures/phase3.res`, -`phase3b.res`, and `phase3c.res` exercise the Phase-3 `--translate` path +`phase3b.res`, and `phase3c.res` exercise the `--translate` path (aliases / sums / generics / records / literal-`let`→`const` → compilable AffineScript, plus the qualified / mutable / optional / non-literal forms it -must skip). +must skip); `partial1.res` exercises the `--partial` path (function +skeletons + switch→match + expression translation, with a pipe form that must +become a TODO hole). Real `.res` files from the estate (e.g. `gitbot-fleet/bots/sustainabot/bot-integration/ src/*.res`) can be run ad hoc through the CLI without changes to the diff --git a/tools/res-to-affine/emitter.ml b/tools/res-to-affine/emitter.ml index 8b27d95..1dc4ba5 100644 --- a/tools/res-to-affine/emitter.ml +++ b/tools/res-to-affine/emitter.ml @@ -114,3 +114,39 @@ let emit_translation ~module_name ~source_path ~source ~findings ~translated = add (quote_block source); add "\n"; Buffer.contents buf + +let emit_partial ~module_name ~source_path ~source ~findings ~translated = + let buf = Buffer.create 4096 in + let add s = Buffer.add_string buf s in + add "// SPDX-License-Identifier: MPL-2.0\n"; + add "// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell\n"; + add "//\n"; + add (Printf.sprintf + "// Generated by tools/res-to-affine from %s (--partial, #488)\n" + source_path); + add "// PARTIAL PORT (#488): module-top-level functions are rendered as `fn`\n"; + add "// skeletons with switch->match + best-effort expression translation.\n"; + add "// This output DELIBERATELY does NOT type-check — param/return types are\n"; + add "// `_` holes and un-translatable expressions/patterns are `() /* TODO */`\n"; + add "// / `_ /* TODO */`. Fill the holes (check against the quoted original)\n"; + add "// to finish the port.\n"; + add "//\n"; + add (summarise_findings findings); + add "\n\n"; + add (Printf.sprintf "module %s;\n\n" module_name); + if translated = [] then begin + add "// (no module-top-level function bindings to port here; see the\n"; + add "// markers above and the original below.)\n" + end else + List.iter + (fun (line, code) -> + add (Printf.sprintf "// from .res line %d\n" line); + add code; + add "\n\n") + translated; + add "// TODO: finish the port — fill the type holes and resolve the\n"; + add "// `() /* TODO */` / `_ /* TODO */` islands per the markers.\n"; + add "\n"; + add (quote_block source); + add "\n"; + Buffer.contents buf diff --git a/tools/res-to-affine/emitter.mli b/tools/res-to-affine/emitter.mli index 382c5fe..c00529c 100644 --- a/tools/res-to-affine/emitter.mli +++ b/tools/res-to-affine/emitter.mli @@ -34,3 +34,15 @@ val emit_translation : declaration ([translated] is the [(source_line, affinescript)] list from {!Walker.translate}, in source order), then a TODO note and the quoted original. Used for [--translate]; {!emit} is unchanged. *) + +val emit_partial : + module_name:string -> + source_path:string -> + source:string -> + findings:Scanner.finding list -> + translated:(int * string) list -> + string +(** Render the #488 partial-port output: a banner stating the result does NOT + type-check, the marker block, a [module Name;] header, the [fn] skeletons + ([translated] from {!Walker.translate_partial}), then a TODO note and the + quoted original. Used for [--partial]. *) diff --git a/tools/res-to-affine/main.ml b/tools/res-to-affine/main.ml index 7b21b4b..77d4a9a 100644 --- a/tools/res-to-affine/main.ml +++ b/tools/res-to-affine/main.ml @@ -35,7 +35,7 @@ let engine_label = function | Scanner_engine -> "scanner" | Walker_engine -> "walker" -let run engine grammar_dir do_translate input output_opt = +let run engine grammar_dir do_translate do_partial input output_opt = if not (Sys.file_exists input) then begin Format.eprintf "res-to-affine: input not found: %s@." input; exit 2 @@ -53,20 +53,24 @@ let run engine grammar_dir do_translate input output_opt = input; Scanner.scan source) in - (* Phase 3: translation needs the AST, so it is walker-only. With the - scanner (or a walker that failed and would fall back), no translation - is emitted — the marker block + quoted original still carry the file. *) + (* Translation (--translate declarations / --partial function skeletons) + needs the AST, so it is walker-only. With the scanner, none is emitted — + the marker block + quoted original still carry the file. --partial takes + precedence over --translate when both are given. *) let translated = - if not do_translate then [] + if not (do_translate || do_partial) then [] else match engine with | Scanner_engine -> Format.eprintf - "res-to-affine: --translate needs the walker engine; \ + "res-to-affine: --translate/--partial need the walker engine; \ no translation emitted for %s@." input; [] | Walker_engine -> - (try Walker.translate ~grammar_dir ~path:input ~source with + let f = + if do_partial then Walker.translate_partial else Walker.translate + in + (try f ~grammar_dir ~path:input ~source with | Failure msg -> Format.eprintf "res-to-affine: %s@." msg; Format.eprintf @@ -75,7 +79,10 @@ let run engine grammar_dir do_translate input output_opt = in let module_name = Emitter.module_name_of_path input in let out = - if do_translate then + if do_partial then + Emitter.emit_partial + ~module_name ~source_path:input ~source ~findings ~translated + else if do_translate then Emitter.emit_translation ~module_name ~source_path:input ~source ~findings ~translated else @@ -142,12 +149,22 @@ let translate_arg = in Cmdliner.Arg.(value & flag & info ["translate"] ~doc) +let partial_arg = + let doc = + "#488 partial-port mode: render module-top-level functions as `fn` \ + skeletons with switch->match and best-effort expression translation. \ + The output DELIBERATELY does not type-check (un-inferable types are `_` \ + holes; un-translatable forms are `() /* TODO */` / `_ /* TODO */`). \ + Takes precedence over `--translate`; needs `--engine=walker`." + in + Cmdliner.Arg.(value & flag & info ["partial"] ~doc) + let cmd = let doc = "Emit an AffineScript skeleton from a ReScript source file." in let info = Cmdliner.Cmd.info "res-to-affine" ~version:"0.1.0" ~doc in let term = Cmdliner.Term.( - const run $ engine_arg $ grammar_dir_arg $ translate_arg + const run $ engine_arg $ grammar_dir_arg $ translate_arg $ partial_arg $ input_arg $ output_arg) in Cmdliner.Cmd.v info term diff --git a/tools/res-to-affine/test/fixtures/partial1.res b/tools/res-to-affine/test/fixtures/partial1.res new file mode 100644 index 0000000..8c552b2 --- /dev/null +++ b/tools/res-to-affine/test/fixtures/partial1.res @@ -0,0 +1,19 @@ +// 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. + +let classify = x => switch x { +| Some(n) => n + 1 +| None => 0 +} + +let area = (w, h) => w *. h + +let greet = name => "hi " ++ name + +let log2 = msg => Js.log(msg) + +// pipe-first has no AffineScript equivalent -> must become a TODO hole. +let piped = x => x->doStuff(1) diff --git a/tools/res-to-affine/test/test_walker.ml b/tools/res-to-affine/test/test_walker.ml index 98c6090..d727be4 100644 --- a/tools/res-to-affine/test/test_walker.ml +++ b/tools/res-to-affine/test/test_walker.ml @@ -381,6 +381,60 @@ let test_translate_c_skips () = Alcotest.(check bool) "non-literal / ref / destructuring lets skipped" false leaked +(* ---- #488 partial-port: fn skeletons + switch->match ---------------------- + + [fixtures/partial1.res] holds five module-top-level functions exercising + switch->match, variant + nullary patterns, int/float/concat operators, a + member-call, and a pipe-first form that must become a TODO hole. The output + is a partial port (does not type-check); these assert its structure. *) + +let partial_fixture = "fixtures/partial1.res" + +let translate_partial1 () = + let source = read_file partial_fixture in + let path = Filename.concat (Sys.getcwd ()) partial_fixture in + Walker.translate_partial ~grammar_dir:(grammar_dir ()) ~path ~source + +let partial1_blob () = + String.concat "\n" (List.map snd (translate_partial1 ())) + +let test_partial_count () = + skip_unless_ready (); + Alcotest.(check int) + "five module-top-level functions -> fn skeletons" + 5 (List.length (translate_partial1 ())) + +let test_partial_switch_to_match () = + skip_unless_ready (); + let blob = partial1_blob () in + let ok = + contains blob "fn classify(x: _) -> _" && contains blob "match x {" + && contains blob "Some(n) => n + 1" && contains blob "None => 0" + in + Alcotest.(check bool) "switch -> match with translated arms + patterns" true ok + +let test_partial_float_op_normalised () = + skip_unless_ready (); + let blob = partial1_blob () in + Alcotest.(check bool) "float op normalised; multi-param skeleton" + true + (contains blob "fn area(w: _, h: _) -> _" + && contains blob "w * h" + && not (contains blob "*.")) + +let test_partial_concat_and_call () = + skip_unless_ready (); + let blob = partial1_blob () in + Alcotest.(check bool) "string concat + member-call translated" + true + (contains blob "\"hi \" ++ name" && contains blob "Js.log(msg)") + +let test_partial_todo_hole () = + skip_unless_ready (); + let blob = partial1_blob () in + Alcotest.(check bool) "untranslatable form becomes a () /* TODO */ hole" + true (contains blob "() /* TODO:") + let () = Alcotest.run "res-to-affine-walker" [ @@ -447,4 +501,17 @@ let () = Alcotest.test_case "call / ref / destructuring lets skipped" `Quick test_translate_c_skips; ] ); + ( "walker-488-partial", + [ + Alcotest.test_case "five functions -> fn skeletons" + `Quick test_partial_count; + Alcotest.test_case "switch -> match + patterns + arm bodies" + `Quick test_partial_switch_to_match; + Alcotest.test_case "float op normalised + multi-param skeleton" + `Quick test_partial_float_op_normalised; + Alcotest.test_case "concat + member-call translated" + `Quick test_partial_concat_and_call; + Alcotest.test_case "untranslatable form -> TODO hole" + `Quick test_partial_todo_hole; + ] ); ] diff --git a/tools/res-to-affine/walker.ml b/tools/res-to-affine/walker.ml index 8bbdc10..0f644b3 100644 --- a/tools/res-to-affine/walker.ml +++ b/tools/res-to-affine/walker.ml @@ -911,6 +911,250 @@ let rec collect_translations ~source ancestors acc node = (fun acc c -> collect_translations ~source (node.ntype :: ancestors) acc c) acc node.children +(* ---- #488 partial-port mode ---------------------------------------------- + + A SEPARATE model from the declaration translator above. It renders module- + top-level function bindings (`let f = (x) => body`) into AffineScript `fn` + skeletons with `switch`->`match` and best-effort expression translation. + The output is DELIBERATELY not type-checked: un-annotated ReScript params + become `_` type holes and any expression/pattern we can't translate is + emitted as a `() /* TODO */` (expr) or `_ /* TODO */` (pattern) hole. The + point is a partial port a human finishes; everything still PARSES. *) + +let partial_excerpt ~source n = + let s = node_text ~source n in + let s = String.map (function '\n' | '\r' | '\t' -> ' ' | c -> c) s in + let s = Str.global_replace (Str.regexp_string "*/") "* /" s in + let s = String.trim s in + if String.length s > 48 then String.sub s 0 45 ^ "..." else s + +let todo_expr ~source n = + Printf.sprintf "() /* TODO: %s */" (partial_excerpt ~source n) + +let todo_pattern ~source n = + Printf.sprintf "_ /* TODO: %s */" (partial_excerpt ~source n) + +(* The binary operator is an anonymous token (absent from the named tree); + we slice it from source. Normalise ReScript float ops + identity equality + to their AffineScript spellings. *) +let affine_binop raw = + match String.trim raw with + | "+." -> "+" | "-." -> "-" | "*." -> "*" | "/." -> "/" + | "===" -> "==" | "!==" -> "!=" + | op -> op + +let rec translate_pattern ~source n = + match n.ntype with + | "value_identifier" | "number" | "string" | "true" | "false" | "character" + -> node_text ~source n + | "tuple_item_pattern" -> ( + match List.find_opt (fun c -> c.ntype <> "type_annotation") n.children with + | Some p -> translate_pattern ~source p + | None -> "_") + | "tuple_pattern" -> + Printf.sprintf "(%s)" + (String.concat ", " (List.map (translate_pattern ~source) n.children)) + | "variant_pattern" -> ( + match + List.find_opt (fun c -> c.ntype = "variant_identifier") n.children + with + | None -> todo_pattern ~source n + | Some v -> ( + let name = node_text ~source v in + match + List.find_opt (fun c -> c.ntype = "formal_parameters") n.children + with + | None -> name + | Some fp -> + let args = + List.filter_map + (fun c -> + if c.ntype = "type_annotation" then None + else Some (translate_pattern ~source c)) + fp.children + in + if args = [] then name + else Printf.sprintf "%s(%s)" name (String.concat ", " args))) + | _ -> todo_pattern ~source n + +let rec translate_expr ~source n = + match n.ntype with + | "number" | "string" | "true" | "false" | "character" | "value_identifier" + -> node_text ~source n + (* a module-qualified value (`Js.log`); the dotted form parses in + AffineScript as field access, which is enough for a skeleton. *) + | "value_identifier_path" -> node_text ~source n + | "unit" -> "()" + | "expression_statement" -> ( + match List.filter (fun c -> c.ntype <> "comment") n.children with + | [ e ] -> translate_expr ~source e + | _ -> todo_expr ~source n) + | "switch_expression" -> translate_switch ~source n + | "parenthesized_expression" -> ( + 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" -> ( + 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))) + | "ternary_expression" -> ( + match + ( child_with_field "condition" n, + child_with_field "consequence" n, + child_with_field "alternative" n ) + with + | Some c, Some a, Some b -> + Printf.sprintf "if %s { %s } else { %s }" (translate_expr ~source c) + (translate_expr ~source a) (translate_expr ~source b) + | _ -> todo_expr ~source n) + | "call_expression" -> + let fn = + match child_with_field "function" n with + | Some f -> translate_expr ~source f + | None -> "todo_fn" + in + let args = + match child_with_field "arguments" n 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 ", " args) + | "binary_expression" -> ( + match List.filter (fun c -> c.ntype <> "comment") n.children with + | [ l; r ] -> + let op = affine_binop (slice ~source ~start:l.stop ~stop:r.start) in + Printf.sprintf "%s %s %s" (translate_expr ~source l) op + (translate_expr ~source r) + | _ -> todo_expr ~source n) + | "member_expression" -> ( + match (child_with_field "record" n, child_with_field "property" n) with + | Some r, Some p -> + Printf.sprintf "%s.%s" (translate_expr ~source r) + (node_text ~source p) + | _ -> todo_expr ~source n) + | _ -> todo_expr ~source n + +and translate_labeled_arg ~source n = + let value = + match child_with_field "value" n with + | Some v -> translate_expr ~source v + | None -> "()" + in + match child_with_field "label" n with + | Some l -> Printf.sprintf "/* ~%s */ %s" (node_text ~source l) value + | None -> value + +and translate_switch ~source sw = + let scrutinee = + match List.find_opt (fun c -> c.ntype <> "switch_match") sw.children with + | Some s -> translate_expr ~source s + | None -> "()" + in + let arms = + List.filter_map + (fun c -> + if c.ntype <> "switch_match" then None + else + let pat = + match child_with_field "pattern" c with + | Some p -> translate_pattern ~source p + | None -> "_" + in + let guard = + match List.find_opt (fun g -> g.ntype = "guard") c.children with + | Some g -> ( + match g.children with + | e :: _ -> " if " ^ translate_expr ~source e + | [] -> "") + | None -> "" + in + let body = + match child_with_field "body" c with + | Some b -> translate_expr ~source b + | None -> "()" + in + Some (Printf.sprintf " %s%s => %s," pat guard body)) + sw.children + in + Printf.sprintf "match %s {\n%s\n}" scrutinee (String.concat "\n" arms) + +let translate_param ~source p = + match p.ntype with + | "value_identifier" -> node_text ~source p ^ ": _" + | "parameter" | "labeled_parameter" -> ( + match + List.find_opt (fun c -> c.ntype = "value_identifier") p.children + with + | Some nm -> node_text ~source nm ^ ": _" + | None -> "_arg: _") + | "unit" -> "_unit: ()" + | _ -> "_arg: _" + +let partial_function ~source ~name fn = + let params = + match child_with_field "parameter" fn with + | Some single -> [ translate_param ~source single ] + | None -> ( + match + List.find_opt (fun c -> c.ntype = "formal_parameters") fn.children + with + | Some fp -> + List.filter_map + (fun c -> + if c.ntype = "type_annotation" || c.ntype = "comment" then None + else Some (translate_param ~source c)) + fp.children + | None -> []) + in + let body = + match child_with_field "body" fn with + | Some b -> translate_expr ~source b + | None -> "()" + in + Printf.sprintf + "// TODO(partial-port): fill the `_` types and `() /* TODO */` holes.\n\ + fn %s(%s) -> _ {\n %s\n}" + name (String.concat ", " params) body + +(* Translate a `let f = (..) => body` whose pattern is a plain identifier and + whose body is a function, into an `fn` skeleton. [None] otherwise. *) +let translate_partial_let ~source lb = + match child_with_field "pattern" lb with + | Some pat when pat.ntype = "value_identifier" -> ( + match child_with_field "body" lb with + | Some body when body.ntype = "function" -> + Some (partial_function ~source ~name:(node_text ~source pat) body) + | _ -> None) + | _ -> None + +let rec collect_partial ~source ancestors acc node = + let acc = + if at_module_toplevel ancestors && node.ntype = "let_declaration" then + List.fold_left + (fun acc c -> + if c.ntype = "let_binding" then + (match translate_partial_let ~source c with + | Some s -> (c.start.row + 1, s) :: acc + | None -> acc) + else acc) + acc node.children + else acc + in + List.fold_left + (fun acc c -> collect_partial ~source (node.ntype :: ancestors) acc c) + acc node.children + (* ---- public entry point --------------------------------------------------- *) let parse_file ~grammar_dir ~path = @@ -933,3 +1177,8 @@ let translate ~grammar_dir ~path ~source = let root = parse_file ~grammar_dir ~path in collect_translations ~source [] [] root |> List.sort (fun (l1, _) (l2, _) -> compare l1 l2) + +let translate_partial ~grammar_dir ~path ~source = + let root = parse_file ~grammar_dir ~path in + collect_partial ~source [] [] root + |> List.sort (fun (l1, _) (l2, _) -> compare l1 l2) diff --git a/tools/res-to-affine/walker.mli b/tools/res-to-affine/walker.mli index 053827b..9d2f2cb 100644 --- a/tools/res-to-affine/walker.mli +++ b/tools/res-to-affine/walker.mli @@ -63,6 +63,23 @@ val translate : skipped form is recovered from the marker block / quoted source the emitter prints. Raises [Failure] under the same conditions as {!scan}. *) +val translate_partial : + grammar_dir:string -> + path:string -> + source:string -> + (int * string) list +(** [translate_partial ~grammar_dir ~path ~source] is the #488 partial-port + model (distinct from {!translate}): it renders module-top-level function + bindings ([let f = (x) => body]) into AffineScript [fn] skeletons with + [switch]->[match] and best-effort expression translation, as + [(source_line, affinescript)] pairs in source order. + + Unlike {!translate}, the output is NOT guaranteed to type-check: un- + annotated parameters become [_] type holes and any expression/pattern + that can't be translated is emitted as a [() /* TODO */] or + [_ /* TODO */] hole. It is a partial port a human finishes; it does + still parse. Raises [Failure] under the same conditions as {!scan}. *) + val default_grammar_dir : string (** Default location of the generated grammar relative to the current working directory: ["tools/vendor/tree-sitter-rescript"]. Matches