Skip to content

Commit ea8bef5

Browse files
feat(res-to-affine): Phase 3 slice 3 — literal let→const translation (Refs #57) (#484)
Phase 3 slice 3: --translate lowers module-level `let <id> = <literal>` (int/float/string/bool) to a typed AffineScript `const name: T = value;`; call / ref / destructuring / exotic-number bindings are skipped (not compile-time constants), never mis-translated. switch->match and qualified-path resolution remain out of the standalone-type-check scope. Every emitted const verified via main.exe check. Refs #57.
1 parent 138baac commit ea8bef5

5 files changed

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

tools/res-to-affine/README.md

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ where re-decomposition is genuinely required.
152152
Phase 3 is when the tool earns its keep on idaptik's 542 files.
153153

154154
**Phase 3 (`--translate`, landed).** The translation path renders the
155-
fully-structural type declarations into compilable AffineScript. Every
155+
self-contained, top-level declarations into compilable AffineScript. Every
156156
generated form below is verified by the compiler itself (`main.exe check`
157157
*Type checking passed*).
158158

@@ -165,28 +165,42 @@ generated form below is verified by the compiler itself (`main.exe check`
165165
| `type box<'a> = {value: 'a}` | `struct Box[A] {`<br>` value: A`<br>`}` | 2 |
166166
| `type option<'a> = None \| Some('a)` | `type Option[A] =`<br>` \| None`<br>` \| Some(A)` | 2 |
167167
| `type id<'a> = 'a` | `type Id[A] = A` | 2 |
168+
| `let answer = 42` | `const answer: Int = 42;` | 3 |
169+
| `let pi = 3.14` | `const pi: Float = 3.14;` | 3 |
170+
| `let greeting = "hi"` | `const greeting: String = "hi";` | 3 |
171+
| `let enabled = true` | `const enabled: Bool = true;` | 3 |
168172

169173
It is **conservative by construction**: a declaration is translated only
170174
when every part is representable — a qualified-path reference
171175
(`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.
176+
(`array<int>`), a GADT return, a variant spread, an object type, a record
177+
with a `mutable` or optional-`?` field, or a `let` whose body is not an
178+
int/float/string/bool literal (a call, a `ref(...)` mutable-global, a
179+
destructuring pattern) causes the whole decl to be *skipped* (it stays in
180+
the marker block + quoted original, never mis-translated). Two
181+
normalisations make the output referenceable: lower-case ReScript type
182+
names are capitalised (`color``Color`) and type variables are mapped
183+
(`'a``A`), because `lib/parser.mly` reads a lower-case name in type
184+
position as a type *variable*, not a constructor. Translation is
185+
walker-only (it needs the AST); with `--engine=scanner` the flag is a no-op.
186+
187+
**Scope boundary — what `--translate` will *not* do.** The guarantee is
188+
"every emitted form type-checks standalone", which is why translation is
189+
limited to self-contained top-level *declarations* (types, structs,
190+
literal consts). Two forms are out of that scope by construction:
191+
192+
- **`switch``match`** is an *expression*, only meaningful inside a
193+
function body. Emitting a type-checkable `match` means translating the
194+
whole enclosing function — but ReScript function bindings are usually
195+
un-annotated (`let f = x => …`), and AffineScript `fn` requires parameter
196+
and return types, so the result wouldn't type-check. It belongs to a
197+
future *partial-port* mode (translate-with-TODO-holes) that drops the
198+
standalone-type-check guarantee, not to this declaration translator.
199+
- **module-qualified references** now *parse* (the
200+
[#228](https://github.com/hyperpolymath/affinescript/issues/228) grammar
201+
gap closed), but a faithful `Belt.Map.t``Belt::Map::T` would not
202+
*resolve* against a target module that doesn't exist yet — it waits for a
203+
module-mapping story.
190204

191205
## Corpus run
192206

@@ -213,10 +227,11 @@ cd tools/res-to-affine/test
213227
The fixture under `test/fixtures/sample.res` is synthetic and exercises
214228
every Phase-1 anti-pattern; `test/fixtures/phase2c.res` exercises the
215229
two anti-patterns that are walker-only by construction
216-
(`inline-callback-record`, `oversized-function`); `test/fixtures/phase3.res`
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).
230+
(`inline-callback-record`, `oversized-function`); `test/fixtures/phase3.res`,
231+
`phase3b.res`, and `phase3c.res` exercise the Phase-3 `--translate` path
232+
(aliases / sums / generics / records / literal-`let``const` → compilable
233+
AffineScript, plus the qualified / mutable / optional / non-literal forms it
234+
must skip).
220235
Real `.res` files
221236
from the estate (e.g. `gitbot-fleet/bots/sustainabot/bot-integration/
222237
src/*.res`) can be run ad hoc through the CLI without changes to the
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// SPDX-License-Identifier: MIT
2+
// Phase 3 slice 3 fixture: module-level `let <id> = <literal>` -> `const`.
3+
// let answer = 42 -> const answer: Int = 42;
4+
// let pi = 3.14 -> const pi: Float = 3.14;
5+
// let greeting = "hi" -> const greeting: String = "hi";
6+
// let enabled = true -> const enabled: Bool = true;
7+
// Non-literal / ref / destructuring bindings are SKIPPED (not compile-time
8+
// constants, or not a plain identifier).
9+
10+
let answer = 42
11+
12+
let pi = 3.14
13+
14+
let greeting = "hi"
15+
16+
let enabled = true
17+
18+
let disabled = false
19+
20+
// SKIPPED: non-literal body (a call) — not a compile-time constant.
21+
let now = Date.now()
22+
23+
// SKIPPED: ref body is the mutable-global anti-pattern, not a const.
24+
let counter = ref(0)
25+
26+
// SKIPPED: destructuring pattern, not a plain identifier.
27+
let (a, b) = (1, 2)

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,59 @@ let test_translate_b_skips () =
328328
in
329329
Alcotest.(check bool) "mutable / optional records skipped" false leaked
330330

331+
(* ---- Phase 3 slice 3: `let <id> = <literal>` -> module-level `const` ------
332+
333+
[fixtures/phase3c.res] holds literal lets (int/float/string/bool) that
334+
become `const`, plus a call-bodied let, a ref (mutable-global), and a
335+
destructuring let that must all be skipped. *)
336+
337+
let phase3c_fixture = "fixtures/phase3c.res"
338+
339+
let translate_phase3c () =
340+
let source = read_file phase3c_fixture in
341+
let path = Filename.concat (Sys.getcwd ()) phase3c_fixture in
342+
Walker.translate ~grammar_dir:(grammar_dir ()) ~path ~source
343+
344+
let translate_phase3c_blob () =
345+
String.concat "\n" (List.map snd (translate_phase3c ()))
346+
347+
let test_translate_c_count () =
348+
skip_unless_ready ();
349+
(* answer, pi, greeting, enabled, disabled -> 5; now/counter/(a,b) skip. *)
350+
Alcotest.(check int)
351+
"five literal let-bindings translate to const"
352+
5 (List.length (translate_phase3c ()))
353+
354+
let test_translate_const_int_float () =
355+
skip_unless_ready ();
356+
let blob = translate_phase3c_blob () in
357+
let ok =
358+
contains blob "const answer: Int = 42;"
359+
&& contains blob "const pi: Float = 3.14;"
360+
in
361+
Alcotest.(check bool) "int + float literal -> typed const" true ok
362+
363+
let test_translate_const_string_bool () =
364+
skip_unless_ready ();
365+
let blob = translate_phase3c_blob () in
366+
let ok =
367+
contains blob "const greeting: String = \"hi\";"
368+
&& contains blob "const enabled: Bool = true;"
369+
&& contains blob "const disabled: Bool = false;"
370+
in
371+
Alcotest.(check bool) "string + bool literal -> typed const" true ok
372+
373+
let test_translate_c_skips () =
374+
skip_unless_ready ();
375+
let blob = translate_phase3c_blob () in
376+
(* call / ref / destructuring bindings must never become a const. *)
377+
let leaked =
378+
contains blob "Date" || contains blob "ref(" || contains blob "const now"
379+
|| contains blob "const counter" || contains blob "const a:"
380+
in
381+
Alcotest.(check bool) "non-literal / ref / destructuring lets skipped"
382+
false leaked
383+
331384
let () =
332385
Alcotest.run "res-to-affine-walker"
333386
[
@@ -383,4 +436,15 @@ let () =
383436
Alcotest.test_case "mutable / optional records skipped"
384437
`Quick test_translate_b_skips;
385438
] );
439+
( "walker-phase3c-let-const",
440+
[
441+
Alcotest.test_case "five literal lets -> const"
442+
`Quick test_translate_c_count;
443+
Alcotest.test_case "int + float -> typed const"
444+
`Quick test_translate_const_int_float;
445+
Alcotest.test_case "string + bool -> typed const"
446+
`Quick test_translate_const_string_bool;
447+
Alcotest.test_case "call / ref / destructuring lets skipped"
448+
`Quick test_translate_c_skips;
449+
] );
386450
]

tools/res-to-affine/walker.ml

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -826,11 +826,68 @@ let translate_type_binding ~source tb =
826826
Some
827827
(Printf.sprintf "type %s = %s" header rhs)))))))
828828

829-
(* Walk the tree; translate every module-top-level type_declaration's
830-
bindings. Returns [(source_line, affinescript)] in tree order. *)
829+
(* ---- value bindings: `let x = <literal>` -> `const x: T = <literal>;` -----
830+
831+
AffineScript has no module-level `let`; a top-level value binding is
832+
`const name: Type = value;` (type annotation + terminating semicolon both
833+
required). We translate only when the RHS is a literal whose type is
834+
unambiguous — int / float / string / bool — so the inferred annotation is
835+
sound and the emitted const type-checks standalone. A `ref(...)` body (the
836+
mutable-global anti-pattern) is not a literal, so it is left untranslated
837+
and still surfaces as a marker. Number forms are restricted to plain
838+
decimal int and D+.D+ float; hex / octal / binary / signed / scientific /
839+
underscored literals are skipped rather than risk a form the AffineScript
840+
lexer might not accept. *)
841+
842+
let classify_number t =
843+
let digits s =
844+
String.length s > 0 && String.for_all (fun c -> c >= '0' && c <= '9') s
845+
in
846+
match String.index_opt t '.' with
847+
| None -> if digits t then Some "Int" else None
848+
| Some i ->
849+
if String.contains_from t (i + 1) '.' then None
850+
else
851+
let intp = String.sub t 0 i in
852+
let frac = String.sub t (i + 1) (String.length t - i - 1) in
853+
if digits intp && digits frac then Some "Float" else None
854+
855+
(* Infer the AffineScript type + value text of a literal expression node, or
856+
[None] if the body isn't a translatable literal. *)
857+
let translate_literal ~source n =
858+
match n.ntype with
859+
| "number" -> (
860+
match classify_number (node_text ~source n) with
861+
| Some ty -> Some (ty, node_text ~source n)
862+
| None -> None)
863+
| "string" -> Some ("String", node_text ~source n)
864+
| "true" | "false" -> Some ("Bool", node_text ~source n)
865+
| _ -> None
866+
867+
(* Translate one let_binding whose pattern is a plain identifier and whose
868+
body is a literal, to a module-level `const`. [None] otherwise (a
869+
destructuring pattern, an underscore discard, or a non-literal body). *)
870+
let translate_let_const ~source lb =
871+
match child_with_field "pattern" lb with
872+
| Some pat when pat.ntype = "value_identifier" -> (
873+
match child_with_field "body" lb with
874+
| None -> None
875+
| Some body -> (
876+
match translate_literal ~source body with
877+
| None -> None
878+
| Some (ty, value) ->
879+
Some
880+
(Printf.sprintf "const %s: %s = %s;"
881+
(node_text ~source pat) ty value)))
882+
| _ -> None
883+
884+
(* Walk the tree; translate every module-top-level type_declaration's bindings
885+
and `let <id> = <literal>` value bindings. Returns [(source_line,
886+
affinescript)] in tree order. *)
831887
let rec collect_translations ~source ancestors acc node =
832888
let acc =
833-
if node.ntype = "type_declaration" && at_module_toplevel ancestors then
889+
if not (at_module_toplevel ancestors) then acc
890+
else if node.ntype = "type_declaration" then
834891
List.fold_left
835892
(fun acc c ->
836893
if c.ntype = "type_binding" then
@@ -839,6 +896,15 @@ let rec collect_translations ~source ancestors acc node =
839896
| None -> acc)
840897
else acc)
841898
acc node.children
899+
else if node.ntype = "let_declaration" then
900+
List.fold_left
901+
(fun acc c ->
902+
if c.ntype = "let_binding" then
903+
(match translate_let_const ~source c with
904+
| Some s -> (c.start.row + 1, s) :: acc
905+
| None -> acc)
906+
else acc)
907+
acc node.children
842908
else acc
843909
in
844910
List.fold_left

0 commit comments

Comments
 (0)