diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c82fb122c..dd6fbef48c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.7.0-beta.0] - Unreleased ### Added +- Added `filename:dirname/1`, `filename:basename/1,2`, `filename:extension/1`, `filename:rootname/1,2` and `filename:join/2` +- Added `init:get_arguments/0` - Added Erlang distribution over serial (uart) - Added WASM32 JIT backend for Emscripten platform - Added `network:wifi_scan/0,1` to ESP32 network driver to scan available APs when in sta or sta+ap mode. diff --git a/libs/estdlib/src/filename.erl b/libs/estdlib/src/filename.erl index 820aea599a..21d814f2bf 100644 --- a/libs/estdlib/src/filename.erl +++ b/libs/estdlib/src/filename.erl @@ -28,7 +28,14 @@ -module(filename). -export([ + basename/1, + basename/2, + dirname/1, + extension/1, join/1, + join/2, + rootname/1, + rootname/2, split/1 ]). @@ -38,8 +45,9 @@ %% @doc Join a list of path components. %% %% If a component is absolute (starts with "/"), all preceding -%% components are discarded. Redundant directory separators are -%% removed from the result. +%% components are discarded. Redundant directory separators and +%% "." components followed by a separator are removed from the +%% result. %% @end %%----------------------------------------------------------------------------- -spec join(Components :: [string()]) -> string(). @@ -51,6 +59,8 @@ join([Name | Rest]) -> join([do_join(Name, hd(Rest)) | tl(Rest)]). %% @private +do_join(Left, []) -> + normalize(Left); do_join(_Left, Right) when hd(Right) =:= $/ -> normalize(Right); do_join(Left, Right) -> @@ -104,10 +114,195 @@ skip_slashes([$/ | Rest]) -> skip_slashes(Rest); skip_slashes(Rest) -> Rest. %% @private -%% Collapse every run of consecutive "/" into a single "/". -normalize([]) -> +normalize(Name) -> + strip_join_trailing_slash(collapse_separators(Name)). + +%% @private +%% Collapse every run of consecutive "/" into a single "/", and drop any +%% "." component followed by a separator ("/./" -> "/"), as OTP join does. +%% A leading "./" and a trailing "/." are kept. +collapse_separators([]) -> []; -normalize([$/, $/ | Rest]) -> - normalize([$/ | Rest]); -normalize([C | Rest]) -> - [C | normalize(Rest)]. +collapse_separators([$/, $/ | Rest]) -> + collapse_separators([$/ | Rest]); +collapse_separators([$/, $., $/ | Rest]) -> + collapse_separators([$/ | Rest]); +collapse_separators([C | Rest]) -> + [C | collapse_separators(Rest)]. + +%% @private +%% Remove a single trailing "/", except for the root "/" itself. +strip_join_trailing_slash([$/]) -> [$/]; +strip_join_trailing_slash(Name) -> strip_trailing_slash(Name). + +%% @private +%% Remove a single trailing "/", if present. +strip_trailing_slash([]) -> []; +strip_trailing_slash([$/]) -> []; +strip_trailing_slash([C | Rest]) -> [C | strip_trailing_slash(Rest)]. + +%%----------------------------------------------------------------------------- +%% @param Name1 first path component +%% @param Name2 second path component +%% @returns the path formed by joining Name1 and Name2 +%% @doc Join two path components, equivalent to `join([Name1, Name2])'. +%% @end +%%----------------------------------------------------------------------------- +-spec join(Name1 :: string(), Name2 :: string()) -> string(). +join(Name1, []) -> + join([Name1]); +join(Name1, Name2) -> + join([Name1, Name2]). + +%%----------------------------------------------------------------------------- +%% @param Name a file name +%% @returns the directory part of Name +%% @doc Return the directory part of a file name, "." if there is none. +%% @end +%%----------------------------------------------------------------------------- +-spec dirname(Name :: string()) -> string(). +dirname(Name) -> + %% Drop the last component (the characters after the last slash, which + %% may be empty for names with a trailing slash), then the separating + %% slash run; what remains, reversed, is the directory. + case drop_component(lists:reverse(Name)) of + no_slash -> + "."; + [] -> + "/"; + DirReversed -> + lists:reverse(DirReversed) + end. + +%% @private +%% Drop the reversed last component and the slash run separating it; return +%% the reversed directory part, or no_slash if the name has no slash at all. +drop_component([]) -> no_slash; +drop_component([$/ | _] = Rest) -> skip_slashes(Rest); +drop_component([_ | Rest]) -> drop_component(Rest). + +%%----------------------------------------------------------------------------- +%% @param Name a file name +%% @returns the last component of Name +%% @doc Return the last component of a file name, ignoring a single +%% trailing slash (further ones make the last component empty). +%% @end +%%----------------------------------------------------------------------------- +-spec basename(Name :: string()) -> string(). +basename(Name) -> + basename(Name, []). + +%%----------------------------------------------------------------------------- +%% @param Name a file name +%% @param Ext an extension +%% @returns the last component of Name, with Ext stripped if it matches +%% @doc Like `basename/1', but also removes the extension Ext when the +%% base name ends with it. +%% @end +%%----------------------------------------------------------------------------- +-spec basename(Name :: string(), Ext :: string()) -> string(). +basename(Name, Ext) -> + basename(Name, Ext, []). + +%% @private +%% Scan Name, accumulating the current component (reversed) in Tail and +%% resetting it at each "/"; when the remainder equals Ext, the component +%% accumulated so far is the base name. A single trailing "/" is ignored. +%% This mirrors OTP basename/2; basename/1 is the Ext = "" case. +basename(Ext, Ext, Tail) -> + lists:reverse(Tail); +basename([$/], Ext, Tail) -> + basename([], Ext, Tail); +basename([$/ | Rest], Ext, _Tail) -> + basename(Rest, Ext, []); +basename([C | Rest], Ext, Tail) -> + basename(Rest, Ext, [C | Tail]); +basename([], _Ext, Tail) -> + lists:reverse(Tail). + +%%----------------------------------------------------------------------------- +%% @param Name a file name +%% @returns the extension of Name, including the dot, or "" if there is none +%% @doc Return the file extension of the last path component, "" if the +%% last component has no dot. +%% @end +%%----------------------------------------------------------------------------- +-spec extension(Name :: string()) -> string(). +extension(Name) -> + case extension_split(Name) of + {_Root, Ext} -> Ext + end. + +%%----------------------------------------------------------------------------- +%% @param Name a file name +%% @returns Name with its extension removed +%% @doc Remove the extension of the last path component, if any. +%% @end +%%----------------------------------------------------------------------------- +-spec rootname(Name :: string()) -> string(). +rootname(Name) -> + case leading_dot_only(Name) of + true -> + []; + false -> + case extension_split(Name) of + {Root, _Ext} -> Root + end + end. + +%% @private +%% True if Name is a leading-dot dotfile with no directory and no further +%% dot (e.g. ".bashrc"), which OTP treats as having no root name. +leading_dot_only([$. | Rest]) -> + not lists:member($/, Rest) andalso not lists:member($., Rest); +leading_dot_only(_) -> + false. + +%%----------------------------------------------------------------------------- +%% @param Name a file name +%% @param Ext an extension +%% @returns Name with Ext removed if Name ends with Ext +%% @doc Remove Ext from Name when it is its extension. +%% @end +%%----------------------------------------------------------------------------- +-spec rootname(Name :: string(), Ext :: string()) -> string(). +rootname(Name, Ext) -> + strip_root_suffix(Name, Ext, [], Name). + +%% @private +%% Remove Ext from the end of Name if present, except when the character +%% before it is a "/" (i.e. Ext is the whole last component), matching +%% OTP rootname/2. +strip_root_suffix(Ext, Ext, [$/ | _], Name) -> Name; +strip_root_suffix(Ext, Ext, Acc, _Name) -> lists:reverse(Acc); +strip_root_suffix([], _Ext, _Acc, Name) -> Name; +strip_root_suffix([C | Rest], Ext, Acc, Name) -> strip_root_suffix(Rest, Ext, [C | Acc], Name). + +%% @private +%% Split Name into {Root, Extension}: the extension starts at the last "." of +%% the last path component, unless that dot is the component's first character +%% (hidden files such as ".bashrc" have no extension). +extension_split(Name) -> + case extension_length(lists:reverse(Name), 0) of + 0 -> + {Name, []}; + ExtLen -> + NameLen = length(Name), + {lists:sublist(Name, NameLen - ExtLen), lists:nthtail(NameLen - ExtLen, Name)} + end. + +%% @private +%% Scan the reversed name for the dot starting the extension; return the +%% extension length (including the dot), or 0 if there is none. +extension_length([], _Consumed) -> + 0; +extension_length([$/ | _], _Consumed) -> + 0; +extension_length([$., Next | _], Consumed) when Next =/= $/ -> + Consumed + 1; +extension_length([$.], _Consumed) -> + 0; +extension_length([$., $/ | _], _Consumed) -> + 0; +extension_length([_ | Rest], Consumed) -> + extension_length(Rest, Consumed + 1). diff --git a/libs/estdlib/src/init.erl b/libs/estdlib/src/init.erl index c62a2e2090..9588880123 100644 --- a/libs/estdlib/src/init.erl +++ b/libs/estdlib/src/init.erl @@ -31,6 +31,7 @@ boot/1, get_argument/1, get_plain_arguments/0, + get_arguments/0, notify_when_started/1 ]). @@ -96,6 +97,16 @@ get_argument(_Flag) -> get_plain_arguments() -> []. +%%----------------------------------------------------------------------------- +%% @return all command-line user flags with their values. +%% @doc Returns all command-line flags, as `{Flag, Values}' tuples. +%% AtomVM has no emulator flags, so this always returns `[]'. +%% @end +%%----------------------------------------------------------------------------- +-spec get_arguments() -> [{atom(), [string()]}]. +get_arguments() -> + []. + %% @private -spec notify_when_started(Pid :: pid()) -> ok | started. notify_when_started(_Pid) -> diff --git a/tests/libs/estdlib/CMakeLists.txt b/tests/libs/estdlib/CMakeLists.txt index 9e70b42d51..f06ddfc108 100644 --- a/tests/libs/estdlib/CMakeLists.txt +++ b/tests/libs/estdlib/CMakeLists.txt @@ -60,6 +60,7 @@ set(ERLANG_MODULES test_supervisor test_lists_subtraction test_file + test_init test_filename test_tcp_socket test_udp_socket diff --git a/tests/libs/estdlib/test_filename.erl b/tests/libs/estdlib/test_filename.erl index b397b71790..c14bc0726b 100644 --- a/tests/libs/estdlib/test_filename.erl +++ b/tests/libs/estdlib/test_filename.erl @@ -27,6 +27,76 @@ test() -> ok = test_join(), ok = test_split(), + ok = test_join2(), + ok = test_dirname(), + ok = test_basename(), + ok = test_extension(), + ok = test_rootname(), + ok. + +test_join2() -> + ?ASSERT_MATCH(filename:join("/usr", "local"), "/usr/local"), + ?ASSERT_MATCH(filename:join("a", ""), "a"), + ?ASSERT_MATCH(filename:join("", "b"), "/b"), + ?ASSERT_MATCH(filename:join("a/", "/b"), "/b"), + ?ASSERT_MATCH(filename:join("a/", "b/"), "a/b"), + ok. + +test_dirname() -> + ?ASSERT_MATCH(filename:dirname("/usr/src/kalle.erl"), "/usr/src"), + ?ASSERT_MATCH(filename:dirname("kalle.erl"), "."), + ?ASSERT_MATCH(filename:dirname("/"), "/"), + ?ASSERT_MATCH(filename:dirname("/usr/"), "/usr"), + ?ASSERT_MATCH(filename:dirname("usr/src/"), "usr/src"), + ?ASSERT_MATCH(filename:dirname("a/b/c"), "a/b"), + ?ASSERT_MATCH(filename:dirname(""), "."), + ?ASSERT_MATCH(filename:dirname("."), "."), + ok. + +test_basename() -> + ?ASSERT_MATCH(filename:basename("/usr/src/kalle.erl"), "kalle.erl"), + ?ASSERT_MATCH(filename:basename("kalle.erl"), "kalle.erl"), + ?ASSERT_MATCH(filename:basename("/"), ""), + ?ASSERT_MATCH(filename:basename("/usr/"), "usr"), + ?ASSERT_MATCH(filename:basename("a/b/c"), "c"), + ?ASSERT_MATCH(filename:basename(""), ""), + ?ASSERT_MATCH(filename:basename("src/kalle.erl", ".erl"), "kalle"), + ?ASSERT_MATCH(filename:basename("src/kalle.beam", ".erl"), "kalle.beam"), + ?ASSERT_MATCH(filename:basename("kalle.erl", ".erl"), "kalle"), + %% Only a single trailing separator marks the "directory" case + ?ASSERT_MATCH(filename:basename("a//"), ""), + ?ASSERT_MATCH(filename:basename("a/b//"), ""), + ?ASSERT_MATCH(filename:basename("trailing/slash///"), ""), + ?ASSERT_MATCH(filename:basename("a/b.erl/", ".erl"), "b.erl"), + ?ASSERT_MATCH(filename:basename("a/", "a"), "a"), + %% An Ext containing "/" can match across components + ?ASSERT_MATCH(filename:basename("/a/b", "/b"), "a"), + ?ASSERT_MATCH(filename:basename("a//b", "/b"), ""), + ok. + +test_extension() -> + ?ASSERT_MATCH(filename:extension("foo.erl"), ".erl"), + ?ASSERT_MATCH(filename:extension("a/b.c/foo"), ""), + ?ASSERT_MATCH(filename:extension("foo"), ""), + ?ASSERT_MATCH(filename:extension("a.b/c.d"), ".d"), + ?ASSERT_MATCH(filename:extension("/x.y/z.erl"), ".erl"), + ok. + +test_rootname() -> + ?ASSERT_MATCH(filename:rootname("foo.erl"), "foo"), + ?ASSERT_MATCH(filename:rootname("a/b.c/foo"), "a/b.c/foo"), + ?ASSERT_MATCH(filename:rootname("/x.y/z.erl"), "/x.y/z"), + ?ASSERT_MATCH(filename:rootname("foo"), "foo"), + ?ASSERT_MATCH(filename:rootname("foo.erl", ".erl"), "foo"), + ?ASSERT_MATCH(filename:rootname("foo.beam", ".erl"), "foo.beam"), + %% A dot extension that is the whole basename is not stripped + ?ASSERT_MATCH(filename:rootname("a/.erl", ".erl"), "a/.erl"), + ?ASSERT_MATCH(filename:rootname("a/.bashrc", ".bashrc"), "a/.bashrc"), + %% A leading-dot dotfile with no directory has no root name + ?ASSERT_MATCH(filename:rootname("."), []), + ?ASSERT_MATCH(filename:rootname(".bashrc"), []), + ?ASSERT_MATCH(filename:rootname(".."), "."), + ?ASSERT_MATCH(filename:rootname(".a.b"), ".a"), ok. test_join() -> @@ -69,6 +139,28 @@ test_join() -> ?ASSERT_MATCH(filename:join(["foo", "."]), "foo/."), ?ASSERT_MATCH(filename:join(["foo", ".."]), "foo/.."), + %% A trailing separator in the final component is stripped + ?ASSERT_MATCH(filename:join(["a/"]), "a"), + ?ASSERT_MATCH(filename:join(["a", ""]), "a"), + ?ASSERT_MATCH(filename:join(["", ""]), ""), + ?ASSERT_MATCH(filename:join(["a/b///c/"]), "a/b/c"), + + %% A "." component followed by a separator is dropped ("/./" -> "/"), + %% but a leading "./" and a trailing "/." are kept + ?ASSERT_MATCH(filename:join("a/.", "b"), "a/b"), + ?ASSERT_MATCH(filename:join("a", "./b"), "a/b"), + ?ASSERT_MATCH(filename:join(["a", ".", "b"]), "a/b"), + ?ASSERT_MATCH(filename:join(["a/./b"]), "a/b"), + ?ASSERT_MATCH(filename:join(["a/.//./b"]), "a/b"), + ?ASSERT_MATCH(filename:join(["a/./."]), "a/."), + ?ASSERT_MATCH(filename:join(["a/../b"]), "a/../b"), + ?ASSERT_MATCH(filename:join(["./a"]), "./a"), + ?ASSERT_MATCH(filename:join(["./"]), "."), + ?ASSERT_MATCH(filename:join(["./."]), "./."), + ?ASSERT_MATCH(filename:join([".././"]), ".."), + ?ASSERT_MATCH(filename:join(["/./"]), "/"), + ?ASSERT_MATCH(filename:join(["/."]), "/."), + ok. test_split() -> diff --git a/tests/libs/estdlib/test_init.erl b/tests/libs/estdlib/test_init.erl new file mode 100644 index 0000000000..e583ed358e --- /dev/null +++ b/tests/libs/estdlib/test_init.erl @@ -0,0 +1,42 @@ +% +% This file is part of AtomVM. +% +% Copyright 2026 Paul Guyot +% +% Licensed under the Apache License, Version 2.0 (the "License"); +% you may not use this file except in compliance with the License. +% You may obtain a copy of the License at +% +% http://www.apache.org/licenses/LICENSE-2.0 +% +% Unless required by applicable law or agreed to in writing, software +% distributed under the License is distributed on an "AS IS" BASIS, +% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +% See the License for the specific language governing permissions and +% limitations under the License. +% +% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later +% + +-module(test_init). +-export([start/0, test/0]). + +start() -> + test(). + +test() -> + %% On AtomVM no emulator flags exist; the test harness may pass its own + %% plain arguments on BEAM, so only the shape of the results is checked. + Arguments = init:get_arguments(), + true = is_list(Arguments), + ok = check_flags(Arguments), + PlainArguments = init:get_plain_arguments(), + true = lists:all(fun(A) -> is_list(A) end, PlainArguments), + error = init:get_argument(no_such_flag_atomvm_test), + ok. + +check_flags([]) -> + ok; +check_flags([{Flag, Values} | T]) when is_atom(Flag) -> + true = lists:all(fun(V) -> is_list(V) end, Values), + check_flags(T). diff --git a/tests/libs/estdlib/tests.erl b/tests/libs/estdlib/tests.erl index 922dbe183a..cdf01e5cd1 100644 --- a/tests/libs/estdlib/tests.erl +++ b/tests/libs/estdlib/tests.erl @@ -79,6 +79,7 @@ get_non_networking_tests(_OTPVersion) -> test_lists_subtraction, test_os, test_file, + test_init, test_filename, test_serial_dist, test_uart,