Skip to content

Commit 925edeb

Browse files
hyperpolymathclaude
andcommitted
feat(stdlib/typecheck): C-proper string primitives + polymorphic ++ (Refs #122)
Option-C enabler: make AffineScript-level string logic compile through the static pipeline + Deno-ESM backend, resting on a thin honest host-primitive layer instead of bespoke per-op externs. typecheck: - `++` (OpConcat) is now polymorphic over String and [T] — dispatched on the synthesised lhs type. Was String-only, so `acc ++ [x]` (how stdlib/string.affine's split/join accumulate) failed `Unify(Array, String)`. - `len` broadened from `Array[t]->Int` to `'a->Int` (stdlib/string calls it on String and arrays; matches the interpreter + the `.length` lowering). - Bound the honest string/char primitives: string_get/string_sub/ string_find/to_lowercase/to_uppercase/trim/parse_int/parse_float/ char_to_int/int_to_char/show (resolve.ml already knew them). codegen_deno: - Lower those primitives + `++` (shape-dispatched `__as_concat`, so array concat isn't string-coerced) to JS intrinsics. The lowering table now applies to ANY matching call head (not only declared externs) so AffineScript-level stdlib resolves... - ...EXCEPT a same-named user definition shadows the intrinsic (new ctx.local_fns guard) — fixes a user `fn len(xs: IntList)` being hijacked into `.length` (NaN). Net: ends_with/strip_suffix/find/int_to_string/string-&-array `++` all work as real AffineScript over the primitive layer (tests/codegen-deno/string_prims.{affine,harness.mjs}). Full Deno-ESM suite green (5 harnesses); `dune runtest` unchanged (same 2 pre-existing E2E Node-CJS vscode failures, 214 tests, zero new regressions). Note: stdlib/string.affine itself still has a separate Resolution error (angle-generic `Option<Char>` + unimported Some/None) — that file's own issue, not the primitive layer; addressed in the consumer migration. Refs #122. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 36e7a3d commit 925edeb

4 files changed

Lines changed: 169 additions & 15 deletions

File tree

lib/codegen_deno.ml

Lines changed: 74 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ type codegen_ctx = {
5959
receiver-first free-function source style (the only form the current
6060
grammar accepts — no [self]/inherent-[impl]) read as real methods. *)
6161
assoc : (string, string) Hashtbl.t;
62+
(* Top-level functions/consts defined in this program. A user
63+
definition shadows a same-named host intrinsic ({!deno_builtins}),
64+
so e.g. a user `fn len(xs: IntList)` is NOT lowered to `.length`. *)
65+
local_fns : (string, unit) Hashtbl.t;
6266
}
6367

6468
let create_ctx symbols = {
@@ -68,6 +72,7 @@ let create_ctx symbols = {
6872
externs = Hashtbl.create 32;
6973
self_name = None;
7074
assoc = Hashtbl.create 32;
75+
local_fns = Hashtbl.create 64;
7176
}
7277

7378
let emit ctx str = Buffer.add_string ctx.output str
@@ -117,11 +122,29 @@ const __as_readDirNames = (p) => {
117122
return names;
118123
};
119124
const __as_isNotFound = (e) => (e instanceof Deno.errors.NotFound);
120-
const __as_endsWith = (s, suffix) => String(s).endsWith(suffix);
121-
const __as_stripSuffix = (s, suffix) =>
122-
String(s).endsWith(suffix) ? String(s).slice(0, -suffix.length) : String(s);
123125
const __as_wasmInstance = (bytes) =>
124126
new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports;
127+
// `++` is overloaded (string concat / array concat); `a + b` would
128+
// stringify arrays. Dispatch on shape so stdlib/string.affine's
129+
// `result ++ [x]` and `a ++ b` are both correct.
130+
const __as_concat = (a, b) => Array.isArray(a) ? a.concat(b) : (a + b);
131+
// Honest host/runtime primitives underpinning the AffineScript-level
132+
// stdlib/string.affine (its is_empty/starts_with/ends_with/split/join/
133+
// replace/... are real AffineScript on top of these).
134+
const __as_strSub = (s, start, n) => String(s).slice(start, start + n);
135+
const __as_strGet = (s, i) => String(s)[i];
136+
const __as_strFind = (s, n) => String(s).indexOf(n);
137+
const __as_charToInt = (c) => String(c).codePointAt(0);
138+
const __as_intToChar = (n) => String.fromCodePoint(n);
139+
const __as_parseInt = (s) => {
140+
const n = parseInt(String(s), 10);
141+
return Number.isNaN(n) ? None : Some(n);
142+
};
143+
const __as_parseFloat = (s) => {
144+
const n = parseFloat(String(s));
145+
return Number.isNaN(n) ? None : Some(n);
146+
};
147+
const __as_show = (v) => (typeof v === "string" ? v : JSON.stringify(v));
125148
// ---- end runtime ----
126149

127150
|}
@@ -154,12 +177,29 @@ let () =
154177
b "jsonParse" (fun a -> Printf.sprintf "JSON.parse(%s)" (arg 0 a));
155178
(* ---- misc host ---- *)
156179
b "dateNow" (fun _ -> "Date.now()");
157-
b "numToFixed2" (fun a -> Printf.sprintf "(Number(%s) / 1024).toFixed(2)" (arg 0 a));
158-
b "endsWith" (fun a -> Printf.sprintf "__as_endsWith(%s, %s)" (arg 0 a) (arg 1 a));
159-
b "stripSuffix" (fun a -> Printf.sprintf "__as_stripSuffix(%s, %s)" (arg 0 a) (arg 1 a));
160180
b "wasmInstance" (fun a -> Printf.sprintf "__as_wasmInstance(%s)" (arg 0 a));
161181
(* Generic JS array push helper (returns the array, fluent). *)
162-
b "arrayPush" (fun a -> Printf.sprintf "(%s.push(%s), %s)" (arg 0 a) (arg 1 a) (arg 0 a))
182+
b "arrayPush" (fun a -> Printf.sprintf "(%s.push(%s), %s)" (arg 0 a) (arg 1 a) (arg 0 a));
183+
(* ---- honest string/number primitives underpinning the
184+
AffineScript-level stdlib/string.affine. These are intrinsics (no
185+
AffineScript definition exists; the interpreter binds them too),
186+
not externs — endsWith/stripSuffix/pathJoin/etc. are NOT here:
187+
they are real AffineScript built on `ends_with`/`substring`/`++`. *)
188+
b "len" (fun a -> Printf.sprintf "((%s).length)" (arg 0 a));
189+
b "string_length" (fun a -> Printf.sprintf "((%s).length)" (arg 0 a));
190+
b "string_get" (fun a -> Printf.sprintf "__as_strGet(%s, %s)" (arg 0 a) (arg 1 a));
191+
b "string_sub" (fun a -> Printf.sprintf "__as_strSub(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a));
192+
b "string_find" (fun a -> Printf.sprintf "__as_strFind(%s, %s)" (arg 0 a) (arg 1 a));
193+
b "to_lowercase" (fun a -> Printf.sprintf "String(%s).toLowerCase()" (arg 0 a));
194+
b "to_uppercase" (fun a -> Printf.sprintf "String(%s).toUpperCase()" (arg 0 a));
195+
b "trim" (fun a -> Printf.sprintf "String(%s).trim()" (arg 0 a));
196+
b "int_to_string" (fun a -> Printf.sprintf "String(%s)" (arg 0 a));
197+
b "float_to_string" (fun a -> Printf.sprintf "String(%s)" (arg 0 a));
198+
b "parse_int" (fun a -> Printf.sprintf "__as_parseInt(%s)" (arg 0 a));
199+
b "parse_float" (fun a -> Printf.sprintf "__as_parseFloat(%s)" (arg 0 a));
200+
b "char_to_int" (fun a -> Printf.sprintf "__as_charToInt(%s)" (arg 0 a));
201+
b "int_to_char" (fun a -> Printf.sprintf "__as_intToChar(%s)" (arg 0 a));
202+
b "show" (fun a -> Printf.sprintf "__as_show(%s)" (arg 0 a))
163203

164204
(* ============================================================================
165205
Identifier sanitisation (JS reserved words -> trailing underscore)
@@ -220,23 +260,34 @@ let rec gen_expr ctx (expr : expr) : string =
220260
an expression sub-term, so await it (valid: it only occurs
221261
inside the [async] method bodies we emit). *)
222262
"(await " ^ recv ^ "." ^ m ^ "(" ^ String.concat ", " rest ^ "))"
263+
| ExprVar id
264+
when Hashtbl.mem deno_builtins id.name
265+
&& not (Hashtbl.mem ctx.local_fns id.name) ->
266+
(* Honest host/runtime intrinsic (FS/JSON/Date/Wasm extern or
267+
a string/number primitive underpinning stdlib/string.affine).
268+
Applied to ANY matching call head, not only declared externs,
269+
so AffineScript-level stdlib compiled here resolves — but a
270+
same-named user definition shadows it (e.g. a user `len`). *)
271+
(Hashtbl.find deno_builtins id.name) (List.map (gen_expr ctx) args)
223272
| ExprVar id when Hashtbl.mem ctx.externs id.name ->
273+
(* Declared extern with no intrinsic lowering: assume a
274+
same-named host symbol is in scope. *)
224275
let arg_strs = List.map (gen_expr ctx) args in
225-
(match Hashtbl.find_opt deno_builtins id.name with
226-
| Some lower -> lower arg_strs
227-
| None ->
228-
(* Unknown extern: assume a same-named host symbol in scope. *)
229-
mangle id.name ^ "(" ^ String.concat ", " arg_strs ^ ")")
276+
mangle id.name ^ "(" ^ String.concat ", " arg_strs ^ ")"
230277
| _ ->
231278
let arg_strs = List.map (gen_expr ctx) args in
232279
gen_expr ctx func ^ "(" ^ String.concat ", " arg_strs ^ ")")
280+
| ExprBinary (e1, OpConcat, e2) ->
281+
(* `++` is string- OR array-concat; dispatch on shape at runtime so
282+
`a ++ b` (string) and `acc ++ [x]` (array) are both correct. *)
283+
"__as_concat(" ^ gen_expr ctx e1 ^ ", " ^ gen_expr ctx e2 ^ ")"
233284
| ExprBinary (e1, op, e2) ->
234285
let op_str = match op with
235286
| OpAdd -> "+" | OpSub -> "-" | OpMul -> "*" | OpDiv -> "/"
236287
| OpMod -> "%" | OpEq -> "===" | OpNe -> "!==" | OpLt -> "<"
237288
| OpLe -> "<=" | OpGt -> ">" | OpGe -> ">=" | OpAnd -> "&&"
238289
| OpOr -> "||" | OpBitAnd -> "&" | OpBitOr -> "|" | OpBitXor -> "^"
239-
| OpShl -> "<<" | OpShr -> ">>" | OpConcat -> "+"
290+
| OpShl -> "<<" | OpShr -> ">>" | OpConcat -> "+" (* unreachable *)
240291
in
241292
"(" ^ gen_expr ctx e1 ^ " " ^ op_str ^ " " ^ gen_expr ctx e2 ^ ")"
242293
| ExprUnary (op, e) ->
@@ -734,12 +785,21 @@ let gen_type_decl ctx (td : type_decl) : unit =
734785

735786
let generate (program : program) (symbols : Symbol.t) : string =
736787
let ctx = create_ctx symbols in
737-
(* Register extern names so calls lower via the builtin table. *)
788+
(* Register extern names so calls lower via the builtin table, and
789+
user-defined top-level names so they shadow host intrinsics. *)
738790
List.iter (function
739791
| TopExternFn { ef_name; _ } ->
740792
Hashtbl.replace ctx.externs ef_name.name ()
741793
| TopFn fd when fd.fd_body = FnExtern ->
742794
Hashtbl.replace ctx.externs fd.fd_name.name ()
795+
| TopFn fd ->
796+
Hashtbl.replace ctx.local_fns fd.fd_name.name ()
797+
| TopConst { tc_name; _ } ->
798+
Hashtbl.replace ctx.local_fns tc_name.name ()
799+
| TopImpl ib ->
800+
List.iter (function
801+
| ImplFn fd -> Hashtbl.replace ctx.local_fns fd.fd_name.name ()
802+
| ImplType _ -> ()) ib.ib_items
743803
| _ -> ()) program.prog_decls;
744804

745805
emit_line ctx "// Generated by AffineScript compiler (Deno-ESM target, issue #122)";

lib/typecheck.ml

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,20 @@ let rec synth (ctx : context) (expr : expr) : ty result =
863863
let* () = unify_or_err lhs_ty lhs_ty' in
864864
let* () = check ctx rhs rhs_ty in
865865
Ok result_ty
866+
end else if (match op with OpConcat -> true | _ -> false) then begin
867+
(* `++` is polymorphic over String and [T] (issue #122 v2.5):
868+
`a ++ b` (string concat) and `acc ++ [x]` (array concat) — the
869+
latter is how stdlib/string.affine's split/join/etc. accumulate.
870+
Dispatch on the synthesised lhs type. *)
871+
let* lhs_ty = synth ctx lhs in
872+
match repr lhs_ty with
873+
| TApp (TCon "Array", [elem]) ->
874+
let* () = check ctx rhs (TApp (TCon "Array", [elem])) in
875+
Ok (TApp (TCon "Array", [elem]))
876+
| _ ->
877+
let* () = unify_or_err lhs_ty ty_string in
878+
let* () = check ctx rhs ty_string in
879+
Ok ty_string
866880
end else begin
867881
let (lhs_ty, rhs_ty, result_ty) = type_of_binop op in
868882
let* () = check ctx lhs lhs_ty in
@@ -1180,8 +1194,35 @@ let register_builtins (ctx : context) : unit =
11801194
bind_var ctx "max" int_binop;
11811195
bind_var ctx "min" int_binop;
11821196
bind_var ctx "pow_float" float_binop;
1197+
(* `len` is polymorphic over String and [T] (issue #122 v2.5):
1198+
stdlib/string.affine calls `len` on both. Broadened from
1199+
Array[tv]->Int to 'a->Int (matches the interpreter's dynamic len;
1200+
the codegen lowers it to `.length`, valid for string and array). *)
11831201
bind_var ctx "len" (let tv = fresh_tyvar 0 in
1184-
TArrow (TApp (TCon "Array", [tv]), QOmega, ty_int, EPure));
1202+
TArrow (tv, QOmega, ty_int, EPure));
1203+
(* Honest string/char primitives underpinning stdlib/string.affine
1204+
(issue #122 v2.5). Concrete String/Char types; the Deno-ESM backend
1205+
lowers each to a JS intrinsic. char ::= TCon "Char". *)
1206+
let ty_char = TCon "Char" in
1207+
let opt t = TApp (TCon "Option", [t]) in
1208+
bind_var ctx "string_get"
1209+
(TArrow (ty_string, QOmega, TArrow (ty_int, QOmega, ty_char, EPure), EPure));
1210+
bind_var ctx "string_sub"
1211+
(TArrow (ty_string, QOmega,
1212+
TArrow (ty_int, QOmega,
1213+
TArrow (ty_int, QOmega, ty_string, EPure), EPure), EPure));
1214+
bind_var ctx "string_find"
1215+
(TArrow (ty_string, QOmega,
1216+
TArrow (ty_string, QOmega, ty_int, EPure), EPure));
1217+
bind_var ctx "to_lowercase" (TArrow (ty_string, QOmega, ty_string, EPure));
1218+
bind_var ctx "to_uppercase" (TArrow (ty_string, QOmega, ty_string, EPure));
1219+
bind_var ctx "trim" (TArrow (ty_string, QOmega, ty_string, EPure));
1220+
bind_var ctx "parse_int" (TArrow (ty_string, QOmega, opt ty_int, EPure));
1221+
bind_var ctx "parse_float" (TArrow (ty_string, QOmega, opt ty_float, EPure));
1222+
bind_var ctx "char_to_int" (TArrow (ty_char, QOmega, ty_int, EPure));
1223+
bind_var ctx "int_to_char" (TArrow (ty_int, QOmega, ty_char, EPure));
1224+
bind_var ctx "show"
1225+
(let tv = fresh_tyvar 0 in TArrow (tv, QOmega, ty_string, EPure));
11851226
bind_var ctx "panic" (TArrow (ty_string, QOmega, ty_never, EPure));
11861227
bind_var ctx "exit" (TArrow (ty_int, QOmega, ty_never, ESingleton "IO"));
11871228
(* TEA runtime — accepts any record, returns unit with IO effect *)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// issue #122 v2.5 regression: the honest string/number primitive layer
3+
// (len/string_sub/string_find/int_to_string + `++` shape dispatch) that
4+
// lets stdlib/string.affine — real AffineScript — run under Deno-ESM.
5+
// These mirror the exact primitives stdlib/string.affine is built on.
6+
7+
pub fn ends_with2(s: String, suffix: String) -> Bool {
8+
let slen = len(s);
9+
let sfxlen = len(suffix);
10+
if sfxlen > slen {
11+
return false;
12+
}
13+
return string_sub(s, slen - sfxlen, sfxlen) == suffix;
14+
}
15+
16+
pub fn strip_suffix(s: String, suffix: String) -> String {
17+
if ends_with2(s, suffix) {
18+
return string_sub(s, 0, len(s) - len(suffix));
19+
}
20+
return s;
21+
}
22+
23+
pub fn find(s: String, needle: String) -> Int = string_find(s, needle);
24+
25+
pub fn n2s(n: Int) -> String = int_to_string(n);
26+
27+
pub fn str_cat(a: String, b: String) -> String = a ++ b;
28+
29+
pub fn arr_cat(xs: [Int]) -> [Int] = xs ++ [99];
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// issue #122 v2.5 — honest string/number primitive lowering + `++` shape
3+
// dispatch (string concat vs array concat).
4+
import assert from "node:assert/strict";
5+
import {
6+
ends_with2,
7+
strip_suffix,
8+
find,
9+
n2s,
10+
str_cat,
11+
arr_cat,
12+
} from "./string_prims.deno.js";
13+
14+
assert.equal(ends_with2("foo.json", ".json"), true, "string_sub/len ends_with");
15+
assert.equal(ends_with2("foo.txt", ".json"), false, "negative ends_with");
16+
assert.equal(ends_with2("a", ".json"), false, "suffix longer than string");
17+
assert.equal(strip_suffix("foo.json", ".json"), "foo", "strip_suffix");
18+
assert.equal(strip_suffix("foo", ".json"), "foo", "strip_suffix no-op");
19+
assert.equal(find("ab.cd", "."), 2, "string_find");
20+
assert.equal(n2s(42), "42", "int_to_string");
21+
assert.equal(str_cat("a", "b"), "ab", "`++` string concat");
22+
assert.deepEqual(arr_cat([1, 2]), [1, 2, 99], "`++` array concat (not '1,299')");
23+
24+
console.log("string_prims.harness.mjs OK");

0 commit comments

Comments
 (0)