Skip to content

Commit c9f3395

Browse files
committed
Add stringToChars and charsToString for character decomposition
atom_concat joins symbols but cannot split one, so a typed symbol name such as e1_ep could not be taken apart into a base and a sort at run time. These are the inverse pair: stringToChars splits a String into an Expression of single-character symbols, and charsToString joins one back. Characters are single-character symbols, matching hyperon-experimental and mettalog (SWI string_chars). charsToString takes its list unevaluated, like car-atom, because the characters of "+ab" are (+ a b) and evaluating the argument would run an addition instead of joining it back; a computed list goes in through let. Iterating with the spread keeps astral code points whole.
1 parent 9a7be4e commit c9f3395

3 files changed

Lines changed: 72 additions & 0 deletions

File tree

packages/core/src/builtins.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,6 +1532,31 @@ const pettaEntries: Array<[string, GroundFn]> = [
15321532
"atom_concat",
15331533
(a) => ok(sym(a.map((x) => (x.kind === "sym" ? x.name : (asStr(x) ?? format(x)))).join(""))),
15341534
],
1535+
// stringToChars/charsToString are the inverse pair atom_concat lacks: split a String into an Expression
1536+
// of single-character symbols and join it back. Chars are single-character symbols, matching
1537+
// hyperon-experimental and mettalog (SWI `string_chars`). Iterating with the spread keeps astral code
1538+
// points (surrogate pairs) intact.
1539+
[
1540+
"stringToChars",
1541+
(a) => {
1542+
const s = asStr(a[0]!);
1543+
if (a.length !== 1 || s === undefined) return ierr("stringToChars expects a String");
1544+
return ok(expr([...s].map((ch) => sym(ch))));
1545+
},
1546+
],
1547+
[
1548+
"charsToString",
1549+
(a) => {
1550+
const chars = a[0]!;
1551+
if (a.length !== 1 || chars.kind !== "expr")
1552+
return ierr("charsToString expects an Expression of characters");
1553+
return ok(
1554+
gstr(
1555+
chars.items.map((x) => (x.kind === "sym" ? x.name : (asStr(x) ?? format(x)))).join(""),
1556+
),
1557+
);
1558+
},
1559+
],
15351560
// parse a string of MeTTa source into its (first) atom; sread is PeTTa's alias.
15361561
[
15371562
"parse",

packages/core/src/stdlib.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,42 @@ describe("spaces: get-atoms follows LeaTTa add-atom semantics", () => {
104104
).toEqual(["(quote (foo (+ 1 1)))"]);
105105
});
106106
});
107+
108+
describe("stdlib stringToChars / charsToString", () => {
109+
it("stringToChars splits a string into single-character symbols", () => {
110+
expect(r1('!(stringToChars "abc")')).toEqual(["(a b c)"]);
111+
});
112+
113+
it("stringToChars of the empty string is the empty expression", () => {
114+
expect(r1('!(stringToChars "")')).toEqual(["()"]);
115+
});
116+
117+
it("charsToString joins a character list into a string", () => {
118+
expect(r1("!(charsToString (a b c))")).toEqual(['"abc"']);
119+
});
120+
121+
it("round-trips a string through its characters", () => {
122+
// charsToString takes an Expression argument (unevaluated, like car-atom and hyperon-experimental), so
123+
// a decoder binds the character list with let, or walks it with list ops, rather than nesting directly.
124+
expect(r1('!(let $cs (stringToChars "e1_ep") (charsToString $cs))')).toEqual(['"e1_ep"']);
125+
});
126+
127+
it("decomposes a legacy typed-symbol name into its characters", () => {
128+
expect(r1('!(stringToChars "e1_ep")')).toEqual(["(e 1 _ e p)"]);
129+
});
130+
131+
it("round-trips a string whose characters are operator symbols", () => {
132+
// This is why charsToString takes Expression rather than %Undefined%: the character list of "+ab" is
133+
// `(+ a b)`, so evaluating the argument would run it as an addition instead of joining it back.
134+
expect(r1('!(stringToChars "+ab")')).toEqual(["(+ a b)"]);
135+
expect(r1('!(let $cs (stringToChars "+ab") (charsToString $cs))')).toEqual(['"+ab"']);
136+
});
137+
138+
it("keeps an astral character whole rather than splitting its surrogate pair", () => {
139+
expect(r1('!(stringToChars "a\u{1F600}b")')).toEqual(["(a \u{1F600} b)"]);
140+
});
141+
142+
it("evaluates the argument of stringToChars, so it composes with String-producing calls", () => {
143+
expect(r1("!(stringToChars (repr foo))")).toEqual(["(f o o)"]);
144+
});
145+
});

packages/core/src/stdlib.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ export const STDLIB_SRC = `
9292
; atom as written; to repr a reduced form, evaluate it first (e.g. bind it with let).
9393
(: repr (-> Atom String))
9494
95+
; stringToChars splits a String into an Expression of single-character symbols; charsToString is the
96+
; inverse. The character list is ordinary data that car-atom/decons-atom walk, which is what lets a
97+
; legacy typed symbol name be taken apart into a base and a sort at run time. charsToString takes its
98+
; list unevaluated, like car-atom: the characters of "+ab" are (+ a b), which evaluating would run as an
99+
; addition. Bind a computed list with let, e.g. (let $cs (stringToChars $s) (charsToString $cs)).
100+
(: stringToChars (-> String Expression))
101+
(: charsToString (-> Expression String))
102+
95103
; trace! prints its first argument and returns the (evaluated) second.
96104
(: trace! (-> %Undefined% Atom %Undefined%))
97105
(= (trace! $msg $ret) (let $unit (println! $msg) $ret))

0 commit comments

Comments
 (0)