Current section

Files

Jump to
libero src libero@scanner.erl
Raw

src/libero@scanner.erl

-module(libero@scanner).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/libero/scanner.gleam").
-export([derive_module_path/1, validate_msg_from_server_fields/1, last_module_segment/1, scan_message_modules/1, validate_conventions/4]).
-export_type([message_module/0, discovered_handler/0]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" Scanning and convention validation for message modules.\n"
"\n"
" Discovers modules in the shared package that export `MsgFromClient` or\n"
" `MsgFromServer` custom types, and validates that the server package\n"
" follows the required conventions (handler modules, shared state, etc.).\n"
" Handlers are discovered by scanning server source for modules that export\n"
" `pub fn update_from_client` and matching via the first parameter's type.\n"
).
-type message_module() :: {message_module,
binary(),
binary(),
boolean(),
boolean(),
list(binary())}.
-type discovered_handler() :: {discovered_handler, binary(), binary()}.
-file("src/libero/scanner.gleam", 194).
?DOC(
" Resolve a qualified type reference like `todos.MsgFromClient`.\n"
" The qualifier matches either an import alias or the last segment of the\n"
" import module path.\n"
).
-spec resolve_qualified(binary(), list(glance:definition(glance:import()))) -> {ok,
binary()} |
{error, nil}.
resolve_qualified(Qualifier, Imports) ->
gleam@list:find_map(
Imports,
fun(Def) ->
{definition, _, Imp} = Def,
Matches = case erlang:element(4, Imp) of
{some, {named, Alias_name}} ->
Alias_name =:= Qualifier;
{some, {discarded, _}} ->
false;
none ->
Segments = gleam@string:split(
erlang:element(3, Imp),
<<"/"/utf8>>
),
case gleam@list:last(Segments) of
{ok, Last} ->
Last =:= Qualifier;
{error, nil} ->
false
end
end,
case Matches of
true ->
{ok, erlang:element(3, Imp)};
false ->
{error, nil}
end
end
).
-file("src/libero/scanner.gleam", 221).
?DOC(
" Resolve an unqualified type reference like `MsgFromClient`.\n"
" Search unqualified_types across all imports.\n"
).
-spec resolve_unqualified(binary(), list(glance:definition(glance:import()))) -> {ok,
binary()} |
{error, nil}.
resolve_unqualified(Type_name, Imports) ->
gleam@list:find_map(
Imports,
fun(Def) ->
{definition, _, Imp} = Def,
Has_type = gleam@list:any(
erlang:element(5, Imp),
fun(Uq) -> erlang:element(2, Uq) =:= Type_name end
),
case Has_type of
true ->
{ok, erlang:element(3, Imp)};
false ->
{error, nil}
end
end
).
-file("src/libero/scanner.gleam", 176).
?DOC(
" Resolve a type annotation to the shared module path it refers to.\n"
" Handles both qualified (`todos.MsgFromClient`) and unqualified\n"
" (`MsgFromClient`) references.\n"
).
-spec resolve_msg_type(glance:type(), list(glance:definition(glance:import()))) -> {ok,
binary()} |
{error, nil}.
resolve_msg_type(Type_ann, Imports) ->
case Type_ann of
{named_type, _, <<"MsgFromClient"/utf8>>, {some, Qualifier}, _} ->
resolve_qualified(Qualifier, Imports);
{named_type, _, <<"MsgFromClient"/utf8>>, none, _} ->
resolve_unqualified(<<"MsgFromClient"/utf8>>, Imports);
_ ->
{error, nil}
end.
-file("src/libero/scanner.gleam", 289).
-spec visit_file(list(binary()), binary(), binary()) -> list(binary()).
visit_file(Acc, Entry, Child) ->
gleam@bool:guard(
not gleam_stdlib:string_ends_with(Entry, <<".gleam"/utf8>>),
Acc,
fun() -> [Child | Acc] end
).
-file("src/libero/scanner.gleam", 303).
?DOC(
" Derive the Gleam module path from a file path by finding `/src/` and\n"
" taking everything after it, then stripping the `.gleam` extension.\n"
" E.g. `examples/todos/shared/src/shared/todos.gleam` -> `shared/todos`.\n"
).
-spec derive_module_path(binary()) -> binary().
derive_module_path(File_path) ->
Without_extension = case gleam_stdlib:string_ends_with(
File_path,
<<".gleam"/utf8>>
) of
true ->
gleam@string:slice(
File_path,
0,
string:length(File_path) - string:length(<<".gleam"/utf8>>)
);
false ->
File_path
end,
_pipe = gleam@string:split_once(Without_extension, <<"/src/"/utf8>>),
_pipe@1 = gleam@result:map(_pipe, fun(Pair) -> erlang:element(2, Pair) end),
gleam@result:unwrap(_pipe@1, Without_extension).
-file("src/libero/scanner.gleam", 86).
-spec parse_message_module(binary()) -> {ok, message_module()} | {error, nil}.
parse_message_module(File_path) ->
gleam@result:'try'(
begin
_pipe = simplifile:read(File_path),
gleam@result:replace_error(_pipe, nil)
end,
fun(Content) ->
gleam@result:'try'(
begin
_pipe@1 = glance:module(Content),
gleam@result:replace_error(_pipe@1, nil)
end,
fun(Parsed) ->
Has_msg_from_client = gleam@list:any(
erlang:element(3, Parsed),
fun(Ct) ->
{definition, _, T} = Ct,
(erlang:element(3, T) =:= <<"MsgFromClient"/utf8>>)
andalso (erlang:element(4, T) =:= public)
end
),
Has_msg_from_server = gleam@list:any(
erlang:element(3, Parsed),
fun(Ct@1) ->
{definition, _, T@1} = Ct@1,
(erlang:element(3, T@1) =:= <<"MsgFromServer"/utf8>>)
andalso (erlang:element(4, T@1) =:= public)
end
),
gleam@bool:guard(
not Has_msg_from_client andalso not Has_msg_from_server,
{error, nil},
fun() ->
Module_path = derive_module_path(File_path),
{ok,
{message_module,
Module_path,
File_path,
Has_msg_from_client,
Has_msg_from_server,
[]}}
end
)
end
)
end
).
-file("src/libero/scanner.gleam", 142).
?DOC(
" Parse a single server source file looking for `pub fn update_from_client`.\n"
" If found, resolve the first parameter's type to a shared module path.\n"
).
-spec parse_handler(binary()) -> {ok, discovered_handler()} | {error, nil}.
parse_handler(File_path) ->
gleam@result:'try'(
begin
_pipe = simplifile:read(File_path),
gleam@result:replace_error(_pipe, nil)
end,
fun(Content) ->
gleam@result:'try'(
begin
_pipe@1 = glance:module(Content),
gleam@result:replace_error(_pipe@1, nil)
end,
fun(Parsed) ->
Target = gleam@list:find(
erlang:element(6, Parsed),
fun(Def) ->
{definition, _, F} = Def,
(erlang:element(3, F) =:= <<"update_from_client"/utf8>>)
andalso (erlang:element(4, F) =:= public)
end
),
gleam@result:'try'(
Target,
fun(_use0) ->
{definition, _, Func} = _use0,
gleam@result:'try'(
gleam@list:first(erlang:element(5, Func)),
fun(First_param) ->
gleam@result:'try'(
gleam@option:to_result(
erlang:element(4, First_param),
nil
),
fun(Type_ann) ->
gleam@result:'try'(
resolve_msg_type(
Type_ann,
erlang:element(2, Parsed)
),
fun(Shared_module) ->
Handler_module = derive_module_path(
File_path
),
{ok,
{discovered_handler,
Handler_module,
Shared_module}}
end
)
end
)
end
)
end
)
end
)
end
).
-file("src/libero/scanner.gleam", 421).
?DOC(
" Scaffold a default shared_state.gleam if missing.\n"
" Returns an empty error list on success so it integrates\n"
" with the existing error-collection flow.\n"
).
-spec scaffold_shared_state(binary()) -> list(libero@gen_error:gen_error()).
scaffold_shared_state(Path) ->
Content = <<"/// Replace this with your application's shared state type.
/// If your state lives in ETS or a database, this unit type
/// is fine as-is.
pub type SharedState {
SharedState
}
pub fn new() -> SharedState {
SharedState
}
"/utf8>>,
case simplifile:write(Path, Content) of
{ok, nil} ->
gleam_stdlib:println(<<" scaffolded "/utf8, Path/binary>>),
[];
{error, Cause} ->
[{cannot_write_file, Path, Cause}]
end.
-file("src/libero/scanner.gleam", 445).
?DOC(" Scaffold a default app_error.gleam if missing.\n").
-spec scaffold_app_error(binary()) -> list(libero@gen_error:gen_error()).
scaffold_app_error(Path) ->
Content = <<"/// Replace this with your application's error type.
/// Handlers return Result(#(MsgFromServer, SharedState), AppError),
/// and AppError values are sent to the client as typed errors.
pub type AppError {
AppError(String)
}
"/utf8>>,
case simplifile:write(Path, Content) of
{ok, nil} ->
gleam_stdlib:println(<<" scaffolded "/utf8, Path/binary>>),
[];
{error, Cause} ->
[{cannot_write_file, Path, Cause}]
end.
-file("src/libero/scanner.gleam", 507).
-spec read_or_error(
message_module(),
fun((binary()) -> list(libero@gen_error:gen_error()))
) -> list(libero@gen_error:gen_error()).
read_or_error(M, Next) ->
case simplifile:read(erlang:element(3, M)) of
{error, Cause} ->
[{cannot_read_file, erlang:element(3, M), Cause}];
{ok, Source} ->
Next(Source)
end.
-file("src/libero/scanner.gleam", 517).
-spec parse_or_error(
message_module(),
binary(),
fun((glance:module_()) -> list(libero@gen_error:gen_error()))
) -> list(libero@gen_error:gen_error()).
parse_or_error(M, Source, Next) ->
case glance:module(Source) of
{error, Cause} ->
[{parse_failed, erlang:element(3, M), Cause}];
{ok, Ast} ->
Next(Ast)
end.
-file("src/libero/scanner.gleam", 484).
-spec check_variant_fields(message_module()) -> list(libero@gen_error:gen_error()).
check_variant_fields(M) ->
read_or_error(
M,
fun(Source) ->
parse_or_error(
M,
Source,
fun(Ast) ->
Msg_from_server = gleam@list:find(
erlang:element(3, Ast),
fun(D) ->
erlang:element(3, erlang:element(3, D)) =:= <<"MsgFromServer"/utf8>>
end
),
case Msg_from_server of
{error, nil} ->
[];
{ok, Ct_def} ->
gleam@list:filter_map(
erlang:element(7, erlang:element(3, Ct_def)),
fun(Variant) ->
Field_count = erlang:length(
erlang:element(3, Variant)
),
case Field_count of
0 ->
{error, nil};
1 ->
{error, nil};
_ ->
{ok,
{msg_from_server_field_count,
erlang:element(2, M),
erlang:element(2, Variant),
Field_count}}
end
end
)
end
end
)
end
).
-file("src/libero/scanner.gleam", 468).
?DOC(
" Validate that every MsgFromServer variant has at most one field.\n"
" Dispatch unwraps the envelope with `element(2, Tuple)` in Erlang,\n"
" which silently drops extra fields. Zero-field variants (bare atoms)\n"
" are fine, they unwrap to Nil.\n"
).
-spec validate_msg_from_server_fields(list(message_module())) -> {ok, nil} |
{error, list(libero@gen_error:gen_error())}.
validate_msg_from_server_fields(Message_modules) ->
Errors = gleam@list:flat_map(
Message_modules,
fun(M) -> case erlang:element(5, M) of
false ->
[];
true ->
check_variant_fields(M)
end end
),
case Errors of
[] ->
{ok, nil};
_ ->
{error, Errors}
end.
-file("src/libero/scanner.gleam", 530).
?DOC(
" Extract the last path segment from a module path.\n"
" E.g. `shared/todos` -> `todos`, `todos` -> `todos`.\n"
).
-spec last_module_segment(binary()) -> binary().
last_module_segment(Module_path) ->
_pipe = gleam@string:split(Module_path, <<"/"/utf8>>),
_pipe@1 = gleam@list:last(_pipe),
gleam@result:unwrap(_pipe@1, Module_path).
-file("src/libero/scanner.gleam", 279).
-spec visit_subdirectory(list(binary()), binary(), binary()) -> {ok,
list(binary())} |
{error, libero@gen_error:gen_error()}.
visit_subdirectory(Acc, Entry, Child) ->
gleam@bool:guard(
Entry =:= <<"generated"/utf8>>,
{ok, Acc},
fun() ->
gleam@result:'try'(
walk_directory(Child),
fun(Nested) -> {ok, lists:append(Nested, Acc)} end
)
end
).
-file("src/libero/scanner.gleam", 242).
?DOC(
" Recursively walk a directory, returning every `.gleam` file found.\n"
" Skips any subdirectory named `generated`, since libero never reads its\n"
" own output, and leaving this convention in place means consumers\n"
" don't need to configure scan_excludes as their projects grow.\n"
).
-spec walk_directory(binary()) -> {ok, list(binary())} |
{error, libero@gen_error:gen_error()}.
walk_directory(Path) ->
gleam@result:'try'(
begin
_pipe = simplifile_erl:read_directory(Path),
gleam@result:map_error(
_pipe,
fun(Cause) -> {cannot_read_dir, Path, Cause} end
)
end,
fun(Entries) ->
gleam@list:try_fold(
Entries,
[],
fun(Acc, Entry) -> visit_entry(Acc, Path, Entry) end
)
end
).
-file("src/libero/scanner.gleam", 257).
?DOC(
" Classify a single directory entry and fold it into the accumulator.\n"
" Stat failures (permissions, races) fall through as \"not a directory\",\n"
" which means the entry is evaluated as a file and filtered out unless\n"
" its name happens to end in `.gleam`. Missing files can't match, so\n"
" silently skipping is safe.\n"
).
-spec visit_entry(list(binary()), binary(), binary()) -> {ok, list(binary())} |
{error, libero@gen_error:gen_error()}.
visit_entry(Acc, Parent, Entry) ->
Child = <<<<Parent/binary, "/"/utf8>>/binary, Entry/binary>>,
gleam@bool:guard(
begin
_pipe = simplifile_erl:is_symlink(Child),
gleam@result:unwrap(_pipe, false)
end,
{ok, Acc},
fun() ->
Is_dir = gleam@result:unwrap(
simplifile_erl:is_directory(Child),
false
),
case Is_dir of
true ->
visit_subdirectory(Acc, Entry, Child);
false ->
{ok, visit_file(Acc, Entry, Child)}
end
end
).
-file("src/libero/scanner.gleam", 61).
?DOC(
" Scan the shared package source directory for modules that export\n"
" `MsgFromClient` or `MsgFromServer` types. These define the wire contract for\n"
" the message-type convention.\n"
"\n"
" Returns `Ok(modules)` with the list of matching modules, or\n"
" `Error([NoMessageModules(...)])` if no message modules are found.\n"
).
-spec scan_message_modules(binary()) -> {ok,
{list(message_module()), gleam@dict:dict(binary(), binary())}} |
{error, list(libero@gen_error:gen_error())}.
scan_message_modules(Shared_src) ->
Files = begin
_pipe = walk_directory(Shared_src),
gleam@result:map_error(
_pipe,
fun(Cause) -> [Cause, {no_message_modules, Shared_src}] end
)
end,
gleam@result:'try'(
Files,
fun(Files@1) ->
Module_files = gleam@list:fold(
Files@1,
maps:new(),
fun(Acc, File_path) ->
Module_path = derive_module_path(File_path),
gleam@dict:insert(Acc, Module_path, File_path)
end
),
Modules = gleam@list:filter_map(
Files@1,
fun(File_path@1) -> parse_message_module(File_path@1) end
),
case Modules of
[] ->
{error, [{no_message_modules, Shared_src}]};
_ ->
{ok, {Modules, Module_files}}
end
end
).
-file("src/libero/scanner.gleam", 128).
?DOC(
" Scan server source for modules that export `pub fn update_from_client`.\n"
" For each such function, resolve the first parameter's type annotation to\n"
" determine which shared message module it handles.\n"
).
-spec scan_handlers(binary()) -> {ok, list(discovered_handler())} |
{error, list(libero@gen_error:gen_error())}.
scan_handlers(Server_src) ->
Files = begin
_pipe = walk_directory(Server_src),
gleam@result:map_error(_pipe, fun(Cause) -> [Cause] end)
end,
gleam@result:'try'(
Files,
fun(Files@1) ->
{ok,
gleam@list:filter_map(
Files@1,
fun(File_path) -> parse_handler(File_path) end
)}
end
).
-file("src/libero/scanner.gleam", 330).
?DOC(
" Validate that the server package follows the conventions required for\n"
" code generation:\n"
" 1. `server/shared_state.gleam` exists\n"
" 2. `server/app_error.gleam` exists\n"
" 3. For each message module with `has_msg_from_client`, a server module\n"
" exports `pub fn update_from_client` with the correct msg type\n"
"\n"
" Returns `Ok(#(updated_modules, errors))` where updated_modules have\n"
" `handler_module` populated from discovered handlers. Errors are fatal\n"
" only if non-empty.\n"
).
-spec validate_conventions(list(message_module()), binary(), binary(), binary()) -> {ok,
list(message_module())} |
{error, list(libero@gen_error:gen_error())}.
validate_conventions(
Message_modules,
Server_src,
Shared_state_path,
App_error_path
) ->
Shared_state_errors = case begin
_pipe = simplifile_erl:is_file(Shared_state_path),
gleam@result:unwrap(_pipe, false)
end of
true ->
[];
false ->
scaffold_shared_state(Shared_state_path)
end,
App_error_errors = case begin
_pipe@1 = simplifile_erl:is_file(App_error_path),
gleam@result:unwrap(_pipe@1, false)
end of
true ->
[];
false ->
scaffold_app_error(App_error_path)
end,
Handlers_result = scan_handlers(Server_src),
{Handlers, Scan_errors} = case Handlers_result of
{ok, H} ->
{H, []};
{error, Errs} ->
{[], Errs}
end,
Match_handler = fun(M, Handler_map) ->
case {erlang:element(4, M),
gleam_stdlib:map_get(Handler_map, erlang:element(2, M))} of
{false, _} ->
{{message_module,
erlang:element(2, M),
erlang:element(3, M),
erlang:element(4, M),
erlang:element(5, M),
[]},
none};
{true, {ok, Handler_list}} ->
{{message_module,
erlang:element(2, M),
erlang:element(3, M),
erlang:element(4, M),
erlang:element(5, M),
Handler_list},
none};
{true, {error, nil}} ->
{{message_module,
erlang:element(2, M),
erlang:element(3, M),
erlang:element(4, M),
erlang:element(5, M),
[]},
{some,
{missing_handler,
erlang:element(2, M),
<<<<"a server module exporting pub fn update_from_client with msg: "/utf8,
(erlang:element(2, M))/binary>>/binary,
".MsgFromClient"/utf8>>}}}
end
end,
Handler_map@1 = gleam@list:fold(
Handlers,
maps:new(),
fun(Acc, H@1) ->
Existing = begin
_pipe@2 = gleam_stdlib:map_get(Acc, erlang:element(3, H@1)),
gleam@result:unwrap(_pipe@2, [])
end,
gleam@dict:insert(
Acc,
erlang:element(3, H@1),
lists:append(Existing, [erlang:element(2, H@1)])
)
end
),
{Updated_modules, Handler_errors} = gleam@list:fold(
Message_modules,
{[], []},
fun(Acc@1, M@1) ->
{Modules_acc, Errors_acc} = Acc@1,
{Updated, New_err} = Match_handler(M@1, Handler_map@1),
New_errors = case New_err of
{some, Err} ->
[Err | Errors_acc];
none ->
Errors_acc
end,
{[Updated | Modules_acc], New_errors}
end
),
All_errors = lists:append(
[Shared_state_errors,
App_error_errors,
Scan_errors,
lists:reverse(Handler_errors)]
),
case All_errors of
[] ->
{ok, lists:reverse(Updated_modules)};
_ ->
{error, All_errors}
end.