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 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)
- 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)
- feat(parser): trailing-comma in fn params and expr lists (Refs gitbot-fleet#148) (#370)
Expand Down
35 changes: 34 additions & 1 deletion tools/res-to-affine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ dune exec tools/res-to-affine/main.exe -- path/to/Foo.res
# or write to a file
dune exec tools/res-to-affine/main.exe -- path/to/Foo.res -o Foo.affine

# Phase 3 (slice 1): also translate fully-structural type declarations
# (primitive aliases + simple sum types) into compilable AffineScript
dune exec tools/res-to-affine/main.exe -- --translate path/to/Foo.res

# opt back into the Phase-1 line-regex scanner (no grammar required)
dune exec tools/res-to-affine/main.exe -- --engine=scanner path/to/Foo.res
```
Expand Down Expand Up @@ -147,6 +151,32 @@ where re-decomposition is genuinely required.

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

**Phase 3 slice 1 (`--translate`, landed).** The first translation slice
renders the two most mechanical, **module-qualified-path-independent**
shapes into compilable AffineScript:

| ReScript | AffineScript |
|---|---|
| `type userId = int` | `type UserId = Int` |
| `type color = Red \| Green \| Blue` | `type Color =`<br>` \| Red`<br>` \| Green`<br>` \| Blue` |
| `type shape = Circle(float) \| Rect(int, int)` | `type Shape =`<br>` \| Circle(Float)`<br>` \| Rect(Int, Int)` |

It is **conservative by construction**: a declaration that uses type
parameters/generics, a qualified path (`Belt.Map.t`), a record body,
non-primitive references, a GADT return annotation, or a variant spread
is *skipped* — it stays in the marker block + quoted original, never
mis-translated. Lower-case ReScript type names are capitalised so they
are referenceable AffineScript type constructors (`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**: record types, generic
type declarations, module-qualified references (now that the
[#228](https://github.com/hyperpolymath/affinescript/issues/228) grammar
gap is closed), `let`-to-`const` for literal bindings, and the
`switch`→`match` expression rewrite (which needs body translation).

## Corpus run

[`CORPUS-RUN.md`](CORPUS-RUN.md) records the first end-to-end run
Expand All @@ -172,7 +202,10 @@ 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`). Real `.res` files
(`inline-callback-record`, `oversized-function`); `test/fixtures/phase3.res`
exercises the Phase-3 `--translate` slice (structural type-declaration
translation, plus the generic/qualified/non-type 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
test suite.
Expand Down
35 changes: 35 additions & 0 deletions tools/res-to-affine/emitter.ml
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,38 @@ let emit ~module_name ~source_path ~source ~findings =
add (quote_block source);
add "\n";
Buffer.contents buf

let emit_translation ~module_name ~source_path ~source ~findings ~translated =
let buf = Buffer.create 4096 in
let add s = Buffer.add_string buf s in
add "// SPDX-License-Identifier: MPL-2.0\n";
add "// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell\n";
add "//\n";
add (Printf.sprintf
"// Generated by tools/res-to-affine from %s (--translate, Phase 3)\n"
source_path);
add "// PARTIAL PORT (Phase 3, slice 1): the fully-structural type\n";
add "// declarations below are translated to compilable AffineScript; every\n";
add "// other form is left as a TODO island — the markers and the quoted\n";
add "// original record what still needs porting. Review before relying on it.\n";
add "//\n";
add (summarise_findings findings);
add "\n\n";
add (Printf.sprintf "module %s;\n\n" module_name);
if translated = [] then begin
add "// (no fully-structural type declarations were translatable here;\n";
add "// see the markers above and the original below.)\n"
end else
List.iter
(fun (line, code) ->
add (Printf.sprintf "// from .res line %d\n" line);
add code;
add "\n\n")
translated;
add "// TODO: port the remaining forms. The original ReScript is retained\n";
add "// below; re-decompose effectful / stateful / exception paths\n";
add "// per the markers before deleting it.\n";
add "\n";
add (quote_block source);
add "\n";
Buffer.contents buf
13 changes: 13 additions & 0 deletions tools/res-to-affine/emitter.mli
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,16 @@ val emit :
findings:Scanner.finding list ->
string
(** Render the skeleton. The result is a complete file contents string. *)

val emit_translation :
module_name:string ->
source_path:string ->
source:string ->
findings:Scanner.finding list ->
translated:(int * string) list ->
string
(** Render the Phase-3 partial port: the same marker block as {!emit}, a
proper [module Name;] header, then each translated structural type
declaration ([translated] is the [(source_line, affinescript)] list
from {!Walker.translate}, in source order), then a TODO note and the
quoted original. Used for [--translate]; {!emit} is unchanged. *)
58 changes: 46 additions & 12 deletions tools/res-to-affine/main.ml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
migration markers. The original source is quoted at the bottom of
the output so the human migrating the file has it side-by-side.

Phase 1 (this binary) uses a text scanner. Phase 2 swaps the
[Scanner] implementation for a tree-sitter AST walker reading the
vendored grammar at [editors/tree-sitter-rescript/]. See the
tool README for the full plan. *)
Detection defaults to the Phase-2c tree-sitter AST [Walker] (the
vendored grammar at [editors/tree-sitter-rescript/]); [--engine=scanner]
falls back to the Phase-1 line [Scanner]. With [--translate], the
Phase-3 slice additionally renders fully-structural type declarations
into compilable AffineScript inline in the skeleton. See the tool
README for the full plan. *)

open Res_to_affine

Expand All @@ -33,7 +35,7 @@ let engine_label = function
| Scanner_engine -> "scanner"
| Walker_engine -> "walker"

let run engine grammar_dir input output_opt =
let run engine grammar_dir do_translate input output_opt =
if not (Sys.file_exists input) then begin
Format.eprintf "res-to-affine: input not found: %s@." input;
exit 2
Expand All @@ -51,23 +53,44 @@ let run engine grammar_dir input output_opt =
input;
Scanner.scan source)
in
(* Phase 3: translation needs the AST, so it is walker-only. With the
scanner (or a walker that failed and would fall back), no translation
is emitted — the marker block + quoted original still carry the file. *)
let translated =
if not do_translate then []
else
match engine with
| Scanner_engine ->
Format.eprintf
"res-to-affine: --translate needs the walker engine; \
no translation emitted for %s@." input;
[]
| Walker_engine ->
(try Walker.translate ~grammar_dir ~path:input ~source with
| Failure msg ->
Format.eprintf "res-to-affine: %s@." msg;
Format.eprintf
"res-to-affine: no translation emitted for %s@." input;
[])
in
let module_name = Emitter.module_name_of_path input in
let out =
Emitter.emit
~module_name
~source_path:input
~source
~findings
if do_translate then
Emitter.emit_translation
~module_name ~source_path:input ~source ~findings ~translated
else
Emitter.emit ~module_name ~source_path:input ~source ~findings
in
match output_opt with
| None ->
print_string out
| Some path ->
write_file path out;
Format.printf
"res-to-affine: %d finding%s [%s] → %s@."
"res-to-affine: %d finding%s, %d translated [%s] → %s@."
(List.length findings)
(if List.length findings = 1 then "" else "s")
(List.length translated)
(engine_label engine)
path

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

let translate_arg =
let doc =
"Phase 3 (slice 1): additionally translate fully-structural type \
declarations — primitive aliases and simple sum types — into \
compilable AffineScript, inline in the skeleton. Every other form \
stays a TODO island. Needs `--engine=walker` (the default); with the \
scanner engine it is a no-op."
in
Cmdliner.Arg.(value & flag & info ["translate"] ~doc)

let cmd =
let doc = "Emit an AffineScript skeleton from a ReScript source file." in
let info = Cmdliner.Cmd.info "res-to-affine" ~version:"0.1.0" ~doc in
let term =
Cmdliner.Term.(
const run $ engine_arg $ grammar_dir_arg $ input_arg $ output_arg)
const run $ engine_arg $ grammar_dir_arg $ translate_arg
$ input_arg $ output_arg)
in
Cmdliner.Cmd.v info term

Expand Down
36 changes: 36 additions & 0 deletions tools/res-to-affine/test/fixtures/phase3.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: MIT
// Synthetic fixture for Phase 3 slice 1: structural type-declaration
// translation. The first three type declarations are fully structural
// and #228-independent, so the walker's --translate path renders them
// as compilable AffineScript:
// type userId = int -> type UserId = Int
// type color = Red | ... -> type Color = | Red | Green | Blue
// type shape = Circle(...) -> type Shape = | Circle(Float) | Rect(Int, Int)
// The trailing `let`/`switch` is NOT a type declaration and must remain a
// TODO island (absent from the translation list). The generic and
// qualified type decls below must also be skipped (slice 1 is monomorphic
// and unqualified).

type userId = int

type color =
| Red
| Green
| Blue

type shape =
| Circle(float)
| Rect(int, int)

// Skipped in slice 1: type parameters (generic).
type box<'a> = Box('a)

// Skipped in slice 1: qualified-path RHS.
type theirMap = Belt.Map.t

// Not a type declaration: stays a TODO island.
let area = s =>
switch s {
| Circle(r) => r *. r
| Rect(w, h) => float_of_int(w * h)
}
84 changes: 84 additions & 0 deletions tools/res-to-affine/test/test_walker.ml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ let read_file path =
close_in ic;
s

(* Substring search — OCaml's [String] only has char membership. *)
let contains haystack needle =
let hn = String.length haystack and nn = String.length needle in
if nn = 0 then true
else
let rec loop i =
if i + nn > hn then false
else if String.sub haystack i nn = needle then true
else loop (i + 1)
in
loop 0

let tree_sitter_available () =
Sys.command "command -v tree-sitter > /dev/null 2>&1" = 0

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

(* ---- Phase 3 slice 1: structural type-declaration translation -------------

[fixtures/phase3.res] holds three translatable structural type decls
(a primitive alias and two simple sum types) plus forms that slice 1
must skip (a generic, a qualified-path alias, and a let/switch). These
gated tests assert the translator's output structurally. *)

let phase3_fixture = "fixtures/phase3.res"

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

let translate_phase3_blob () =
String.concat "\n" (List.map snd (translate_phase3 ()))

let test_translate_count () =
skip_unless_ready ();
Alcotest.(check int)
"exactly the three structural type decls are translated"
3 (List.length (translate_phase3 ()))

let test_translate_alias () =
skip_unless_ready ();
Alcotest.(check bool)
"primitive alias -> capitalised TyCon + Int"
true (contains (translate_phase3_blob ()) "type UserId = Int")

let test_translate_nullary_sum () =
skip_unless_ready ();
let blob = translate_phase3_blob () in
let ok =
contains blob "type Color =" && contains blob "| Red"
&& contains blob "| Green" && contains blob "| Blue"
in
Alcotest.(check bool) "nullary sum -> leading-pipe variant form" true ok

let test_translate_payload_sum () =
skip_unless_ready ();
let blob = translate_phase3_blob () in
let ok =
contains blob "type Shape =" && contains blob "| Circle(Float)"
&& contains blob "| Rect(Int, Int)"
in
Alcotest.(check bool) "primitive-payload sum -> mapped param types" true ok

let test_translate_skips_non_structural () =
skip_unless_ready ();
let blob = translate_phase3_blob () in
(* the generic Box, qualified Belt.Map.t, and the let/switch must all be
absent from the translation — slice 1 never guesses them. *)
let leaked =
contains blob "switch" || contains blob "area" || contains blob "Box"
|| contains blob "Belt" || contains blob "'a"
in
Alcotest.(check bool) "non-structural / generic / qualified forms skipped"
false leaked

let () =
Alcotest.run "res-to-affine-walker"
[
Expand Down Expand Up @@ -221,4 +292,17 @@ let () =
Alcotest.test_case "oversized-function found on phase2c.res"
`Quick test_walker_finds_oversized_function;
] );
( "walker-phase3-translate",
[
Alcotest.test_case "three structural type decls translated"
`Quick test_translate_count;
Alcotest.test_case "primitive alias -> type UserId = Int"
`Quick test_translate_alias;
Alcotest.test_case "nullary sum -> leading-pipe variants"
`Quick test_translate_nullary_sum;
Alcotest.test_case "primitive-payload sum -> mapped params"
`Quick test_translate_payload_sum;
Alcotest.test_case "generic / qualified / non-type forms skipped"
`Quick test_translate_skips_non_structural;
] );
]
Loading
Loading