Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> = <literal>` (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)
Expand Down
67 changes: 49 additions & 18 deletions tools/res-to-affine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions tools/res-to-affine/emitter.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 12 additions & 0 deletions tools/res-to-affine/emitter.mli
Original file line number Diff line number Diff line change
Expand Up @@ -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]. *)
35 changes: 26 additions & 9 deletions tools/res-to-affine/main.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions tools/res-to-affine/test/fixtures/partial1.res
Original file line number Diff line number Diff line change
@@ -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)
67 changes: 67 additions & 0 deletions tools/res-to-affine/test/test_walker.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
[
Expand Down Expand Up @@ -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;
] );
]
Loading
Loading