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): 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)
- 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)
Expand Down
61 changes: 38 additions & 23 deletions tools/res-to-affine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ where re-decomposition is genuinely required.
Phase 3 is when the tool earns its keep on idaptik's 542 files.

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

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

It is **conservative by construction**: a declaration is translated only
when every part is representable — a qualified-path reference
(`Belt.Map.t`), a non-primitive/opaque reference, a nested generic
(`array<int>`), a GADT return, a variant spread, an object type, or a
record with a `mutable` or optional-`?` field causes the whole decl to be
*skipped* (it stays in the marker block + quoted original, never
mis-translated). Two normalisations make the output referenceable:
lower-case ReScript type names are capitalised (`color` → `Color`) and
type variables are mapped (`'a` → `A`), because `lib/parser.mly` reads a
lower-case name in type 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.

Deliberately **deferred to later Phase-3 slices**: `let`-to-`const` for
literal bindings, the `switch`→`match` expression rewrite (needs body
translation), and **module-qualified references** — these 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, so emitting
it would break the "every translated form type-checks" guarantee. It
waits for a module-mapping story.
(`array<int>`), a GADT return, a variant spread, an object type, a record
with a `mutable` or optional-`?` field, or a `let` whose body is not an
int/float/string/bool literal (a call, a `ref(...)` mutable-global, a
destructuring pattern) causes the whole decl to be *skipped* (it stays in
the marker block + quoted original, never mis-translated). Two
normalisations make the output referenceable: lower-case ReScript type
names are capitalised (`color` → `Color`) and type variables are mapped
(`'a` → `A`), because `lib/parser.mly` reads a lower-case name in type
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
[#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.

## Corpus run

Expand All @@ -213,10 +227,11 @@ cd tools/res-to-affine/test
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`
and `test/fixtures/phase3b.res` exercise the Phase-3 `--translate` path
(aliases / sums / generics / records → compilable AffineScript, plus the
qualified / mutable / optional / non-type forms it must skip).
(`inline-callback-record`, `oversized-function`); `test/fixtures/phase3.res`,
`phase3b.res`, and `phase3c.res` exercise the Phase-3 `--translate` path
(aliases / sums / generics / records / literal-`let`→`const` → compilable
AffineScript, plus the qualified / mutable / optional / non-literal forms it
must skip).
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
27 changes: 27 additions & 0 deletions tools/res-to-affine/test/fixtures/phase3c.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: MIT
// Phase 3 slice 3 fixture: module-level `let <id> = <literal>` -> `const`.
// let answer = 42 -> const answer: Int = 42;
// let pi = 3.14 -> const pi: Float = 3.14;
// let greeting = "hi" -> const greeting: String = "hi";
// let enabled = true -> const enabled: Bool = true;
// Non-literal / ref / destructuring bindings are SKIPPED (not compile-time
// constants, or not a plain identifier).

let answer = 42

let pi = 3.14

let greeting = "hi"

let enabled = true

let disabled = false

// SKIPPED: non-literal body (a call) — not a compile-time constant.
let now = Date.now()

// SKIPPED: ref body is the mutable-global anti-pattern, not a const.
let counter = ref(0)

// SKIPPED: destructuring pattern, not a plain identifier.
let (a, b) = (1, 2)
64 changes: 64 additions & 0 deletions tools/res-to-affine/test/test_walker.ml
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,59 @@ let test_translate_b_skips () =
in
Alcotest.(check bool) "mutable / optional records skipped" false leaked

(* ---- Phase 3 slice 3: `let <id> = <literal>` -> module-level `const` ------

[fixtures/phase3c.res] holds literal lets (int/float/string/bool) that
become `const`, plus a call-bodied let, a ref (mutable-global), and a
destructuring let that must all be skipped. *)

let phase3c_fixture = "fixtures/phase3c.res"

let translate_phase3c () =
let source = read_file phase3c_fixture in
let path = Filename.concat (Sys.getcwd ()) phase3c_fixture in
Walker.translate ~grammar_dir:(grammar_dir ()) ~path ~source

let translate_phase3c_blob () =
String.concat "\n" (List.map snd (translate_phase3c ()))

let test_translate_c_count () =
skip_unless_ready ();
(* answer, pi, greeting, enabled, disabled -> 5; now/counter/(a,b) skip. *)
Alcotest.(check int)
"five literal let-bindings translate to const"
5 (List.length (translate_phase3c ()))

let test_translate_const_int_float () =
skip_unless_ready ();
let blob = translate_phase3c_blob () in
let ok =
contains blob "const answer: Int = 42;"
&& contains blob "const pi: Float = 3.14;"
in
Alcotest.(check bool) "int + float literal -> typed const" true ok

let test_translate_const_string_bool () =
skip_unless_ready ();
let blob = translate_phase3c_blob () in
let ok =
contains blob "const greeting: String = \"hi\";"
&& contains blob "const enabled: Bool = true;"
&& contains blob "const disabled: Bool = false;"
in
Alcotest.(check bool) "string + bool literal -> typed const" true ok

let test_translate_c_skips () =
skip_unless_ready ();
let blob = translate_phase3c_blob () in
(* call / ref / destructuring bindings must never become a const. *)
let leaked =
contains blob "Date" || contains blob "ref(" || contains blob "const now"
|| contains blob "const counter" || contains blob "const a:"
in
Alcotest.(check bool) "non-literal / ref / destructuring lets skipped"
false leaked

let () =
Alcotest.run "res-to-affine-walker"
[
Expand Down Expand Up @@ -383,4 +436,15 @@ let () =
Alcotest.test_case "mutable / optional records skipped"
`Quick test_translate_b_skips;
] );
( "walker-phase3c-let-const",
[
Alcotest.test_case "five literal lets -> const"
`Quick test_translate_c_count;
Alcotest.test_case "int + float -> typed const"
`Quick test_translate_const_int_float;
Alcotest.test_case "string + bool -> typed const"
`Quick test_translate_const_string_bool;
Alcotest.test_case "call / ref / destructuring lets skipped"
`Quick test_translate_c_skips;
] );
]
72 changes: 69 additions & 3 deletions tools/res-to-affine/walker.ml
Original file line number Diff line number Diff line change
Expand Up @@ -826,11 +826,68 @@ let translate_type_binding ~source tb =
Some
(Printf.sprintf "type %s = %s" header rhs)))))))

(* Walk the tree; translate every module-top-level type_declaration's
bindings. Returns [(source_line, affinescript)] in tree order. *)
(* ---- value bindings: `let x = <literal>` -> `const x: T = <literal>;` -----

AffineScript has no module-level `let`; a top-level value binding is
`const name: Type = value;` (type annotation + terminating semicolon both
required). We translate only when the RHS is a literal whose type is
unambiguous — int / float / string / bool — so the inferred annotation is
sound and the emitted const type-checks standalone. A `ref(...)` body (the
mutable-global anti-pattern) is not a literal, so it is left untranslated
and still surfaces as a marker. Number forms are restricted to plain
decimal int and D+.D+ float; hex / octal / binary / signed / scientific /
underscored literals are skipped rather than risk a form the AffineScript
lexer might not accept. *)

let classify_number t =
let digits s =
String.length s > 0 && String.for_all (fun c -> c >= '0' && c <= '9') s
in
match String.index_opt t '.' with
| None -> if digits t then Some "Int" else None
| Some i ->
if String.contains_from t (i + 1) '.' then None
else
let intp = String.sub t 0 i in
let frac = String.sub t (i + 1) (String.length t - i - 1) in
if digits intp && digits frac then Some "Float" else None

(* Infer the AffineScript type + value text of a literal expression node, or
[None] if the body isn't a translatable literal. *)
let translate_literal ~source n =
match n.ntype with
| "number" -> (
match classify_number (node_text ~source n) with
| Some ty -> Some (ty, node_text ~source n)
| None -> None)
| "string" -> Some ("String", node_text ~source n)
| "true" | "false" -> Some ("Bool", node_text ~source n)
| _ -> None

(* Translate one let_binding whose pattern is a plain identifier and whose
body is a literal, to a module-level `const`. [None] otherwise (a
destructuring pattern, an underscore discard, or a non-literal body). *)
let translate_let_const ~source lb =
match child_with_field "pattern" lb with
| Some pat when pat.ntype = "value_identifier" -> (
match child_with_field "body" lb with
| None -> None
| Some body -> (
match translate_literal ~source body with
| None -> None
| Some (ty, value) ->
Some
(Printf.sprintf "const %s: %s = %s;"
(node_text ~source pat) ty value)))
| _ -> None

(* Walk the tree; translate every module-top-level type_declaration's bindings
and `let <id> = <literal>` value bindings. Returns [(source_line,
affinescript)] in tree order. *)
let rec collect_translations ~source ancestors acc node =
let acc =
if node.ntype = "type_declaration" && at_module_toplevel ancestors then
if not (at_module_toplevel ancestors) then acc
else if node.ntype = "type_declaration" then
List.fold_left
(fun acc c ->
if c.ntype = "type_binding" then
Expand All @@ -839,6 +896,15 @@ let rec collect_translations ~source ancestors acc node =
| None -> acc)
else acc)
acc node.children
else if node.ntype = "let_declaration" then
List.fold_left
(fun acc c ->
if c.ntype = "let_binding" then
(match translate_let_const ~source c with
| Some s -> (c.start.row + 1, s) :: acc
| None -> acc)
else acc)
acc node.children
else acc
in
List.fold_left
Expand Down
Loading