Skip to content

Commit c157a0f

Browse files
feat(res-to-affine): partial-port mode — --partial fn skeletons + switch→match (Refs #488) (#494)
#488 partial-port slice 1: new --partial flag renders module-top-level functions as `fn` skeletons with switch->match + 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. Distinct model from --translate; output parses but deliberately does not type-check. Refs #488.
1 parent 4800fc9 commit c157a0f

9 files changed

Lines changed: 476 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0
2020

2121
### Added
2222

23+
- 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)
2324
- 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)
2425
- 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)
2526
- 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)

tools/res-to-affine/README.md

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,15 @@ dune exec tools/res-to-affine/main.exe -- path/to/Foo.res
2424
# or write to a file
2525
dune exec tools/res-to-affine/main.exe -- path/to/Foo.res -o Foo.affine
2626

27-
# Phase 3 (slice 1): also translate fully-structural type declarations
28-
# (primitive aliases + simple sum types) into compilable AffineScript
27+
# --translate: render self-contained top-level declarations (type aliases,
28+
# sums, structs, generics, literal `let`->`const`) as compilable AffineScript
2929
dune exec tools/res-to-affine/main.exe -- --translate path/to/Foo.res
3030

31+
# --partial (#488): render module-top-level functions as `fn` skeletons with
32+
# switch->match + best-effort bodies. Output is a partial port that does NOT
33+
# type-check (un-inferable types/exprs become `_` / `() /* TODO */` holes).
34+
dune exec tools/res-to-affine/main.exe -- --partial path/to/Foo.res
35+
3136
# opt back into the Phase-1 line-regex scanner (no grammar required)
3237
dune exec tools/res-to-affine/main.exe -- --engine=scanner path/to/Foo.res
3338
```
@@ -186,23 +191,47 @@ names are capitalised (`color` → `Color`) and type variables are mapped
186191
position as a type *variable*, not a constructor. Translation is
187192
walker-only (it needs the AST); with `--engine=scanner` the flag is a no-op.
188193

189-
**Scope boundary — what `--translate` will *not* do.** The guarantee is
190-
"every emitted form type-checks standalone", which is why translation is
191-
limited to self-contained top-level *declarations* (types, structs,
192-
literal consts). Two forms are out of that scope by construction:
193-
194-
- **`switch``match`** is an *expression*, only meaningful inside a
195-
function body. Emitting a type-checkable `match` means translating the
196-
whole enclosing function — but ReScript function bindings are usually
197-
un-annotated (`let f = x => …`), and AffineScript `fn` requires parameter
198-
and return types, so the result wouldn't type-check. It belongs to a
199-
future *partial-port* mode (translate-with-TODO-holes) that drops the
200-
standalone-type-check guarantee, not to this declaration translator.
201-
- **module-qualified references** now *parse* (the
194+
**Scope boundary.** `--translate` keeps the "every emitted form type-checks
195+
standalone" guarantee, which is why it is limited to self-contained top-level
196+
*declarations* (types, structs, literal consts). Forms that can't meet that
197+
guarantee live in a separate mode or remain deferred:
198+
199+
- **`switch``match` + function bodies** — landed under **`--partial`**
200+
([#488](https://github.com/hyperpolymath/affinescript/issues/488)), a
201+
distinct partial-port model. A `match` is an *expression*, only meaningful
202+
inside a function, and ReScript bindings are usually un-annotated
203+
(`let f = x => …`) while AffineScript `fn` requires param/return types — so
204+
`--partial` emits a `fn` skeleton with `_` type holes + `switch``match` +
205+
best-effort expression translation, and its output **deliberately does not
206+
type-check**. Un-translatable expressions/patterns become `() /* TODO */` /
207+
`_ /* TODO */` islands; the result still *parses*. See the `--partial`
208+
section below.
209+
- **module-qualified references** in *type* position now *parse* (the
202210
[#228](https://github.com/hyperpolymath/affinescript/issues/228) grammar
203211
gap closed), but a faithful `Belt.Map.t``Belt::Map::T` would not
204212
*resolve* against a target module that doesn't exist yet — it waits for a
205-
module-mapping story.
213+
module-mapping story (tracked in #488).
214+
215+
### `--partial` — partial-port mode (#488, landed)
216+
217+
Renders each module-top-level function `let f = (params) => body` into an
218+
AffineScript `fn` skeleton:
219+
220+
| ReScript | AffineScript (`--partial`) |
221+
|---|---|
222+
| `let area = (w, h) => w *. h` | `fn area(w: _, h: _) -> _ { w * h }` |
223+
| `let classify = x => switch x { \| Some(n) => n + 1 \| None => 0 }` | `fn classify(x: _) -> _ { match x { Some(n) => n + 1, None => 0, } }` |
224+
| `let greet = name => "hi " ++ name` | `fn greet(name: _) -> _ { "hi " ++ name }` |
225+
226+
It translates literals, identifiers, calls, binary operators (normalising
227+
ReScript's float ops `+.`/`*.``+`/`*` and `===`/`!==``==`/`!=`), string
228+
concat `++`, member/qualified access, ternaries, and `switch``match` with
229+
variant/tuple/literal patterns. Anything else (pipe-first `->`, records, etc.)
230+
becomes a `() /* TODO */` hole. The output is a partial port to finish by
231+
hand: it **parses** but is not expected to type-check (verified — the
232+
generated skeletons reach resolution/type-checking without a parse error).
233+
First slice; the next steps (pipe desugaring `a->f(b)``f(a, b)`, `if`/block
234+
bodies, combining with `--translate`) continue under #488.
206235

207236
## Corpus run
208237

@@ -230,10 +259,12 @@ The fixture under `test/fixtures/sample.res` is synthetic and exercises
230259
every Phase-1 anti-pattern; `test/fixtures/phase2c.res` exercises the
231260
two anti-patterns that are walker-only by construction
232261
(`inline-callback-record`, `oversized-function`); `test/fixtures/phase3.res`,
233-
`phase3b.res`, and `phase3c.res` exercise the Phase-3 `--translate` path
262+
`phase3b.res`, and `phase3c.res` exercise the `--translate` path
234263
(aliases / sums / generics / records / literal-`let``const` → compilable
235264
AffineScript, plus the qualified / mutable / optional / non-literal forms it
236-
must skip).
265+
must skip); `partial1.res` exercises the `--partial` path (function
266+
skeletons + switch→match + expression translation, with a pipe form that must
267+
become a TODO hole).
237268
Real `.res` files
238269
from the estate (e.g. `gitbot-fleet/bots/sustainabot/bot-integration/
239270
src/*.res`) can be run ad hoc through the CLI without changes to the

tools/res-to-affine/emitter.ml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,39 @@ let emit_translation ~module_name ~source_path ~source ~findings ~translated =
114114
add (quote_block source);
115115
add "\n";
116116
Buffer.contents buf
117+
118+
let emit_partial ~module_name ~source_path ~source ~findings ~translated =
119+
let buf = Buffer.create 4096 in
120+
let add s = Buffer.add_string buf s in
121+
add "// SPDX-License-Identifier: MPL-2.0\n";
122+
add "// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell\n";
123+
add "//\n";
124+
add (Printf.sprintf
125+
"// Generated by tools/res-to-affine from %s (--partial, #488)\n"
126+
source_path);
127+
add "// PARTIAL PORT (#488): module-top-level functions are rendered as `fn`\n";
128+
add "// skeletons with switch->match + best-effort expression translation.\n";
129+
add "// This output DELIBERATELY does NOT type-check — param/return types are\n";
130+
add "// `_` holes and un-translatable expressions/patterns are `() /* TODO */`\n";
131+
add "// / `_ /* TODO */`. Fill the holes (check against the quoted original)\n";
132+
add "// to finish the port.\n";
133+
add "//\n";
134+
add (summarise_findings findings);
135+
add "\n\n";
136+
add (Printf.sprintf "module %s;\n\n" module_name);
137+
if translated = [] then begin
138+
add "// (no module-top-level function bindings to port here; see the\n";
139+
add "// markers above and the original below.)\n"
140+
end else
141+
List.iter
142+
(fun (line, code) ->
143+
add (Printf.sprintf "// from .res line %d\n" line);
144+
add code;
145+
add "\n\n")
146+
translated;
147+
add "// TODO: finish the port — fill the type holes and resolve the\n";
148+
add "// `() /* TODO */` / `_ /* TODO */` islands per the markers.\n";
149+
add "\n";
150+
add (quote_block source);
151+
add "\n";
152+
Buffer.contents buf

tools/res-to-affine/emitter.mli

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,15 @@ val emit_translation :
3434
declaration ([translated] is the [(source_line, affinescript)] list
3535
from {!Walker.translate}, in source order), then a TODO note and the
3636
quoted original. Used for [--translate]; {!emit} is unchanged. *)
37+
38+
val emit_partial :
39+
module_name:string ->
40+
source_path:string ->
41+
source:string ->
42+
findings:Scanner.finding list ->
43+
translated:(int * string) list ->
44+
string
45+
(** Render the #488 partial-port output: a banner stating the result does NOT
46+
type-check, the marker block, a [module Name;] header, the [fn] skeletons
47+
([translated] from {!Walker.translate_partial}), then a TODO note and the
48+
quoted original. Used for [--partial]. *)

tools/res-to-affine/main.ml

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ let engine_label = function
3535
| Scanner_engine -> "scanner"
3636
| Walker_engine -> "walker"
3737

38-
let run engine grammar_dir do_translate input output_opt =
38+
let run engine grammar_dir do_translate do_partial input output_opt =
3939
if not (Sys.file_exists input) then begin
4040
Format.eprintf "res-to-affine: input not found: %s@." input;
4141
exit 2
@@ -53,20 +53,24 @@ let run engine grammar_dir do_translate input output_opt =
5353
input;
5454
Scanner.scan source)
5555
in
56-
(* Phase 3: translation needs the AST, so it is walker-only. With the
57-
scanner (or a walker that failed and would fall back), no translation
58-
is emitted — the marker block + quoted original still carry the file. *)
56+
(* Translation (--translate declarations / --partial function skeletons)
57+
needs the AST, so it is walker-only. With the scanner, none is emitted —
58+
the marker block + quoted original still carry the file. --partial takes
59+
precedence over --translate when both are given. *)
5960
let translated =
60-
if not do_translate then []
61+
if not (do_translate || do_partial) then []
6162
else
6263
match engine with
6364
| Scanner_engine ->
6465
Format.eprintf
65-
"res-to-affine: --translate needs the walker engine; \
66+
"res-to-affine: --translate/--partial need the walker engine; \
6667
no translation emitted for %s@." input;
6768
[]
6869
| Walker_engine ->
69-
(try Walker.translate ~grammar_dir ~path:input ~source with
70+
let f =
71+
if do_partial then Walker.translate_partial else Walker.translate
72+
in
73+
(try f ~grammar_dir ~path:input ~source with
7074
| Failure msg ->
7175
Format.eprintf "res-to-affine: %s@." msg;
7276
Format.eprintf
@@ -75,7 +79,10 @@ let run engine grammar_dir do_translate input output_opt =
7579
in
7680
let module_name = Emitter.module_name_of_path input in
7781
let out =
78-
if do_translate then
82+
if do_partial then
83+
Emitter.emit_partial
84+
~module_name ~source_path:input ~source ~findings ~translated
85+
else if do_translate then
7986
Emitter.emit_translation
8087
~module_name ~source_path:input ~source ~findings ~translated
8188
else
@@ -142,12 +149,22 @@ let translate_arg =
142149
in
143150
Cmdliner.Arg.(value & flag & info ["translate"] ~doc)
144151

152+
let partial_arg =
153+
let doc =
154+
"#488 partial-port mode: render module-top-level functions as `fn` \
155+
skeletons with switch->match and best-effort expression translation. \
156+
The output DELIBERATELY does not type-check (un-inferable types are `_` \
157+
holes; un-translatable forms are `() /* TODO */` / `_ /* TODO */`). \
158+
Takes precedence over `--translate`; needs `--engine=walker`."
159+
in
160+
Cmdliner.Arg.(value & flag & info ["partial"] ~doc)
161+
145162
let cmd =
146163
let doc = "Emit an AffineScript skeleton from a ReScript source file." in
147164
let info = Cmdliner.Cmd.info "res-to-affine" ~version:"0.1.0" ~doc in
148165
let term =
149166
Cmdliner.Term.(
150-
const run $ engine_arg $ grammar_dir_arg $ translate_arg
167+
const run $ engine_arg $ grammar_dir_arg $ translate_arg $ partial_arg
151168
$ input_arg $ output_arg)
152169
in
153170
Cmdliner.Cmd.v info term
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// SPDX-License-Identifier: MIT
2+
// #488 partial-port fixture: module-top-level functions -> `fn` skeletons
3+
// with switch->match and best-effort expression translation. Output is NOT
4+
// expected to type-check; it must parse, with un-translatable forms as
5+
// `() /* TODO */` holes.
6+
7+
let classify = x => switch x {
8+
| Some(n) => n + 1
9+
| None => 0
10+
}
11+
12+
let area = (w, h) => w *. h
13+
14+
let greet = name => "hi " ++ name
15+
16+
let log2 = msg => Js.log(msg)
17+
18+
// pipe-first has no AffineScript equivalent -> must become a TODO hole.
19+
let piped = x => x->doStuff(1)

tools/res-to-affine/test/test_walker.ml

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,60 @@ let test_translate_c_skips () =
381381
Alcotest.(check bool) "non-literal / ref / destructuring lets skipped"
382382
false leaked
383383

384+
(* ---- #488 partial-port: fn skeletons + switch->match ----------------------
385+
386+
[fixtures/partial1.res] holds five module-top-level functions exercising
387+
switch->match, variant + nullary patterns, int/float/concat operators, a
388+
member-call, and a pipe-first form that must become a TODO hole. The output
389+
is a partial port (does not type-check); these assert its structure. *)
390+
391+
let partial_fixture = "fixtures/partial1.res"
392+
393+
let translate_partial1 () =
394+
let source = read_file partial_fixture in
395+
let path = Filename.concat (Sys.getcwd ()) partial_fixture in
396+
Walker.translate_partial ~grammar_dir:(grammar_dir ()) ~path ~source
397+
398+
let partial1_blob () =
399+
String.concat "\n" (List.map snd (translate_partial1 ()))
400+
401+
let test_partial_count () =
402+
skip_unless_ready ();
403+
Alcotest.(check int)
404+
"five module-top-level functions -> fn skeletons"
405+
5 (List.length (translate_partial1 ()))
406+
407+
let test_partial_switch_to_match () =
408+
skip_unless_ready ();
409+
let blob = partial1_blob () in
410+
let ok =
411+
contains blob "fn classify(x: _) -> _" && contains blob "match x {"
412+
&& contains blob "Some(n) => n + 1" && contains blob "None => 0"
413+
in
414+
Alcotest.(check bool) "switch -> match with translated arms + patterns" true ok
415+
416+
let test_partial_float_op_normalised () =
417+
skip_unless_ready ();
418+
let blob = partial1_blob () in
419+
Alcotest.(check bool) "float op normalised; multi-param skeleton"
420+
true
421+
(contains blob "fn area(w: _, h: _) -> _"
422+
&& contains blob "w * h"
423+
&& not (contains blob "*."))
424+
425+
let test_partial_concat_and_call () =
426+
skip_unless_ready ();
427+
let blob = partial1_blob () in
428+
Alcotest.(check bool) "string concat + member-call translated"
429+
true
430+
(contains blob "\"hi \" ++ name" && contains blob "Js.log(msg)")
431+
432+
let test_partial_todo_hole () =
433+
skip_unless_ready ();
434+
let blob = partial1_blob () in
435+
Alcotest.(check bool) "untranslatable form becomes a () /* TODO */ hole"
436+
true (contains blob "() /* TODO:")
437+
384438
let () =
385439
Alcotest.run "res-to-affine-walker"
386440
[
@@ -447,4 +501,17 @@ let () =
447501
Alcotest.test_case "call / ref / destructuring lets skipped"
448502
`Quick test_translate_c_skips;
449503
] );
504+
( "walker-488-partial",
505+
[
506+
Alcotest.test_case "five functions -> fn skeletons"
507+
`Quick test_partial_count;
508+
Alcotest.test_case "switch -> match + patterns + arm bodies"
509+
`Quick test_partial_switch_to_match;
510+
Alcotest.test_case "float op normalised + multi-param skeleton"
511+
`Quick test_partial_float_op_normalised;
512+
Alcotest.test_case "concat + member-call translated"
513+
`Quick test_partial_concat_and_call;
514+
Alcotest.test_case "untranslatable form -> TODO hole"
515+
`Quick test_partial_todo_hole;
516+
] );
450517
]

0 commit comments

Comments
 (0)