Skip to content

Commit 3484010

Browse files
feat(res-to-affine): Phase 3 slice 2 — record + generic type-decl translation (Refs #57) (#481)
Phase 3 (partial translation) slice 2: --translate now also renders record types (-> struct) and generics (type params 'a -> [A], threaded through aliases / sums / records) into compilable AffineScript; mutable/optional records, qualified paths, object types, GADT returns, and nested generics are skipped (never guessed). Every emitted form verified via main.exe check. Refs #57.
1 parent e8978a7 commit 3484010

6 files changed

Lines changed: 317 additions & 99 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 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)
2324
- 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)
2425
- 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)
2526
- 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)

tools/res-to-affine/README.md

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -151,31 +151,42 @@ where re-decomposition is genuinely required.
151151

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

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).
154+
**Phase 3 (`--translate`, landed).** The translation path renders the
155+
fully-structural type declarations into compilable AffineScript. Every
156+
generated form below is verified by the compiler itself (`main.exe check`
157+
*Type checking passed*).
158+
159+
| ReScript | AffineScript | Slice |
160+
|---|---|---|
161+
| `type userId = int` | `type UserId = Int` | 1 |
162+
| `type color = Red \| Green \| Blue` | `type Color =`<br>` \| Red`<br>` \| Green`<br>` \| Blue` | 1 |
163+
| `type shape = Circle(float) \| Rect(int, int)` | `type Shape =`<br>` \| Circle(Float)`<br>` \| Rect(Int, Int)` | 1 |
164+
| `type point = {x: int, y: int}` | `struct Point {`<br>` x: Int,`<br>` y: Int`<br>`}` | 2 |
165+
| `type box<'a> = {value: 'a}` | `struct Box[A] {`<br>` value: A`<br>`}` | 2 |
166+
| `type option<'a> = None \| Some('a)` | `type Option[A] =`<br>` \| None`<br>` \| Some(A)` | 2 |
167+
| `type id<'a> = 'a` | `type Id[A] = A` | 2 |
168+
169+
It is **conservative by construction**: a declaration is translated only
170+
when every part is representable — a qualified-path reference
171+
(`Belt.Map.t`), a non-primitive/opaque reference, a nested generic
172+
(`array<int>`), a GADT return, a variant spread, an object type, or a
173+
record with a `mutable` or optional-`?` field causes the whole decl to be
174+
*skipped* (it stays in the marker block + quoted original, never
175+
mis-translated). Two normalisations make the output referenceable:
176+
lower-case ReScript type names are capitalised (`color``Color`) and
177+
type variables are mapped (`'a``A`), because `lib/parser.mly` reads a
178+
lower-case name in type position as a type *variable*, not a constructor.
179+
Translation is walker-only (it needs the AST); with `--engine=scanner`
180+
the flag is a no-op.
181+
182+
Deliberately **deferred to later Phase-3 slices**: `let`-to-`const` for
183+
literal bindings, the `switch``match` expression rewrite (needs body
184+
translation), and **module-qualified references** — these now *parse*
185+
(the [#228](https://github.com/hyperpolymath/affinescript/issues/228)
186+
grammar gap closed), but a faithful `Belt.Map.t``Belt::Map::T` would
187+
not *resolve* against a target module that doesn't exist yet, so emitting
188+
it would break the "every translated form type-checks" guarantee. It
189+
waits for a module-mapping story.
179190

180191
## Corpus run
181192

@@ -203,8 +214,9 @@ The fixture under `test/fixtures/sample.res` is synthetic and exercises
203214
every Phase-1 anti-pattern; `test/fixtures/phase2c.res` exercises the
204215
two anti-patterns that are walker-only by construction
205216
(`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).
217+
and `test/fixtures/phase3b.res` exercise the Phase-3 `--translate` path
218+
(aliases / sums / generics / records → compilable AffineScript, plus the
219+
qualified / mutable / optional / non-type forms it must skip).
208220
Real `.res` files
209221
from the estate (e.g. `gitbot-fleet/bots/sustainabot/bot-integration/
210222
src/*.res`) can be run ad hoc through the CLI without changes to the

tools/res-to-affine/test/fixtures/phase3.res

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
// type userId = int -> type UserId = Int
77
// type color = Red | ... -> type Color = | Red | Green | Blue
88
// 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).
9+
// The trailing `let`/`switch` is NOT a type declaration and stays a TODO
10+
// island (absent from the translation list). The generic `box` below now
11+
// translates too (slice 2: type parameters); the qualified-path decl stays
12+
// skipped (qualified-path RHS is deferred — it would parse but not resolve).
1313

1414
type userId = int
1515

@@ -22,10 +22,10 @@ type shape =
2222
| Circle(float)
2323
| Rect(int, int)
2424

25-
// Skipped in slice 1: type parameters (generic).
25+
// Slice 2: type parameters now translate -> type Box[A] = | Box(A)
2626
type box<'a> = Box('a)
2727

28-
// Skipped in slice 1: qualified-path RHS.
28+
// Skipped: qualified-path RHS (deferred).
2929
type theirMap = Belt.Map.t
3030

3131
// Not a type declaration: stays a TODO island.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// SPDX-License-Identifier: MIT
2+
// Phase 3 slice 2 fixture: record types (-> struct) and generics.
3+
// type point = {x: int, y: int} -> struct Point { x: Int, y: Int }
4+
// type box<'a> = {value: 'a} -> struct Box[A] { value: A }
5+
// type id<'a> = 'a -> type Id[A] = A
6+
// Records with `mutable` or optional `?` fields are SKIPPED (their
7+
// semantics can't be dropped).
8+
9+
type point = {
10+
x: int,
11+
y: int,
12+
}
13+
14+
type box<'a> = {
15+
value: 'a,
16+
}
17+
18+
type id<'a> = 'a
19+
20+
// SKIPPED: mutable field — AffineScript struct fields can't carry it.
21+
type counter = {
22+
mutable count: int,
23+
}
24+
25+
// SKIPPED: optional field — `?` has no struct equivalent.
26+
type config = {
27+
verbose?: bool,
28+
}

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

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -225,9 +225,18 @@ let translate_phase3_blob () =
225225

226226
let test_translate_count () =
227227
skip_unless_ready ();
228+
(* userId, color, shape, and (slice 2) the generic box — 4. theirMap
229+
(qualified) and the let/switch stay skipped. *)
228230
Alcotest.(check int)
229-
"exactly the three structural type decls are translated"
230-
3 (List.length (translate_phase3 ()))
231+
"four structural type decls are translated"
232+
4 (List.length (translate_phase3 ()))
233+
234+
let test_translate_generic_sum () =
235+
skip_unless_ready ();
236+
let blob = translate_phase3_blob () in
237+
Alcotest.(check bool)
238+
"generic sum -> type Box[A] = | Box(A)"
239+
true (contains blob "type Box[A] =" && contains blob "| Box(A)")
231240

232241
let test_translate_alias () =
233242
skip_unless_ready ();
@@ -256,15 +265,69 @@ let test_translate_payload_sum () =
256265
let test_translate_skips_non_structural () =
257266
skip_unless_ready ();
258267
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. *)
268+
(* the qualified Belt.Map.t and the let/switch must stay absent — the tool
269+
never guesses them; and no raw ReScript type-var ['a] leaks through. *)
261270
let leaked =
262-
contains blob "switch" || contains blob "area" || contains blob "Box"
271+
contains blob "switch" || contains blob "area"
263272
|| contains blob "Belt" || contains blob "'a"
264273
in
265-
Alcotest.(check bool) "non-structural / generic / qualified forms skipped"
274+
Alcotest.(check bool) "qualified / non-type forms skipped, no raw type-var"
266275
false leaked
267276

277+
(* ---- Phase 3 slice 2: records (-> struct) and generics --------------------
278+
279+
[fixtures/phase3b.res] holds a record (-> struct), a generic record
280+
(-> struct with type params), a generic alias, and two records that must
281+
be skipped (mutable + optional fields). *)
282+
283+
let phase3b_fixture = "fixtures/phase3b.res"
284+
285+
let translate_phase3b () =
286+
let source = read_file phase3b_fixture in
287+
let path = Filename.concat (Sys.getcwd ()) phase3b_fixture in
288+
Walker.translate ~grammar_dir:(grammar_dir ()) ~path ~source
289+
290+
let translate_phase3b_blob () =
291+
String.concat "\n" (List.map snd (translate_phase3b ()))
292+
293+
let test_translate_b_count () =
294+
skip_unless_ready ();
295+
(* point, box, id translate; counter (mutable) and config (optional) skip. *)
296+
Alcotest.(check int)
297+
"three of five record/generic decls translate"
298+
3 (List.length (translate_phase3b ()))
299+
300+
let test_translate_record () =
301+
skip_unless_ready ();
302+
let blob = translate_phase3b_blob () in
303+
let ok =
304+
contains blob "struct Point {" && contains blob "x: Int"
305+
&& contains blob "y: Int"
306+
in
307+
Alcotest.(check bool) "record -> struct with mapped field types" true ok
308+
309+
let test_translate_generic_record () =
310+
skip_unless_ready ();
311+
let blob = translate_phase3b_blob () in
312+
let ok = contains blob "struct Box[A] {" && contains blob "value: A" in
313+
Alcotest.(check bool) "generic record -> struct with type params" true ok
314+
315+
let test_translate_generic_alias () =
316+
skip_unless_ready ();
317+
Alcotest.(check bool)
318+
"generic alias -> type Id[A] = A"
319+
true (contains (translate_phase3b_blob ()) "type Id[A] = A")
320+
321+
let test_translate_b_skips () =
322+
skip_unless_ready ();
323+
let blob = translate_phase3b_blob () in
324+
(* mutable + optional records must be skipped, never silently flattened. *)
325+
let leaked =
326+
contains blob "mutable" || contains blob "count"
327+
|| contains blob "verbose" || contains blob "?"
328+
in
329+
Alcotest.(check bool) "mutable / optional records skipped" false leaked
330+
268331
let () =
269332
Alcotest.run "res-to-affine-walker"
270333
[
@@ -294,15 +357,30 @@ let () =
294357
] );
295358
( "walker-phase3-translate",
296359
[
297-
Alcotest.test_case "three structural type decls translated"
360+
Alcotest.test_case "four structural type decls translated"
298361
`Quick test_translate_count;
299362
Alcotest.test_case "primitive alias -> type UserId = Int"
300363
`Quick test_translate_alias;
301364
Alcotest.test_case "nullary sum -> leading-pipe variants"
302365
`Quick test_translate_nullary_sum;
303366
Alcotest.test_case "primitive-payload sum -> mapped params"
304367
`Quick test_translate_payload_sum;
305-
Alcotest.test_case "generic / qualified / non-type forms skipped"
368+
Alcotest.test_case "generic sum -> type Box[A] = | Box(A)"
369+
`Quick test_translate_generic_sum;
370+
Alcotest.test_case "qualified / non-type forms skipped"
306371
`Quick test_translate_skips_non_structural;
307372
] );
373+
( "walker-phase3b-records-generics",
374+
[
375+
Alcotest.test_case "three of five record/generic decls translate"
376+
`Quick test_translate_b_count;
377+
Alcotest.test_case "record -> struct"
378+
`Quick test_translate_record;
379+
Alcotest.test_case "generic record -> struct[A]"
380+
`Quick test_translate_generic_record;
381+
Alcotest.test_case "generic alias -> type Id[A] = A"
382+
`Quick test_translate_generic_alias;
383+
Alcotest.test_case "mutable / optional records skipped"
384+
`Quick test_translate_b_skips;
385+
] );
308386
]

0 commit comments

Comments
 (0)