Skip to content

Commit 2763909

Browse files
feat(res-to-affine): Phase 3 slice 1 — structural type-decl translation (Refs #57) (#477)
Phase 3 (partial translation) slice 1: --translate renders fully-structural type declarations (primitive aliases + simple sum types) into compilable AffineScript; conservative (generics / qualified paths / records / non-primitive payloads / GADT returns / variant spreads skipped, never guessed); walker-only. Refs #57.
1 parent b440819 commit 2763909

9 files changed

Lines changed: 450 additions & 25 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): 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)
2324
- feat(stdlib/Http): RSR rewire — surface `hpm-http-rsr` Zig FFI (10 server-side externs: listen / port / free / accept / method / path / header / body / respond / request-free) + opaque `HpmHttpServer` + `HpmHttpRequest` types; native-only (#425)
2425
- feat(stdlib/json): v0.3 — RSR rewire to `hpm-json-rsr` Zig FFI (11 externs + opaque `HpmJsonValue` + `parse` / `to_json`), Deno-ESM lowering via `__as_hpmJson*` shims (#421)
2526
- feat(parser): trailing-comma in fn params and expr lists (Refs gitbot-fleet#148) (#370)

tools/res-to-affine/README.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ dune exec tools/res-to-affine/main.exe -- path/to/Foo.res
2222
# or write to a file
2323
dune exec tools/res-to-affine/main.exe -- path/to/Foo.res -o Foo.affine
2424

25+
# Phase 3 (slice 1): also translate fully-structural type declarations
26+
# (primitive aliases + simple sum types) into compilable AffineScript
27+
dune exec tools/res-to-affine/main.exe -- --translate path/to/Foo.res
28+
2529
# opt back into the Phase-1 line-regex scanner (no grammar required)
2630
dune exec tools/res-to-affine/main.exe -- --engine=scanner path/to/Foo.res
2731
```
@@ -147,6 +151,32 @@ where re-decomposition is genuinely required.
147151

148152
Phase 3 is when the tool earns its keep on idaptik's 542 files.
149153

154+
**Phase 3 slice 1 (`--translate`, landed).** The first translation slice
155+
renders the two most mechanical, **module-qualified-path-independent**
156+
shapes into compilable AffineScript:
157+
158+
| ReScript | AffineScript |
159+
|---|---|
160+
| `type userId = int` | `type UserId = Int` |
161+
| `type color = Red \| Green \| Blue` | `type Color =`<br>` \| Red`<br>` \| Green`<br>` \| Blue` |
162+
| `type shape = Circle(float) \| Rect(int, int)` | `type Shape =`<br>` \| Circle(Float)`<br>` \| Rect(Int, Int)` |
163+
164+
It is **conservative by construction**: a declaration that uses type
165+
parameters/generics, a qualified path (`Belt.Map.t`), a record body,
166+
non-primitive references, a GADT return annotation, or a variant spread
167+
is *skipped* — it stays in the marker block + quoted original, never
168+
mis-translated. Lower-case ReScript type names are capitalised so they
169+
are referenceable AffineScript type constructors (`lib/parser.mly` reads
170+
a lower-case name in type position as a type *variable*, not a
171+
constructor). Translation is walker-only (it needs the AST); with
172+
`--engine=scanner` the flag is a no-op.
173+
174+
Deliberately **deferred to later Phase-3 slices**: record types, generic
175+
type declarations, module-qualified references (now that the
176+
[#228](https://github.com/hyperpolymath/affinescript/issues/228) grammar
177+
gap is closed), `let`-to-`const` for literal bindings, and the
178+
`switch``match` expression rewrite (which needs body translation).
179+
150180
## Corpus run
151181

152182
[`CORPUS-RUN.md`](CORPUS-RUN.md) records the first end-to-end run
@@ -172,7 +202,10 @@ cd tools/res-to-affine/test
172202
The fixture under `test/fixtures/sample.res` is synthetic and exercises
173203
every Phase-1 anti-pattern; `test/fixtures/phase2c.res` exercises the
174204
two anti-patterns that are walker-only by construction
175-
(`inline-callback-record`, `oversized-function`). Real `.res` files
205+
(`inline-callback-record`, `oversized-function`); `test/fixtures/phase3.res`
206+
exercises the Phase-3 `--translate` slice (structural type-declaration
207+
translation, plus the generic/qualified/non-type forms it must skip).
208+
Real `.res` files
176209
from the estate (e.g. `gitbot-fleet/bots/sustainabot/bot-integration/
177210
src/*.res`) can be run ad hoc through the CLI without changes to the
178211
test suite.

tools/res-to-affine/emitter.ml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,38 @@ let emit ~module_name ~source_path ~source ~findings =
7979
add (quote_block source);
8080
add "\n";
8181
Buffer.contents buf
82+
83+
let emit_translation ~module_name ~source_path ~source ~findings ~translated =
84+
let buf = Buffer.create 4096 in
85+
let add s = Buffer.add_string buf s in
86+
add "// SPDX-License-Identifier: MPL-2.0\n";
87+
add "// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell\n";
88+
add "//\n";
89+
add (Printf.sprintf
90+
"// Generated by tools/res-to-affine from %s (--translate, Phase 3)\n"
91+
source_path);
92+
add "// PARTIAL PORT (Phase 3, slice 1): the fully-structural type\n";
93+
add "// declarations below are translated to compilable AffineScript; every\n";
94+
add "// other form is left as a TODO island — the markers and the quoted\n";
95+
add "// original record what still needs porting. Review before relying on it.\n";
96+
add "//\n";
97+
add (summarise_findings findings);
98+
add "\n\n";
99+
add (Printf.sprintf "module %s;\n\n" module_name);
100+
if translated = [] then begin
101+
add "// (no fully-structural type declarations were translatable here;\n";
102+
add "// see the markers above and the original below.)\n"
103+
end else
104+
List.iter
105+
(fun (line, code) ->
106+
add (Printf.sprintf "// from .res line %d\n" line);
107+
add code;
108+
add "\n\n")
109+
translated;
110+
add "// TODO: port the remaining forms. The original ReScript is retained\n";
111+
add "// below; re-decompose effectful / stateful / exception paths\n";
112+
add "// per the markers before deleting it.\n";
113+
add "\n";
114+
add (quote_block source);
115+
add "\n";
116+
Buffer.contents buf

tools/res-to-affine/emitter.mli

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,16 @@ val emit :
2121
findings:Scanner.finding list ->
2222
string
2323
(** Render the skeleton. The result is a complete file contents string. *)
24+
25+
val emit_translation :
26+
module_name:string ->
27+
source_path:string ->
28+
source:string ->
29+
findings:Scanner.finding list ->
30+
translated:(int * string) list ->
31+
string
32+
(** Render the Phase-3 partial port: the same marker block as {!emit}, a
33+
proper [module Name;] header, then each translated structural type
34+
declaration ([translated] is the [(source_line, affinescript)] list
35+
from {!Walker.translate}, in source order), then a TODO note and the
36+
quoted original. Used for [--translate]; {!emit} is unchanged. *)

tools/res-to-affine/main.ml

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
migration markers. The original source is quoted at the bottom of
99
the output so the human migrating the file has it side-by-side.
1010
11-
Phase 1 (this binary) uses a text scanner. Phase 2 swaps the
12-
[Scanner] implementation for a tree-sitter AST walker reading the
13-
vendored grammar at [editors/tree-sitter-rescript/]. See the
14-
tool README for the full plan. *)
11+
Detection defaults to the Phase-2c tree-sitter AST [Walker] (the
12+
vendored grammar at [editors/tree-sitter-rescript/]); [--engine=scanner]
13+
falls back to the Phase-1 line [Scanner]. With [--translate], the
14+
Phase-3 slice additionally renders fully-structural type declarations
15+
into compilable AffineScript inline in the skeleton. See the tool
16+
README for the full plan. *)
1517

1618
open Res_to_affine
1719

@@ -33,7 +35,7 @@ let engine_label = function
3335
| Scanner_engine -> "scanner"
3436
| Walker_engine -> "walker"
3537

36-
let run engine grammar_dir input output_opt =
38+
let run engine grammar_dir do_translate input output_opt =
3739
if not (Sys.file_exists input) then begin
3840
Format.eprintf "res-to-affine: input not found: %s@." input;
3941
exit 2
@@ -51,23 +53,44 @@ let run engine grammar_dir input output_opt =
5153
input;
5254
Scanner.scan source)
5355
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. *)
59+
let translated =
60+
if not do_translate then []
61+
else
62+
match engine with
63+
| Scanner_engine ->
64+
Format.eprintf
65+
"res-to-affine: --translate needs the walker engine; \
66+
no translation emitted for %s@." input;
67+
[]
68+
| Walker_engine ->
69+
(try Walker.translate ~grammar_dir ~path:input ~source with
70+
| Failure msg ->
71+
Format.eprintf "res-to-affine: %s@." msg;
72+
Format.eprintf
73+
"res-to-affine: no translation emitted for %s@." input;
74+
[])
75+
in
5476
let module_name = Emitter.module_name_of_path input in
5577
let out =
56-
Emitter.emit
57-
~module_name
58-
~source_path:input
59-
~source
60-
~findings
78+
if do_translate then
79+
Emitter.emit_translation
80+
~module_name ~source_path:input ~source ~findings ~translated
81+
else
82+
Emitter.emit ~module_name ~source_path:input ~source ~findings
6183
in
6284
match output_opt with
6385
| None ->
6486
print_string out
6587
| Some path ->
6688
write_file path out;
6789
Format.printf
68-
"res-to-affine: %d finding%s [%s] → %s@."
90+
"res-to-affine: %d finding%s, %d translated [%s] → %s@."
6991
(List.length findings)
7092
(if List.length findings = 1 then "" else "s")
93+
(List.length translated)
7194
(engine_label engine)
7295
path
7396

@@ -109,12 +132,23 @@ let grammar_dir_arg =
109132
value & opt string Walker.default_grammar_dir &
110133
info ["grammar-dir"] ~docv:"DIR" ~doc)
111134

135+
let translate_arg =
136+
let doc =
137+
"Phase 3 (slice 1): additionally translate fully-structural type \
138+
declarations — primitive aliases and simple sum types — into \
139+
compilable AffineScript, inline in the skeleton. Every other form \
140+
stays a TODO island. Needs `--engine=walker` (the default); with the \
141+
scanner engine it is a no-op."
142+
in
143+
Cmdliner.Arg.(value & flag & info ["translate"] ~doc)
144+
112145
let cmd =
113146
let doc = "Emit an AffineScript skeleton from a ReScript source file." in
114147
let info = Cmdliner.Cmd.info "res-to-affine" ~version:"0.1.0" ~doc in
115148
let term =
116149
Cmdliner.Term.(
117-
const run $ engine_arg $ grammar_dir_arg $ input_arg $ output_arg)
150+
const run $ engine_arg $ grammar_dir_arg $ translate_arg
151+
$ input_arg $ output_arg)
118152
in
119153
Cmdliner.Cmd.v info term
120154

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// SPDX-License-Identifier: MIT
2+
// Synthetic fixture for Phase 3 slice 1: structural type-declaration
3+
// translation. The first three type declarations are fully structural
4+
// and #228-independent, so the walker's --translate path renders them
5+
// as compilable AffineScript:
6+
// type userId = int -> type UserId = Int
7+
// type color = Red | ... -> type Color = | Red | Green | Blue
8+
// type shape = Circle(...) -> type Shape = | Circle(Float) | Rect(Int, Int)
9+
// The trailing `let`/`switch` is NOT a type declaration and must remain a
10+
// TODO island (absent from the translation list). The generic and
11+
// qualified type decls below must also be skipped (slice 1 is monomorphic
12+
// and unqualified).
13+
14+
type userId = int
15+
16+
type color =
17+
| Red
18+
| Green
19+
| Blue
20+
21+
type shape =
22+
| Circle(float)
23+
| Rect(int, int)
24+
25+
// Skipped in slice 1: type parameters (generic).
26+
type box<'a> = Box('a)
27+
28+
// Skipped in slice 1: qualified-path RHS.
29+
type theirMap = Belt.Map.t
30+
31+
// Not a type declaration: stays a TODO island.
32+
let area = s =>
33+
switch s {
34+
| Circle(r) => r *. r
35+
| Rect(w, h) => float_of_int(w * h)
36+
}

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

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ let read_file path =
2121
close_in ic;
2222
s
2323

24+
(* Substring search — OCaml's [String] only has char membership. *)
25+
let contains haystack needle =
26+
let hn = String.length haystack and nn = String.length needle in
27+
if nn = 0 then true
28+
else
29+
let rec loop i =
30+
if i + nn > hn then false
31+
else if String.sub haystack i nn = needle then true
32+
else loop (i + 1)
33+
in
34+
loop 0
35+
2436
let tree_sitter_available () =
2537
Sys.command "command -v tree-sitter > /dev/null 2>&1" = 0
2638

@@ -194,6 +206,65 @@ let test_walker_finds_oversized_function () =
194206
gated end-to-end tests above exercise it through real
195207
tree-sitter output. *)
196208

209+
(* ---- Phase 3 slice 1: structural type-declaration translation -------------
210+
211+
[fixtures/phase3.res] holds three translatable structural type decls
212+
(a primitive alias and two simple sum types) plus forms that slice 1
213+
must skip (a generic, a qualified-path alias, and a let/switch). These
214+
gated tests assert the translator's output structurally. *)
215+
216+
let phase3_fixture = "fixtures/phase3.res"
217+
218+
let translate_phase3 () =
219+
let source = read_file phase3_fixture in
220+
let path = Filename.concat (Sys.getcwd ()) phase3_fixture in
221+
Walker.translate ~grammar_dir:(grammar_dir ()) ~path ~source
222+
223+
let translate_phase3_blob () =
224+
String.concat "\n" (List.map snd (translate_phase3 ()))
225+
226+
let test_translate_count () =
227+
skip_unless_ready ();
228+
Alcotest.(check int)
229+
"exactly the three structural type decls are translated"
230+
3 (List.length (translate_phase3 ()))
231+
232+
let test_translate_alias () =
233+
skip_unless_ready ();
234+
Alcotest.(check bool)
235+
"primitive alias -> capitalised TyCon + Int"
236+
true (contains (translate_phase3_blob ()) "type UserId = Int")
237+
238+
let test_translate_nullary_sum () =
239+
skip_unless_ready ();
240+
let blob = translate_phase3_blob () in
241+
let ok =
242+
contains blob "type Color =" && contains blob "| Red"
243+
&& contains blob "| Green" && contains blob "| Blue"
244+
in
245+
Alcotest.(check bool) "nullary sum -> leading-pipe variant form" true ok
246+
247+
let test_translate_payload_sum () =
248+
skip_unless_ready ();
249+
let blob = translate_phase3_blob () in
250+
let ok =
251+
contains blob "type Shape =" && contains blob "| Circle(Float)"
252+
&& contains blob "| Rect(Int, Int)"
253+
in
254+
Alcotest.(check bool) "primitive-payload sum -> mapped param types" true ok
255+
256+
let test_translate_skips_non_structural () =
257+
skip_unless_ready ();
258+
let blob = translate_phase3_blob () in
259+
(* the generic Box, qualified Belt.Map.t, and the let/switch must all be
260+
absent from the translation — slice 1 never guesses them. *)
261+
let leaked =
262+
contains blob "switch" || contains blob "area" || contains blob "Box"
263+
|| contains blob "Belt" || contains blob "'a"
264+
in
265+
Alcotest.(check bool) "non-structural / generic / qualified forms skipped"
266+
false leaked
267+
197268
let () =
198269
Alcotest.run "res-to-affine-walker"
199270
[
@@ -221,4 +292,17 @@ let () =
221292
Alcotest.test_case "oversized-function found on phase2c.res"
222293
`Quick test_walker_finds_oversized_function;
223294
] );
295+
( "walker-phase3-translate",
296+
[
297+
Alcotest.test_case "three structural type decls translated"
298+
`Quick test_translate_count;
299+
Alcotest.test_case "primitive alias -> type UserId = Int"
300+
`Quick test_translate_alias;
301+
Alcotest.test_case "nullary sum -> leading-pipe variants"
302+
`Quick test_translate_nullary_sum;
303+
Alcotest.test_case "primitive-payload sum -> mapped params"
304+
`Quick test_translate_payload_sum;
305+
Alcotest.test_case "generic / qualified / non-type forms skipped"
306+
`Quick test_translate_skips_non_structural;
307+
] );
224308
]

0 commit comments

Comments
 (0)