diff --git a/CHANGELOG.md b/CHANGELOG.md index 34efce0..bfd6568 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 = ` (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) diff --git a/tools/res-to-affine/README.md b/tools/res-to-affine/README.md index 2907bfe..4a37fce 100644 --- a/tools/res-to-affine/README.md +++ b/tools/res-to-affine/README.md @@ -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*). @@ -165,28 +165,42 @@ generated form below is verified by the compiler itself (`main.exe check` | `type box<'a> = {value: 'a}` | `struct Box[A] {`
` value: A`
`}` | 2 | | `type option<'a> = None \| Some('a)` | `type Option[A] =`
` \| None`
` \| 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`), 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`), 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 @@ -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 diff --git a/tools/res-to-affine/test/fixtures/phase3c.res b/tools/res-to-affine/test/fixtures/phase3c.res new file mode 100644 index 0000000..c416616 --- /dev/null +++ b/tools/res-to-affine/test/fixtures/phase3c.res @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +// Phase 3 slice 3 fixture: module-level `let = ` -> `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) diff --git a/tools/res-to-affine/test/test_walker.ml b/tools/res-to-affine/test/test_walker.ml index 4a6ff15..98c6090 100644 --- a/tools/res-to-affine/test/test_walker.ml +++ b/tools/res-to-affine/test/test_walker.ml @@ -328,6 +328,59 @@ let test_translate_b_skips () = in Alcotest.(check bool) "mutable / optional records skipped" false leaked +(* ---- Phase 3 slice 3: `let = ` -> 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" [ @@ -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; + ] ); ] diff --git a/tools/res-to-affine/walker.ml b/tools/res-to-affine/walker.ml index bd2437b..8bbdc10 100644 --- a/tools/res-to-affine/walker.ml +++ b/tools/res-to-affine/walker.ml @@ -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 = ` -> `const x: T = ;` ----- + + 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 = ` 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 @@ -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