diff --git a/CHANGELOG.md b/CHANGELOG.md index a9472d204d..f5df09a30e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added USB CDC port drivers for ESP32, RP2, and STM32 platforms - Added a Linux `gpio` driver for the generic_unix port (in `avm_unix`) using sysfs - Added `console:print_err/1` to write to standard error +- Added `string:to_integer/1` ### Changed - Updated network type db() to dbm() to reflect the actual representation of the type diff --git a/libs/estdlib/src/string.erl b/libs/estdlib/src/string.erl index 93e87d5a2d..85ab75afa8 100644 --- a/libs/estdlib/src/string.erl +++ b/libs/estdlib/src/string.erl @@ -34,6 +34,7 @@ trim/1, trim/2, find/2, find/3, length/1, + to_integer/1, jaro_similarity/2 ]). @@ -57,6 +58,251 @@ upper_char(C) when is_integer(C) andalso C >= $a andalso C =< $z -> upper_char(C) when is_integer(C) -> C. +%%----------------------------------------------------------------------------- +%% @param String a chardata value possibly beginning with an integer +%% @returns `{Int, Rest}' where `Rest' is the unconsumed chardata suffix, or +%% `{error, no_integer}' if it does not begin with an integer, or +%% `{error, badarg}' if malformed chardata or invalid UTF-8 is +%% encountered while parsing +%% @doc Parse a leading (optionally signed) integer from a `unicode:chardata()' +%% value. Digits and sign are ASCII. The remainder keeps binary form when the +%% original argument is a binary. +%% +%% Matching OTP, the maximal leading run of ASCII `+', `-', and digits is +%% validated before the integer grammar is applied, so leftover signs in that +%% run are examined for chardata errors even when they become part of `Rest'. +%% @end +%%----------------------------------------------------------------------------- +-spec to_integer(String :: unicode:chardata()) -> + {integer(), unicode:chardata()} | {error, no_integer | badarg}. +to_integer(String) -> + try to_integer_cd(String) of + {error, _} = Err -> + Err; + {Int, Rest} -> + {Int, Rest} + catch + error:badarg -> + {error, badarg} + end. + +%% @private +to_integer_cd(Bin) when is_binary(Bin) -> + case take_int_bin(Bin) of + no_integer -> + {error, no_integer}; + {Int, Rest} -> + {Int, Rest} + end; +to_integer_cd(List) when is_list(List) -> + case take_int_cd(List) of + no_integer -> + {error, no_integer}; + {Int, Rest} -> + {Int, Rest} + end; +to_integer_cd(_) -> + {error, badarg}. + +%% Take the maximal leading run of ASCII + - digits (OTP string:take/2 set), +%% validate the first codepoint after that run, then apply integer grammar. +%% @private +take_int_bin(Bin) -> + {PrefLen, Tail} = take_set_bin(Bin, 0), + case ensure_utf8_boundary(Tail) of + ok when PrefLen =:= 0 -> + no_integer; + ok -> + Pref = binary:part(Bin, 0, PrefLen), + case parse_int_bin(Pref) of + no_integer -> + no_integer; + {Int, RestPref} -> + {Int, <>} + end; + error -> + erlang:error(badarg) + end. + +%% @private +take_set_bin(<>, N) when + C =:= $+ orelse C =:= $- orelse (C >= $0 andalso C =< $9) +-> + take_set_bin(Rest, N + 1); +take_set_bin(Rest, N) -> + {N, Rest}. + +%% @private +parse_int_bin(<<$+, Rest/binary>>) -> + parse_int_digits_bin(Rest, 1); +parse_int_bin(<<$-, Rest/binary>>) -> + parse_int_digits_bin(Rest, -1); +parse_int_bin(Bin) -> + parse_int_digits_bin(Bin, 1). + +%% @private +parse_int_digits_bin(Bin, Sign) -> + {N, Rest} = take_digits_bin(Bin, 0), + case N of + 0 -> + no_integer; + _ -> + Digits = binary:part(Bin, 0, N), + {Sign * binary_to_integer(Digits), Rest} + end. + +%% @private +take_digits_bin(<>, N) when C >= $0 andalso C =< $9 -> + take_digits_bin(Rest, N + 1); +take_digits_bin(Rest, N) -> + {N, Rest}. + +%% @private +ensure_utf8_boundary(<<>>) -> + ok; +ensure_utf8_boundary(<>) when C =< 16#7F -> + ok; +ensure_utf8_boundary(<<_/utf8, _/binary>>) -> + ok; +ensure_utf8_boundary(_) -> + error. + +%% @private +take_int_cd(CD) -> + {HeadChars, Tail0} = take_set_cd(CD, []), + Tail = normalize_empty_cd(Tail0), + case HeadChars of + [] -> + no_integer; + _ -> + case parse_int_chars(HeadChars) of + no_integer -> + no_integer; + {Int, RestChars} -> + {Int, append_cd(RestChars, Tail)} + end + end. + +%% Collect leading + - digit codepoints; stop before first non-member. +%% @private +take_set_cd(CD, Acc) -> + case next_cp(CD) of + empty -> + {lists:reverse(Acc), CD}; + {C, Rest} when C =:= $+ orelse C =:= $- orelse (C >= $0 andalso C =< $9) -> + take_set_cd(Rest, [C | Acc]); + {_C, _Rest} -> + {lists:reverse(Acc), CD} + end. + +%% @private +parse_int_chars([$+ | Rest]) -> + parse_int_digits_chars(Rest, 1); +parse_int_chars([$- | Rest]) -> + parse_int_digits_chars(Rest, -1); +parse_int_chars(Chars) -> + parse_int_digits_chars(Chars, 1). + +%% @private +parse_int_digits_chars([C | Rest], Sign) when C >= $0 andalso C =< $9 -> + parse_int_more_chars(Rest, Sign, [C]); +parse_int_digits_chars(_Rest, _Sign) -> + no_integer. + +%% @private +parse_int_more_chars([C | Rest], Sign, Acc) when C >= $0 andalso C =< $9 -> + parse_int_more_chars(Rest, Sign, [C | Acc]); +parse_int_more_chars(Rest, Sign, Acc) -> + {Sign * list_to_integer(lists:reverse(Acc)), Rest}. + +%% @private +append_cd([], Tail) -> + Tail; +append_cd(RestChars, Tail) -> + RestChars ++ Tail. + +%% @private +normalize_empty_cd(CD) -> + case is_empty_cd(CD) of + true -> + []; + false -> + CD + end. + +%% @private +is_empty_cd([]) -> + true; +is_empty_cd(<<>>) -> + true; +is_empty_cd([H | T]) -> + is_empty_cd(H) andalso is_empty_cd(T); +is_empty_cd(_) -> + false. + +%% next_cp(Chardata) -> {Codepoint, Rest} | empty +%% Raises error:badarg on malformed UTF-8 / non-chardata, including improper +%% list continuations that are not a list or binary. +%% @private +next_cp([]) -> + empty; +next_cp(<<>>) -> + empty; +next_cp([H | T]) when is_integer(H) -> + if + H >= 0 andalso H =< 16#10FFFF -> + {H, ensure_cd_cont(T)}; + true -> + erlang:error(badarg) + end; +next_cp([H | T]) when is_binary(H) -> + case next_cp_bin(H) of + empty -> + next_cp(ensure_cd_cont(T)); + {C, RestBin} -> + {C, stack_rest(RestBin, T)} + end; +next_cp([H | T]) when is_list(H) -> + case next_cp(H) of + empty -> + next_cp(ensure_cd_cont(T)); + {C, RestH} -> + {C, stack_rest(RestH, T)} + end; +next_cp(Bin) when is_binary(Bin) -> + next_cp_bin(Bin); +next_cp(_) -> + erlang:error(badarg). + +%% @private +next_cp_bin(<<>>) -> + empty; +next_cp_bin(<>) -> + {C, Rest}; +next_cp_bin(_) -> + erlang:error(badarg). + +%% @private +stack_rest([], T) -> + ensure_cd_cont(T); +stack_rest(<<>>, T) -> + ensure_cd_cont(T); +stack_rest(H, []) -> + H; +stack_rest(H, T) -> + [H | ensure_cd_cont(T)]. + +%% A chardata continuation must be [] | binary() | list(). +%% @private +ensure_cd_cont([]) -> + []; +ensure_cd_cont(T) when is_binary(T) -> + T; +ensure_cd_cont(T) when is_list(T) -> + T; +ensure_cd_cont(_) -> + erlang:error(badarg). + %%----------------------------------------------------------------------------- %% @param Input a string or character to convert %% @returns a Character or string diff --git a/tests/libs/estdlib/test_string.erl b/tests/libs/estdlib/test_string.erl index e67bd12b6a..65fe5dce04 100644 --- a/tests/libs/estdlib/test_string.erl +++ b/tests/libs/estdlib/test_string.erl @@ -30,6 +30,79 @@ test() -> ok = test_trim(), ok = test_find(), ok = test_length(), + ok = test_to_integer(), + ok. + +test_to_integer() -> + {27, ""} = string:to_integer("27"), + {12, "abc"} = string:to_integer("12abc"), + {-5, "x"} = string:to_integer("-5x"), + {42, ""} = string:to_integer("+42"), + {1, "+2"} = string:to_integer("0001+2"), + {1, <<"+2">>} = string:to_integer(<<"1+2">>), + {123, <<"x">>} = string:to_integer(<<"123x">>), + {-5, <<"x">>} = string:to_integer(<<"-5x">>), + {12, <<"é"/utf8>>} = string:to_integer(<<"12é"/utf8>>), + {12, <<"x">>} = string:to_integer([$1 | <<"2x">>]), + {123, "x"} = string:to_integer([[<<"12">>], "3x"]), + {12, <<"x">>} = string:to_integer([[], <<"12x">>]), + {12, []} = string:to_integer([<<"12">>, [], []]), + {error, no_integer} = string:to_integer("abc"), + {error, no_integer} = string:to_integer(""), + {error, no_integer} = string:to_integer(<<"+">>), + {error, no_integer} = string:to_integer("+"), + {error, no_integer} = string:to_integer("--1"), + {error, no_integer} = string:to_integer("++1"), + {error, no_integer} = string:to_integer([<<"abc">>, 16#FFFFF]), + {error, no_integer} = string:to_integer([[], [<<>>], []]), + {error, no_integer} = string:to_integer([[$-], [], <<>>]), + %% Valid first nonnumeric character: malformed tail is unexamined. + {error, no_integer} = string:to_integer(<<"a", 16#FF>>), + {error, no_integer} = string:to_integer([<<"abc">>, <<16#FF>>]), + %% Boundary char outside take-set: leftover signs are not validated past it. + {1, <<$x, 16#FF>>} = string:to_integer(<<$1, $x, 16#FF>>), + {1, [<<"x">>, <<16#FF>>]} = string:to_integer([<<"1x">>, <<16#FF>>]), + {error, badarg} = string:to_integer(foo), + {error, badarg} = string:to_integer([$3, hello]), + {error, badarg} = string:to_integer([49, 50 | bad]), + {error, badarg} = string:to_integer(<<$3, 16#FF>>), + {error, badarg} = string:to_integer([<<"12">>, <<255>>]), + %% Malformed first codepoint (or after sign) is badarg, not no_integer. + {error, badarg} = string:to_integer(<<16#FF>>), + {error, badarg} = string:to_integer(<<16#C2>>), + {error, badarg} = string:to_integer(<<$+, 16#FF>>), + {error, badarg} = string:to_integer([16#110000]), + {error, badarg} = string:to_integer([$1, 16#110000]), + %% Leftover signs in the take-set prefix are validated (OTP take semantics). + {error, badarg} = string:to_integer(<<$1, $+, 16#FF>>), + {error, badarg} = string:to_integer([$1, $+, <<16#FF>>]), + {error, badarg} = string:to_integer(<<$-, $-, 16#FF>>), + {error, badarg} = string:to_integer([<<"1+">>, <<16#FF>>]), + %% Improper list spines are rejected even when behind a valid boundary. + {error, badarg} = string:to_integer([$a | foo]), + {error, badarg} = string:to_integer([$1, $x | foo]), + {error, badarg} = string:to_integer([<<"1x">> | foo]), + {error, badarg} = string:to_integer([$1 | foo]), + %% Large integer near AtomVM's ~256-bit magnitude limit (supported on both). + BigOk = lists:duplicate(77, $9), + BigOkBin = list_to_binary(BigOk), + {BigOkInt, []} = string:to_integer(BigOk), + true = is_integer(BigOkInt) andalso BigOkInt > 0, + {BigOkInt, <<>>} = string:to_integer(BigOkBin), + %% Oversized for AtomVM (~272 bits); OTP accepts arbitrary size. + BigOver = lists:duplicate(80, $9), + BigOverBin = list_to_binary(BigOver), + case erlang:system_info(machine) of + "BEAM" -> + {BigOverInt, []} = string:to_integer(BigOver), + true = is_integer(BigOverInt) andalso BigOverInt > BigOkInt, + {BigOverInt, <<>>} = string:to_integer(BigOverBin), + ok; + _ -> + {error, badarg} = string:to_integer(BigOver), + {error, badarg} = string:to_integer(BigOverBin), + ok + end, ok. test_to_upper() -> diff --git a/tests/libs/estdlib/tests.erl b/tests/libs/estdlib/tests.erl index 84520c3c39..e9cc609610 100644 --- a/tests/libs/estdlib/tests.erl +++ b/tests/libs/estdlib/tests.erl @@ -75,6 +75,7 @@ get_non_networking_tests(_OTPVersion) -> test_queue, test_timer, test_spawn, + test_string, test_supervisor, test_lists_subtraction, test_os,