Skip to content

Commit b2ce306

Browse files
committed
fix(beam): decode UTF-8 codepoints in ToCharArray and related String functions
1 parent fedf367 commit b2ce306

3 files changed

Lines changed: 71 additions & 18 deletions

File tree

src/Fable.Transforms/Beam/Replacements.fs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1166,8 +1166,10 @@ let private strings
11661166
Helper.LibCall(com, "fable_string", "last_index_of", t, [ c; sub; maxIdx ])
11671167
|> Some
11681168
| _ -> None
1169-
// str.ToCharArray() → binary_to_list(Str), wrap as array ref
1170-
| "ToCharArray", Some c, [] -> emitExpr r t [ c ] "binary_to_list($0)" |> wrapArr com r t |> Some
1169+
// str.ToCharArray() → unicode:characters_to_list(Str), wrap as array ref.
1170+
// Must decode UTF-8 codepoints, not raw bytes — binary_to_list would split a
1171+
// multi-byte character into one bogus "char" per byte.
1172+
| "ToCharArray", Some c, [] -> emitExpr r t [ c ] "unicode:characters_to_list($0)" |> wrapArr com r t |> Some
11711173
| "ToCharArray", Some c, [ start; len ] ->
11721174
Helper.LibCall(com, "fable_string", "to_char_array", t, [ c; start; len ])
11731175
|> wrapArr com r t

src/fable-library-beam/fable_string.erl

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -216,29 +216,29 @@ is_null_or_white_space(Str) ->
216216
%% String module functions (F# String.forall, String.exists, etc.)
217217

218218
forall(Fn, Str) ->
219-
Chars = binary_to_list(Str),
219+
Chars = unicode:characters_to_list(Str),
220220
lists:all(Fn, Chars).
221221

222222
exists(Fn, Str) ->
223-
Chars = binary_to_list(Str),
223+
Chars = unicode:characters_to_list(Str),
224224
lists:any(Fn, Chars).
225225

226226
init(Count, Fn) ->
227227
Chars = lists:map(fun(I) -> (Fn)(I) end, lists:seq(0, Count - 1)),
228228
iolist_to_binary(Chars).
229229

230230
collect(Fn, Str) ->
231-
Chars = binary_to_list(Str),
231+
Chars = unicode:characters_to_list(Str),
232232
Parts = lists:map(fun(C) -> (Fn)(C) end, Chars),
233233
iolist_to_binary(Parts).
234234

235235
iter(Fn, Str) ->
236-
Chars = binary_to_list(Str),
236+
Chars = unicode:characters_to_list(Str),
237237
lists:foreach(Fn, Chars),
238238
ok.
239239

240240
iteri(Fn, Str) ->
241-
Chars = binary_to_list(Str),
241+
Chars = unicode:characters_to_list(Str),
242242
iteri_loop(Fn, Chars, 0),
243243
ok.
244244

@@ -249,17 +249,17 @@ iteri_loop(Fn, [C | Rest], Idx) ->
249249
iteri_loop(Fn, Rest, Idx + 1).
250250

251251
map(Fn, Str) ->
252-
Chars = binary_to_list(Str),
252+
Chars = unicode:characters_to_list(Str),
253253
Mapped = lists:map(Fn, Chars),
254254
<<<<C/utf8>> || C <- Mapped>>.
255255

256256
mapi(Fn, Str) ->
257-
Chars = binary_to_list(Str),
257+
Chars = unicode:characters_to_list(Str),
258258
{Mapped, _} = lists:mapfoldl(fun(C, I) -> {((Fn)(I))(C), I + 1} end, 0, Chars),
259259
<<<<C/utf8>> || C <- Mapped>>.
260260

261261
filter(Fn, Str) ->
262-
Chars = binary_to_list(Str),
262+
Chars = unicode:characters_to_list(Str),
263263
Filtered = lists:filter(Fn, Chars),
264264
<<<<C/utf8>> || C <- Filtered>>.
265265

@@ -285,8 +285,8 @@ index_of_any(Str, Chars, StartIdx) when is_reference(Chars) ->
285285
index_of_any(Str, get(Chars), StartIdx);
286286
index_of_any(Str, Chars, StartIdx) ->
287287
CharSet = Chars,
288-
Bytes = binary_to_list(Str),
289-
index_of_any_loop(Bytes, CharSet, 0, StartIdx).
288+
Codepoints = unicode:characters_to_list(Str),
289+
index_of_any_loop(Codepoints, CharSet, 0, StartIdx).
290290

291291
index_of_any_loop([], _CharSet, _Idx, _StartIdx) ->
292292
-1;
@@ -322,26 +322,32 @@ contains(Str, Sub) ->
322322
trim_chars(Str, Chars) when is_integer(Chars) ->
323323
trim_chars(Str, [Chars]);
324324
trim_chars(Str, Chars) ->
325-
iolist_to_binary(string:trim(binary_to_list(Str), both, Chars)).
325+
iolist_to_binary(string:trim(Str, both, Chars)).
326326

327327
trim_start_chars(Str, Chars) when is_integer(Chars) ->
328328
trim_start_chars(Str, [Chars]);
329329
trim_start_chars(Str, Chars) ->
330-
iolist_to_binary(string:trim(binary_to_list(Str), leading, Chars)).
330+
iolist_to_binary(string:trim(Str, leading, Chars)).
331331

332332
trim_end_chars(Str, Chars) when is_integer(Chars) ->
333333
trim_end_chars(Str, [Chars]);
334334
trim_end_chars(Str, Chars) ->
335-
iolist_to_binary(string:trim(binary_to_list(Str), trailing, Chars)).
335+
iolist_to_binary(string:trim(Str, trailing, Chars)).
336336

337337
%% ToCharArray
338+
%%
339+
%% An F# string is a UTF-8 binary, so splitting it into chars means walking Unicode
340+
%% codepoints, not raw bytes — `binary_to_list/1` would instead hand back one bogus
341+
%% "char" per byte of a multi-byte codepoint's UTF-8 encoding. Start/Len are char
342+
%% (codepoint) offsets, matching .NET's `ToCharArray(startIndex, length)`, so they're
343+
%% applied to the codepoint list rather than to the binary's byte offsets.
338344

339345
to_char_array(Str) ->
340-
binary_to_list(Str).
346+
unicode:characters_to_list(Str).
341347

342348
to_char_array(Str, Start, Len) ->
343-
Part = binary:part(Str, Start, Len),
344-
binary_to_list(Part).
349+
Chars = unicode:characters_to_list(Str),
350+
lists:sublist(Chars, Start + 1, Len).
345351

346352
%% String comparison
347353

tests/Beam/StringTests.fs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ let ``test String.TrimEnd works`` () =
117117
let ``test String.Trim with chars works`` () =
118118
@"\\\abc///".Trim('\\','/') |> equal "abc"
119119

120+
[<Fact>]
121+
let ``test String.Trim with chars works with non-ASCII characters`` () =
122+
"café".Trim('é') |> equal "caf"
123+
120124
[<Fact>]
121125
let ``test String.TrimStart with chars works`` () =
122126
"!!--abc ".TrimStart('!','-') |> equal "abc "
@@ -340,6 +344,11 @@ let ``test String.forall and exists work`` () =
340344
"a!a" |> String.forall (fun c -> c = '!') |> equal false
341345
"aaa" |> String.forall (fun c -> c = '!') |> equal false
342346

347+
[<Fact>]
348+
let ``test String.forall works with non-ASCII characters`` () =
349+
"café" |> String.forall (fun c -> c <> 'x') |> equal true
350+
"café" |> String.exists (fun c -> c = 'é') |> equal true
351+
343352
[<Fact>]
344353
let ``test String.init works`` () =
345354
String.init 3 (fun i -> "a") |> equal "aaa"
@@ -348,13 +357,23 @@ let ``test String.init works`` () =
348357
let ``test String.collect works`` () =
349358
"abc" |> String.collect (fun c -> "bcd") |> equal "bcdbcdbcd"
350359

360+
[<Fact>]
361+
let ``test String.collect works with non-ASCII characters`` () =
362+
"café" |> String.collect string |> equal "café"
363+
351364
[<Fact>]
352365
let ``test String.iter works`` () =
353366
let res = ref ""
354367
"Hello world!"
355368
|> String.iter (fun c -> res.Value <- res.Value + c.ToString())
356369
equal "Hello world!" res.Value
357370

371+
[<Fact>]
372+
let ``test String.iter works with non-ASCII characters`` () =
373+
let res = ref ""
374+
"café" |> String.iter (fun c -> res.Value <- res.Value + c.ToString())
375+
equal "café" res.Value
376+
358377
[<Fact>]
359378
let ``test String.iteri works`` () =
360379
let mutable res = ""
@@ -367,6 +386,11 @@ let ``test String.map works`` () =
367386
"Hello world!" |> String.map (fun c -> if c = 'H' then '_' else c)
368387
|> equal "_ello world!"
369388

389+
[<Fact>]
390+
let ``test String.map works with non-ASCII characters`` () =
391+
"café" |> String.map (fun c -> if c = 'é' then 'e' else c)
392+
|> equal "cafe"
393+
370394
[<Fact>]
371395
let ``test String.mapi works`` () =
372396
"Hello world!" |> String.mapi (fun i c -> if i = 1 || c = 'H' then '_' else c)
@@ -376,6 +400,10 @@ let ``test String.mapi works`` () =
376400
let ``test String.filter works`` () =
377401
String.filter (fun x -> x <> '.') "a.b.c" |> equal "abc"
378402

403+
[<Fact>]
404+
let ``test String.filter works with non-ASCII characters`` () =
405+
String.filter (fun c -> c <> 'é') "café" |> equal "caf"
406+
379407
[<Fact>]
380408
let ``test String.filter works when predicate matches everything`` () =
381409
String.filter (fun x -> x <> '.') "abc" |> equal "abc"
@@ -433,6 +461,18 @@ let ``test String.ToCharArray with range works`` () =
433461
let arr = "abcd".ToCharArray(1, 2)
434462
arr |> equal [|'b';'c'|]
435463

464+
[<Fact>]
465+
let ``test String.ToCharArray works with non-ASCII characters`` () =
466+
let s = "café"
467+
let arr = s.ToCharArray()
468+
arr |> equal [|'c';'a';'f';'é'|]
469+
let rebuilt = arr |> Array.map string |> String.concat ""
470+
rebuilt |> equal s
471+
472+
[<Fact>]
473+
let ``test String.ToCharArray with range works with non-ASCII characters`` () =
474+
"café".ToCharArray(2, 2) |> equal [|'f';'é'|]
475+
436476
// --- String.Equals ---
437477

438478
[<Fact>]
@@ -723,6 +763,11 @@ let ``test String.IndexOfAny works`` () =
723763
"abcdbcebc".IndexOfAny([|'b'|], 2) |> equal 4
724764
"abcdbcebc".IndexOfAny([|'f';'e'|]) |> equal 6
725765

766+
[<Fact>]
767+
let ``test String.IndexOfAny works with non-ASCII characters`` () =
768+
"café".IndexOfAny([|'é'|]) |> equal 3
769+
"café".IndexOfAny([|'f'|]) |> equal 2
770+
726771
[<Fact>]
727772
let ``test String.Join with indices works`` () =
728773
String.Join("**", [|"a"; "b"; "c"; "d"|], 1, 2) |> equal "b**c"

0 commit comments

Comments
 (0)