diff --git a/src/rebar3_nova.erl b/src/rebar3_nova.erl index 3483f6e..d6e7ce3 100644 --- a/src/rebar3_nova.erl +++ b/src/rebar3_nova.erl @@ -22,10 +22,12 @@ init(State) -> rebar3_nova_gen_resource, rebar3_nova_gen_test, rebar3_nova_gen_auth, + rebar3_nova_gen_live, rebar3_nova_middleware, rebar3_nova_config, rebar3_nova_audit, - rebar3_nova_release + rebar3_nova_release, + rebar3_nova_new ] ); ["git"] -> @@ -44,10 +46,12 @@ init(State) -> rebar3_nova_gen_resource, rebar3_nova_gen_test, rebar3_nova_gen_auth, + rebar3_nova_gen_live, rebar3_nova_middleware, rebar3_nova_config, rebar3_nova_audit, - rebar3_nova_release + rebar3_nova_release, + rebar3_nova_new ] ); SomethingElse -> diff --git a/src/rebar3_nova_gen_live.erl b/src/rebar3_nova_gen_live.erl new file mode 100644 index 0000000..9878ea4 --- /dev/null +++ b/src/rebar3_nova_gen_live.erl @@ -0,0 +1,730 @@ +-module(rebar3_nova_gen_live). + +-export([init/1, do/1, format_error/1]). +-export([generate/5, generate_optional/5]). + +-define(PROVIDER, gen_live). +-define(DEPS, [{default, compile}]). + +-spec init(rebar_state:t()) -> {ok, rebar_state:t()}. +init(State) -> + Provider = providers:create([ + {name, ?PROVIDER}, + {module, ?MODULE}, + {namespace, nova}, + {bare, true}, + {deps, ?DEPS}, + {example, + "rebar3 nova gen_live --name users --fields name:string,email:string,active:boolean"}, + {opts, [ + {name, $n, "name", string, "Resource name, plural (required)"}, + {fields, $f, "fields", string, "Comma-separated field:type pairs (required)"}, + {actions, $a, "actions", {string, "index,show,new,edit"}, + "Comma-separated view actions"}, + {no_schema, undefined, "no-schema", boolean, "Skip schema and migration generation"} + ]}, + {short_desc, "Generate Arizona LiveView CRUD views for a resource"}, + {desc, + "Generates Arizona views, Kura schema, migration, and test suite\n" + "for a resource. Similar to Phoenix's phx.gen.live.\n\n" + "Example:\n" + " rebar3 nova gen_live --name users --fields name:string,email:string,active:boolean\n\n" + "Supported field types: string, text, integer, float, boolean, date,\n" + " utc_datetime, uuid, jsonb"} + ]), + {ok, rebar_state:add_provider(State, Provider)}. + +-spec do(rebar_state:t()) -> {ok, rebar_state:t()} | {error, string()}. +do(State) -> + {Args, _} = rebar_state:command_parsed_args(State), + case {proplists:get_value(name, Args), proplists:get_value(fields, Args)} of + {undefined, _} -> + rebar_api:abort("--name is required", []); + {_, undefined} -> + rebar_api:abort("--fields is required", []); + {Name, FieldsStr} -> + AppName = rebar3_nova_utils:get_app_name(State), + AppDir = rebar3_nova_utils:get_app_dir(State), + ActionsStr = proplists:get_value(actions, Args, "index,show,new,edit"), + Actions = rebar3_nova_utils:parse_actions(ActionsStr), + Fields = parse_fields(FieldsStr), + Opts = #{ + no_schema => proplists:get_value(no_schema, Args, false) + }, + generate(AppName, AppDir, Name, Fields, Actions), + generate_optional(AppName, AppDir, Name, Fields, Opts), + print_route_hints(AppName, Name, Actions), + {ok, State} + end. + +-spec format_error(any()) -> iolist(). +format_error(Reason) -> + io_lib:format("~p", [Reason]). + +-spec generate(atom(), file:filename(), string(), [{string(), string()}], [atom()]) -> ok. +generate(AppName, AppDir, Name, Fields, Actions) -> + App = atom_to_list(AppName), + Singular = singularize(Name), + lists:foreach( + fun(Action) -> + generate_view(App, AppDir, Singular, Name, Fields, Action) + end, + Actions + ). + +%%====================================================================== +%% Internal: optional generators (schema, migration, test) +%%====================================================================== + +generate_optional(AppName, AppDir, Name, Fields, Opts) -> + App = atom_to_list(AppName), + Singular = singularize(Name), + case maps:get(no_schema, Opts, false) of + false -> + generate_schema(App, AppDir, Singular, Name, Fields), + generate_migration(AppDir, Name, Fields); + true -> + ok + end, + generate_test(App, AppDir, Singular, Name, Fields). + +%%====================================================================== +%% Internal: view generators +%%====================================================================== + +generate_view(App, AppDir, Singular, Plural, Fields, index) -> + Mod = App ++ "_" ++ Singular ++ "_index_view", + FileName = filename:join([AppDir, "src", "views", Mod ++ ".erl"]), + Content = index_view_content(Mod, App, Singular, Plural, Fields), + rebar3_nova_utils:write_file_if_not_exists(FileName, Content); +generate_view(App, AppDir, Singular, _Plural, Fields, show) -> + Mod = App ++ "_" ++ Singular ++ "_show_view", + FileName = filename:join([AppDir, "src", "views", Mod ++ ".erl"]), + Content = show_view_content(Mod, App, Singular, Fields), + rebar3_nova_utils:write_file_if_not_exists(FileName, Content); +generate_view(App, AppDir, Singular, _Plural, Fields, new) -> + Mod = App ++ "_" ++ Singular ++ "_new_view", + FileName = filename:join([AppDir, "src", "views", Mod ++ ".erl"]), + Content = new_view_content(Mod, App, Singular, Fields), + rebar3_nova_utils:write_file_if_not_exists(FileName, Content); +generate_view(App, AppDir, Singular, _Plural, Fields, edit) -> + Mod = App ++ "_" ++ Singular ++ "_edit_view", + FileName = filename:join([AppDir, "src", "views", Mod ++ ".erl"]), + Content = edit_view_content(Mod, App, Singular, Fields), + rebar3_nova_utils:write_file_if_not_exists(FileName, Content); +generate_view(_App, _AppDir, _Singular, _Plural, _Fields, _Action) -> + ok. + +%%---------------------------------------------------------------------- +%% Index view +%%---------------------------------------------------------------------- + +index_view_content(Mod, App, Singular, Plural, Fields) -> + Schema = App ++ "_" ++ Singular, + Repo = App ++ "_repo", + iolist_to_binary([ + "-module(", + Mod, + ").\n" + "-compile({parse_transform, arizona_parse_transform}).\n" + "-behaviour(arizona_view).\n\n" + "-export([mount/2, render/1, handle_event/3]).\n\n" + "mount(_MountArg, _Request) ->\n" + " {ok, Items} = kura_repo_worker:all(", + Repo, + ", kura_query:from(", + Schema, + ")),\n" + " arizona_view:new(?MODULE, #{", + Plural, + " => Items}, none).\n\n" + "render(Bindings) ->\n" + " arizona_template:from_html(~\"\"\"\"\n" + "
\n" + "

", + capitalize(Plural), + "

\n" + " New ", + capitalize(Singular), + "\n" + " \n" + " \n" + " \n", + table_headers(Fields), + " \n" + " \n" + " \n" + " \n" + " {arizona_template:render_list(\n" + " fun(Item) ->\n" + " arizona_template:from_html(~\"\"\"\n" + " \n", + table_cells(Fields), + " \n" + " \n" + " \"\"\")\n" + " end, arizona_template:get_binding(", + Plural, + ", Bindings))}\n" + " \n" + "
\n" + " Show\n" + " Edit\n" + "
\n" + "
\n" + " \"\"\"\").\n\n" + "handle_event(~\"delete\", #{~\"id\" := Id}, View) ->\n" + " {ok, Record} = kura_repo_worker:get(", + Repo, + ", ", + Schema, + ", Id),\n" + " CS = kura_changeset:cast(", + Schema, + ", Record, #{}, []),\n" + " {ok, _} = kura_repo_worker:delete(", + Repo, + ", CS),\n" + " {ok, Items} = kura_repo_worker:all(", + Repo, + ", kura_query:from(", + Schema, + ")),\n" + " State = arizona_view:get_state(View),\n" + " NewState = arizona_stateful:put_binding(", + Plural, + ", Items, State),\n" + " {[], arizona_view:update_state(NewState, View)}.\n" + ]). + +table_headers(Fields) -> + iolist_to_binary([ + [" ", capitalize(Name), "\n"] + || {Name, _Type} <- Fields + ]). + +table_cells(Fields) -> + iolist_to_binary([ + [" {maps:get(", Name, ", Item)}\n"] + || {Name, _Type} <- Fields + ]). + +%%---------------------------------------------------------------------- +%% Show view +%%---------------------------------------------------------------------- + +show_view_content(Mod, App, Singular, Fields) -> + Schema = App ++ "_" ++ Singular, + Repo = App ++ "_repo", + iolist_to_binary([ + "-module(", + Mod, + ").\n" + "-compile({parse_transform, arizona_parse_transform}).\n" + "-behaviour(arizona_view).\n\n" + "-export([mount/2, render/1]).\n\n" + "mount(#{id := Id}, _Request) ->\n" + " {ok, Item} = kura_repo_worker:get(", + Repo, + ", ", + Schema, + ", Id),\n" + " arizona_view:new(?MODULE, #{", + Singular, + " => Item}, none).\n\n" + "render(Bindings) ->\n" + " ", + capitalize(Singular), + " = arizona_template:get_binding(", + Singular, + ", Bindings),\n" + " arizona_template:from_html(~\"\"\"\n" + "
\n" + "

", + capitalize(Singular), + "

\n" + "
\n", + show_fields(Singular, Fields), + "
\n" + " Back\n" + " Edit\n" + "
\n" + " \"\"\").\n" + ]). + +show_fields(_Singular, Fields) -> + iolist_to_binary([ + [ + "
", + capitalize(Name), + "
\n" + "
{maps:get(", + Name, + ", ", + capitalize(_Singular), + ")}
\n" + ] + || {Name, _Type} <- Fields + ]). + +%%---------------------------------------------------------------------- +%% New view +%%---------------------------------------------------------------------- + +new_view_content(Mod, App, Singular, Fields) -> + Schema = App ++ "_" ++ Singular, + Repo = App ++ "_repo", + CastFields = field_names_list(Fields), + iolist_to_binary([ + "-module(", + Mod, + ").\n" + "-compile({parse_transform, arizona_parse_transform}).\n" + "-behaviour(arizona_view).\n\n" + "-export([mount/2, render/1, handle_event/3]).\n\n" + "mount(_MountArg, _Request) ->\n" + " arizona_view:new(?MODULE, #{errors => []}, none).\n\n" + "render(Bindings) ->\n" + " arizona_template:from_html(~\"\"\"\n" + "
\n" + "

New ", + capitalize(Singular), + "

\n" + "
\n", + form_fields(Fields), + " \n" + " Cancel\n" + "
\n" + "
\n" + " \"\"\").\n\n" + "handle_event(~\"save\", Params, View) ->\n" + " CS = kura_changeset:cast(", + Schema, + ", #{}, Params, ", + CastFields, + "),\n" + " CS1 = kura_changeset:validate_required(CS, ", + CastFields, + "),\n" + " case kura_repo_worker:insert(", + Repo, + ", CS1) of\n" + " {ok, _} ->\n" + " {[{redirect, ~\"/", + pluralize(Singular), + "\", #{}}], View};\n" + " {error, Changeset} ->\n" + " Errors = maps:get(errors, Changeset, []),\n" + " State = arizona_view:get_state(View),\n" + " NewState = arizona_stateful:put_binding(errors, Errors, State),\n" + " {[], arizona_view:update_state(NewState, View)}\n" + " end.\n" + ]). + +%%---------------------------------------------------------------------- +%% Edit view +%%---------------------------------------------------------------------- + +edit_view_content(Mod, App, Singular, Fields) -> + Schema = App ++ "_" ++ Singular, + Repo = App ++ "_repo", + CastFields = field_names_list(Fields), + iolist_to_binary([ + "-module(", + Mod, + ").\n" + "-compile({parse_transform, arizona_parse_transform}).\n" + "-behaviour(arizona_view).\n\n" + "-export([mount/2, render/1, handle_event/3]).\n\n" + "mount(#{id := Id}, _Request) ->\n" + " {ok, Item} = kura_repo_worker:get(", + Repo, + ", ", + Schema, + ", Id),\n" + " arizona_view:new(?MODULE, #{", + Singular, + " => Item, errors => []}, none).\n\n" + "render(Bindings) ->\n" + " ", + capitalize(Singular), + " = arizona_template:get_binding(", + Singular, + ", Bindings),\n" + " arizona_template:from_html(~\"\"\"\n" + "
\n" + "

Edit ", + capitalize(Singular), + "

\n" + "
\n", + edit_form_fields(Singular, Fields), + " \n" + " Cancel\n" + "
\n" + "
\n" + " \"\"\").\n\n" + "handle_event(~\"save\", Params, View) ->\n" + " State0 = arizona_view:get_state(View),\n" + " ", + capitalize(Singular), + " = arizona_stateful:get_binding(", + Singular, + ", State0),\n" + " Id = maps:get(id, ", + capitalize(Singular), + "),\n" + " {ok, Existing} = kura_repo_worker:get(", + Repo, + ", ", + Schema, + ", Id),\n" + " CS = kura_changeset:cast(", + Schema, + ", Existing, Params, ", + CastFields, + "),\n" + " case kura_repo_worker:update(", + Repo, + ", CS) of\n" + " {ok, _} ->\n" + " {[{redirect, ~\"/", + pluralize(Singular), + "\", #{}}], View};\n" + " {error, Changeset} ->\n" + " Errors = maps:get(errors, Changeset, []),\n" + " NewState = arizona_stateful:put_binding(errors, Errors, State0),\n" + " {[], arizona_view:update_state(NewState, View)}\n" + " end.\n" + ]). + +%%====================================================================== +%% Internal: form field generation +%%====================================================================== + +form_fields(Fields) -> + iolist_to_binary([form_field(Name, Type) || {Name, Type} <- Fields]). + +form_field(Name, Type) -> + Label = capitalize(Name), + InputType = field_to_input_type(Type), + case InputType of + "textarea" -> + [ + " \n" + " \n" + ]; + "checkbox" -> + [ + " \n" + ]; + _ -> + [ + " \n" + " \n" + ] + end. + +edit_form_fields(Singular, Fields) -> + iolist_to_binary([edit_form_field(Singular, Name, Type) || {Name, Type} <- Fields]). + +edit_form_field(Singular, Name, Type) -> + Label = capitalize(Name), + InputType = field_to_input_type(Type), + Value = "maps:get(" ++ Name ++ ", " ++ capitalize(Singular) ++ ")", + case InputType of + "textarea" -> + [ + " \n" + " \n" + ]; + "checkbox" -> + [ + " \n" + ]; + _ -> + [ + " \n" + " \n" + ] + end. + +field_to_input_type("string") -> "text"; +field_to_input_type("text") -> "textarea"; +field_to_input_type("integer") -> "number"; +field_to_input_type("float") -> "number"; +field_to_input_type("boolean") -> "checkbox"; +field_to_input_type("date") -> "date"; +field_to_input_type("utc_datetime") -> "datetime-local"; +field_to_input_type("uuid") -> "text"; +field_to_input_type("jsonb") -> "textarea"; +field_to_input_type(_) -> "text". + +%%====================================================================== +%% Internal: schema generator +%%====================================================================== + +generate_schema(App, AppDir, Singular, Plural, Fields) -> + Mod = App ++ "_" ++ Singular, + FileName = filename:join([AppDir, "src", "schemas", Mod ++ ".erl"]), + Content = schema_content(Mod, Plural, Fields), + rebar3_nova_utils:write_file_if_not_exists(FileName, Content). + +schema_content(Mod, Table, Fields) -> + FieldDefs = schema_fields(Fields), + iolist_to_binary([ + "-module(", + Mod, + ").\n" + "-behaviour(kura_schema).\n" + "-include_lib(\"kura/include/kura.hrl\").\n\n" + "-export([table/0, fields/0]).\n\n" + "table() -> <<\"", + Table, + "\">>.\n\n" + "fields() ->\n" + " [\n" + " #kura_field{name = id, type = id, primary_key = true, nullable = false}", + FieldDefs, + ",\n" + " #kura_field{name = inserted_at, type = utc_datetime},\n" + " #kura_field{name = updated_at, type = utc_datetime}\n" + " ].\n" + ]). + +schema_fields(Fields) -> + iolist_to_binary([ + [",\n #kura_field{name = ", Name, ", type = ", Type, "}"] + || {Name, Type} <- Fields + ]). + +%%====================================================================== +%% Internal: migration generator +%%====================================================================== + +generate_migration(AppDir, Table, Fields) -> + TS = timestamp(), + Mod = "m" ++ TS ++ "_create_" ++ Table, + FileName = filename:join([AppDir, "src", "migrations", Mod ++ ".erl"]), + Content = migration_content(Mod, Table, Fields), + rebar3_nova_utils:write_file_if_not_exists(FileName, Content). + +migration_content(Mod, Table, Fields) -> + Columns = migration_columns(Fields), + iolist_to_binary([ + "-module(", + Mod, + ").\n" + "-behaviour(kura_migration).\n" + "-include_lib(\"kura/include/kura.hrl\").\n\n" + "-export([up/0, down/0]).\n\n" + "up() ->\n" + " [{create_table, <<\"", + Table, + "\">>, [\n" + " #kura_column{name = id, type = id, primary_key = true}", + Columns, + ",\n" + " #kura_column{name = inserted_at, type = utc_datetime},\n" + " #kura_column{name = updated_at, type = utc_datetime}\n" + " ]}].\n\n" + "down() ->\n" + " [{drop_table, <<\"", + Table, + "\">>}].\n" + ]). + +migration_columns(Fields) -> + iolist_to_binary([ + [",\n #kura_column{name = ", Name, ", type = ", Type, "}"] + || {Name, Type} <- Fields + ]). + +%%====================================================================== +%% Internal: test generator +%%====================================================================== + +generate_test(App, AppDir, Singular, Plural, Fields) -> + Mod = App ++ "_" ++ Singular ++ "_live_SUITE", + FileName = filename:join([AppDir, "test", Mod ++ ".erl"]), + Content = test_content(Mod, App, Singular, Plural, Fields), + rebar3_nova_utils:write_file_if_not_exists(FileName, Content). + +test_content(Mod, App, Singular, _Plural, _Fields) -> + Schema = App ++ "_" ++ Singular, + Repo = App ++ "_repo", + iolist_to_binary([ + "-module(", + Mod, + ").\n\n" + "-include_lib(\"common_test/include/ct.hrl\").\n" + "-include_lib(\"stdlib/include/assert.hrl\").\n\n" + "-export([all/0, init_per_suite/1, end_per_suite/1]).\n" + "-export([list_test/1, get_test/1, create_test/1, update_test/1, delete_test/1]).\n\n" + "all() ->\n" + " [list_test, get_test, create_test, update_test, delete_test].\n\n" + "init_per_suite(Config) ->\n" + " {ok, _} = application:ensure_all_started(", + App, + "),\n" + " Config.\n\n" + "end_per_suite(_Config) ->\n" + " ok.\n\n" + "list_test(_Config) ->\n" + " {ok, Items} = kura_repo_worker:all(", + Repo, + ", kura_query:from(", + Schema, + ")),\n" + " ?assert(is_list(Items)).\n\n" + "get_test(_Config) ->\n" + " %% TODO: Create a record first, then fetch it\n" + " ok.\n\n" + "create_test(_Config) ->\n" + " %% TODO: Insert with kura_changeset:cast/4 + kura_repo_worker:insert/2\n" + " ok.\n\n" + "update_test(_Config) ->\n" + " %% TODO: Create a record, then update it\n" + " ok.\n\n" + "delete_test(_Config) ->\n" + " %% TODO: Create a record, then delete it\n" + " ok.\n" + ]). + +%%====================================================================== +%% Internal: route hints +%%====================================================================== + +print_route_hints(AppName, Plural, Actions) -> + Singular = singularize(Plural), + rebar_api:info("~nAdd these routes to your router:~n", []), + rebar_api:info(" %% ~s views", [capitalize(Singular)]), + lists:foreach( + fun(Action) -> print_route_hint(AppName, Singular, Plural, Action) end, + Actions + ). + +print_route_hint(AppName, Singular, Plural, index) -> + rebar_api:info( + " {<<\"/~s\">>, {~s_~s_index_view, mount}, #{methods => [get]}}", + [Plural, AppName, Singular] + ); +print_route_hint(AppName, Singular, Plural, show) -> + rebar_api:info( + " {<<\"/~s/:id\">>, {~s_~s_show_view, mount}, #{methods => [get]}}", + [Plural, AppName, Singular] + ); +print_route_hint(AppName, Singular, Plural, new) -> + rebar_api:info( + " {<<\"/~s/new\">>, {~s_~s_new_view, mount}, #{methods => [get]}}", + [Plural, AppName, Singular] + ); +print_route_hint(AppName, Singular, Plural, edit) -> + rebar_api:info( + " {<<\"/~s/:id/edit\">>, {~s_~s_edit_view, mount}, #{methods => [get]}}", + [Plural, AppName, Singular] + ); +print_route_hint(_, _, _, _) -> + ok. + +%%====================================================================== +%% Internal: helpers +%%====================================================================== + +parse_fields(Str) -> + Pairs = string:tokens(Str, ","), + [parse_field(string:trim(P)) || P <- Pairs]. + +parse_field(Pair) -> + case string:tokens(Pair, ":") of + [Name, Type] -> {string:trim(Name), string:trim(Type)}; + [Name] -> {string:trim(Name), "string"}; + _ -> erlang:error({bad_field_spec, Pair}) + end. + +singularize(Name) -> + case lists:reverse(Name) of + [$s | Rest] -> lists:reverse(Rest); + _ -> Name + end. + +pluralize(Name) -> + case lists:last(Name) of + $s -> Name; + _ -> Name ++ "s" + end. + +capitalize([H | T]) when H >= $a, H =< $z -> + [H - 32 | T]; +capitalize(Other) -> + Other. + +timestamp() -> + {{Y, Mo, D}, {H, Mi, S}} = calendar:universal_time(), + lists:flatten( + io_lib:format( + "~4..0B~2..0B~2..0B~2..0B~2..0B~2..0B", + [Y, Mo, D, H, Mi, S] + ) + ). + +field_names_list(Fields) -> + Names = [Name || {Name, _Type} <- Fields], + "[" ++ string:join(Names, ", ") ++ "]". diff --git a/src/rebar3_nova_new.erl b/src/rebar3_nova_new.erl new file mode 100644 index 0000000..b178606 --- /dev/null +++ b/src/rebar3_nova_new.erl @@ -0,0 +1,1139 @@ +-module(rebar3_nova_new). + +-export([init/1, do/1, format_error/1]). +-export([generate_project/2, validate_flags/1]). + +-define(PROVIDER, new). +-define(DEPS, []). + +-spec init(rebar_state:t()) -> {ok, rebar_state:t()}. +init(State) -> + Provider = providers:create([ + {name, ?PROVIDER}, + {module, ?MODULE}, + {namespace, nova}, + {bare, true}, + {deps, ?DEPS}, + {example, + "rebar3 nova new myapp [--kura] [--pgo] [--arizona] [--lfe] [--ci] [--docker] [--otel]"}, + {opts, [ + {name, $n, "name", string, "Project name"}, + {kura, undefined, "kura", boolean, "Include Kura database layer"}, + {pgo, undefined, "pgo", boolean, "Include PGO PostgreSQL client"}, + {arizona, undefined, "arizona", boolean, "Include Arizona live views"}, + {lfe, undefined, "lfe", boolean, "Generate LFE source files"}, + {ci, undefined, "ci", boolean, "Generate GitHub Actions CI workflow"}, + {docker, undefined, "docker", boolean, "Generate Dockerfile"}, + {otel, undefined, "otel", boolean, "Include OpenTelemetry instrumentation"} + ]}, + {short_desc, "Create a new Nova project"}, + {desc, "Create a new Nova project with composable flags"} + ]), + {ok, rebar_state:add_provider(State, Provider)}. + +-spec do(rebar_state:t()) -> {ok, rebar_state:t()} | {error, string()}. +do(State) -> + {Opts, Args} = rebar_state:command_parsed_args(State), + Name = resolve_name(Opts, Args), + Flags = parse_flags(Opts), + case validate_flags(Flags) of + ok -> + generate_project(Name, Flags), + print_summary(Name, Flags), + {ok, State}; + {error, Reason} -> + {error, Reason} + end. + +-spec format_error(any()) -> iolist(). +format_error(Reason) -> + io_lib:format("~p", [Reason]). + +%%====================================================================== +%% Flag parsing +%%====================================================================== + +resolve_name(Opts, Args) -> + case proplists:get_value(name, Opts) of + undefined -> + case Args of + [N | _] -> N; + _ -> error(missing_project_name) + end; + N -> + N + end. + +parse_flags(Opts) -> + #{ + kura => proplists:get_value(kura, Opts, false), + pgo => proplists:get_value(pgo, Opts, false), + arizona => proplists:get_value(arizona, Opts, false), + lfe => proplists:get_value(lfe, Opts, false), + ci => proplists:get_value(ci, Opts, false), + docker => proplists:get_value(docker, Opts, false), + otel => proplists:get_value(otel, Opts, false) + }. + +validate_flags(#{kura := true, pgo := true}) -> + {error, "--kura and --pgo are mutually exclusive (kura uses pgo internally)"}; +validate_flags(_) -> + ok. + +%%====================================================================== +%% Project generation +%%====================================================================== + +generate_project(Name, Flags) -> + case filelib:is_dir(Name) of + true -> + error({dir_exists, Name}); + false -> + ok + end, + generate_rebar_config(Name, Flags), + generate_app_src(Name, Flags), + generate_app(Name, Flags), + generate_sup(Name, Flags), + generate_router(Name, Flags), + generate_controller(Name, Flags), + generate_dev_sys_config(Name, Flags), + generate_prod_sys_config(Name, Flags), + generate_vm_args(Name), + generate_tool_versions(Name), + generate_gitignore(Name), + copy_favicon(Name), + maybe_generate_view(Name, Flags), + maybe_generate_kura(Name, Flags), + maybe_generate_pgo(Name, Flags), + maybe_generate_arizona(Name, Flags), + maybe_generate_ci(Name, Flags), + maybe_generate_docker(Name, Flags). + +%%====================================================================== +%% rebar.config +%%====================================================================== + +generate_rebar_config(Name, Flags) -> + Path = filename:join(Name, "rebar.config"), + Content = [ + "%% -*- mode: erlang;erlang-indent-level: 4;indent-tabs-mode: nil -*-\n\n", + "{erl_opts, [debug_info]}.\n", + "{src_dirs, [{\"src\", [{recursive, true}]}]}.\n", + "{shell, [{config, \"./config/dev_sys.config.src\"}]}.\n\n", + rebar_erlydtl_opts(Flags), + rebar_deps(Flags), + rebar_relx(Name, Flags), + rebar_profiles(Flags), + "{dialyzer, [{plt_apps, all_deps}]}.\n\n", + rebar_plugins(Flags), + rebar_erlfmt(Flags), + rebar_provider_hooks(Flags), + rebar_xref() + ], + rebar3_nova_utils:write_file(Path, Content). + +rebar_erlydtl_opts(#{arizona := true}) -> + []; +rebar_erlydtl_opts(#{lfe := true}) -> + [ + "{erlydtl_opts, [\n", + " {doc_root, \"src/views\"},\n", + " {recursive, true},\n", + " {libraries, [\n", + " {nova_erlydtl_inventory, nova_erlydtl_inventory}\n", + " ]},\n", + " {default_libraries, [nova_erlydtl_inventory]}\n", + "]}.\n\n" + ]; +rebar_erlydtl_opts(_) -> + [ + "{erlydtl_opts, [\n", + " {doc_root, \"src/views\"},\n", + " {recursive, true},\n", + " {libraries, [\n", + " {nova_erlydtl_inventory, nova_erlydtl_inventory}\n", + " ]},\n", + " {default_libraries, [nova_erlydtl_inventory]}\n", + "]}.\n\n" + ]. + +rebar_deps(Flags) -> + BaseDeps = + case maps:get(lfe, Flags) of + true -> [" nova,\n", " {logjam, \"1.2.4\"}"]; + false -> [" nova,\n", " {flatlog, \"0.1.2\"}"] + end, + KuraDep = + case maps:get(kura, Flags) of + true -> [",\n kura"]; + false -> [] + end, + PgoDep = + case maps:get(pgo, Flags) of + true -> [",\n pgo"]; + false -> [] + end, + ArizonaDeps = + case maps:get(arizona, Flags) of + true -> [",\n arizona_core,\n arizona_nova"]; + false -> [] + end, + LfeDep = + case maps:get(lfe, Flags) of + true -> + [",\n {lfe, {git, \"http://github.com/rvirding/lfe\", {branch, \"develop\"}}}"]; + false -> + [] + end, + OtelDeps = + case maps:get(otel, Flags) of + true -> + [ + ",\n opentelemetry,\n opentelemetry_api,\n opentelemetry_exporter,\n opentelemetry_nova" + ]; + false -> + [] + end, + ["{deps, [\n", BaseDeps, KuraDep, PgoDep, ArizonaDeps, LfeDep, OtelDeps, "\n]}.\n\n"]. + +rebar_relx(Name, _Flags) -> + [ + "{relx, [\n", + " {release, {", + Name, + ", git}, [\n", + " ", + Name, + ",\n", + " sasl\n", + " ]},\n", + " {mode, dev},\n", + " {sys_config_src, \"./config/dev_sys.config.src\"},\n", + " {vm_args_src, \"./config/vm.args.src\"}\n", + "]}.\n\n" + ]. + +rebar_profiles(_Flags) -> + [ + "{profiles, [\n", + " {prod, [\n", + " {relx, [\n", + " {mode, prod},\n", + " {sys_config_src, \"./config/prod_sys.config.src\"}\n", + " ]}\n", + " ]},\n", + " {test, [\n", + " {erl_opts, [nowarn_export_all, warnings_as_errors, {i, \"test/util\"}]},\n", + " {deps, [\n", + " {meck, \"1.1.0\"}\n", + " ]},\n", + " {ct_opts, [{sys_config, \"./config/dev_sys.config.src\"}]},\n", + " {extra_src_dirs, [{\"test\", [{recursive, true}]}]},\n", + " {xref_extra_paths, [\"test\"]}\n", + " ]}\n", + "]}.\n\n" + ]. + +rebar_plugins(Flags) -> + ErlydtlPlugin = + case maps:get(arizona, Flags) of + true -> + []; + false -> + [ + " {rebar3_erlydtl_plugin, \".*\",\n", + " {git, \"https://github.com/erlydtl/rebar3_erlydtl_plugin.git\", {branch, \"master\"}}},\n" + ] + end, + LfePlugin = + case maps:get(lfe, Flags) of + true -> + [ + " {rebar3_lfe, {git, \"http://github.com/lfe-rebar3/rebar3_lfe\", {branch, \"release/0.4.x\"}}},\n" + ]; + false -> + [] + end, + [ + "{project_plugins, [\n", + ErlydtlPlugin, + LfePlugin, + " {rebar3_nova, \".*\",\n", + " {git, \"https://github.com/novaframework/rebar3_nova.git\", {branch, \"master\"}}},\n", + " {erlfmt, \"~>1.7\"}\n", + "]}.\n\n" + ]. + +rebar_erlfmt(_Flags) -> + [ + "{erlfmt, [\n", + " write,\n", + " {files, [\n", + " \"rebar.config\",\n", + " \"src/*.app.src\",\n", + " \"src/**/{*.erl, *.hrl}\",\n", + " \"test/**/{*.erl, *.hrl}\"\n", + " ]},\n", + " {exclude_files, [\"config/vm.args.src\", \"config/prod_sys.config.src\"]}\n", + "]}.\n\n" + ]. + +rebar_provider_hooks(#{lfe := true}) -> + [ + "{provider_hooks, [\n", + " {pre, [{compile, {erlydtl, compile}},\n", + " {compile, {lfe, compile}}]}\n", + "]}.\n\n" + ]; +rebar_provider_hooks(#{arizona := true}) -> + []; +rebar_provider_hooks(_) -> + []. + +rebar_xref() -> + [ + "{xref_checks, [\n", + " undefined_function_calls,\n", + " undefined_functions,\n", + " locals_not_used,\n", + " deprecated_function_calls,\n", + " deprecated_functions\n", + "]}.\n" + ]. + +%%====================================================================== +%% app.src +%%====================================================================== + +generate_app_src(Name, Flags) -> + Path = filename:join([Name, "src", Name ++ ".app.src"]), + Apps = app_src_applications(Flags), + Content = [ + "%% -*- mode: erlang;erlang-indent-level: 4;indent-tabs-mode: nil -*-\n", + "{application, ", + Name, + ", [\n", + " {description, \"", + Name, + " managed by Nova\"},\n", + " {vsn, git},\n", + " {registered, []},\n", + " {mod, {", + Name, + "_app, []}},\n", + " {included_applications, []},\n", + " {applications, [\n", + " ", + Apps, + "\n", + " ]},\n", + " {env, []},\n", + " {modules, []},\n", + " {maintainers, []},\n", + " {licenses, [\"Apache 2.0\"]},\n", + " {links, []}\n", + "]}.\n" + ], + rebar3_nova_utils:write_file(Path, Content). + +app_src_applications(Flags) -> + Base = ["kernel,\n stdlib,\n nova"], + Kura = + case maps:get(kura, Flags) of + true -> [",\n kura"]; + false -> [] + end, + Pgo = + case maps:get(pgo, Flags) of + true -> [",\n pgo"]; + false -> [] + end, + Arizona = + case maps:get(arizona, Flags) of + true -> [",\n arizona_core,\n arizona_nova"]; + false -> [] + end, + Otel = + case maps:get(otel, Flags) of + true -> [",\n opentelemetry,\n opentelemetry_api"]; + false -> [] + end, + [Base, Kura, Pgo, Arizona, Otel]. + +%%====================================================================== +%% app.erl / app.lfe +%%====================================================================== + +generate_app(Name, #{lfe := true}) -> + Path = filename:join([Name, "src", Name ++ "_app.lfe"]), + Content = [ + "(defmodule ", + Name, + "_app\n", + " (behaviour gen_server)\n", + " (export\n", + " ;; app implementation\n", + " (start 2)\n", + " (stop 0)))\n\n", + "(include-lib \"logjam/include/logjam.hrl\")\n\n", + "(defun start (_type _args)\n", + " (log-info \"Starting ", + Name, + " application ...\")\n", + " (", + Name, + ".sup:start_link))\n\n", + "(defun stop ()\n", + " (", + Name, + ".sup:stop)\n", + " 'ok)\n" + ], + rebar3_nova_utils:write_file(Path, Content); +generate_app(Name, #{otel := true}) -> + Path = filename:join([Name, "src", Name ++ "_app.erl"]), + Content = [ + "-module(", + Name, + "_app).\n\n", + "-behaviour(application).\n\n", + "-export([start/2, stop/1]).\n\n", + "start(_StartType, _StartArgs) ->\n", + " opentelemetry:setup(),\n", + " ", + Name, + "_sup:start_link().\n\n", + "stop(_State) ->\n", + " ok.\n" + ], + rebar3_nova_utils:write_file(Path, Content); +generate_app(Name, _Flags) -> + Path = filename:join([Name, "src", Name ++ "_app.erl"]), + Content = [ + "-module(", + Name, + "_app).\n\n", + "-behaviour(application).\n\n", + "-export([start/2, stop/1]).\n\n", + "start(_StartType, _StartArgs) ->\n", + " ", + Name, + "_sup:start_link().\n\n", + "stop(_State) ->\n", + " ok.\n" + ], + rebar3_nova_utils:write_file(Path, Content). + +%%====================================================================== +%% sup.erl / sup.lfe +%%====================================================================== + +generate_sup(Name, #{lfe := true}) -> + Path = filename:join([Name, "src", Name ++ ".sup.lfe"]), + Content = [ + "(defmodule ", + Name, + ".sup\n", + " (behaviour supervisor)\n", + " (export\n", + " (start_link 0)\n", + " (stop 0)\n", + " (init 1)))\n\n", + "(defun SERVER () (MODULE))\n", + "(defun supervisor-opts () '())\n", + "(defun sup-flags ()\n", + " `#M(strategy one_for_all\n", + " intensity 0\n", + " period 1))\n\n", + "(defun start_link ()\n", + " (supervisor:start_link `#(local ,(SERVER))\n", + " (MODULE)\n", + " (supervisor-opts)))\n\n", + "(defun stop ()\n", + " (gen_server:call (SERVER) 'stop))\n\n", + "(defun init (_args)\n", + " `#(ok #(,(sup-flags) ())))\n" + ], + rebar3_nova_utils:write_file(Path, Content); +generate_sup(Name, #{kura := true}) -> + Path = filename:join([Name, "src", Name ++ "_sup.erl"]), + Content = [ + "-module(", + Name, + "_sup).\n\n", + "-behaviour(supervisor).\n\n", + "-export([start_link/0]).\n", + "-export([init/1]).\n\n", + "-define(SERVER, ?MODULE).\n\n", + "start_link() ->\n", + " supervisor:start_link({local, ?SERVER}, ?MODULE, []).\n\n", + "init([]) ->\n", + " SupFlags = #{strategy => one_for_all},\n", + " ChildSpecs = [\n", + " #{id => ", + Name, + "_repo,\n", + " start => {", + Name, + "_repo, start_link, []},\n", + " type => worker}\n", + " ],\n", + " {ok, {SupFlags, ChildSpecs}}.\n" + ], + rebar3_nova_utils:write_file(Path, Content); +generate_sup(Name, _Flags) -> + Path = filename:join([Name, "src", Name ++ "_sup.erl"]), + Content = [ + "-module(", + Name, + "_sup).\n\n", + "-behaviour(supervisor).\n\n", + "-export([start_link/0]).\n", + "-export([init/1]).\n\n", + "-define(SERVER, ?MODULE).\n\n", + "start_link() ->\n", + " supervisor:start_link({local, ?SERVER}, ?MODULE, []).\n\n", + "init([]) ->\n", + " SupFlags = #{strategy => one_for_all},\n", + " ChildSpecs = [],\n", + " {ok, {SupFlags, ChildSpecs}}.\n" + ], + rebar3_nova_utils:write_file(Path, Content). + +%%====================================================================== +%% router +%%====================================================================== + +generate_router(Name, #{lfe := true, arizona := true}) -> + Path = filename:join([Name, "src", Name ++ "_router.lfe"]), + Content = [ + "(defmodule ", + Name, + "_router\n", + " (export\n", + " (routes 1)))\n\n", + "(defun routes\n", + " (_)\n", + " `(#M(prefix \"\"\n", + " security false\n", + " routes\n", + " (\n", + " #(\"/heartbeat\" ,(lambda (_) #(status 200)) #M(methods (get)))\n", + " #(\"/\" ,(lambda (params) (", + Name, + "_main_controller:index params))\n", + " #M(methods (get)))\n", + " ))\n", + " #M(prefix \"\"\n", + " security false\n", + " routes\n", + " (\n", + " #(\"/ws\" ,(lambda (params) (arizona_nova_adapter:handler params))\n", + " #M(protocol ws))\n", + " ))))\n" + ], + rebar3_nova_utils:write_file(Path, Content); +generate_router(Name, #{lfe := true}) -> + Path = filename:join([Name, "src", Name ++ "_router.lfe"]), + Content = [ + "(defmodule ", + Name, + "_router\n", + " (export\n", + " (routes 1)))\n\n", + "(defun routes\n", + " (_)\n", + " `(#M(prefix \"\"\n", + " security false\n", + " routes\n", + " (\n", + " #(\"/heartbeat\" ,(lambda (_) #(status 200)) #M(methods (get)))\n", + " #(\"/\" ,(lambda (params) (", + Name, + "_main_controller:index params))\n", + " #M(methods (get)))\n", + " ))))\n" + ], + rebar3_nova_utils:write_file(Path, Content); +generate_router(Name, #{arizona := true}) -> + Path = filename:join([Name, "src", Name ++ "_router.erl"]), + Content = [ + "-module(", + Name, + "_router).\n", + "-behaviour(nova_router).\n\n", + "-export([routes/1]).\n\n", + "routes(_Environment) ->\n", + " [\n", + " #{\n", + " prefix => \"\",\n", + " security => false,\n", + " routes => [\n", + " {\"/\", fun ", + Name, + "_main_controller:index/1, #{methods => [get]}},\n", + " {\"/heartbeat\", fun(_) -> {status, 200} end, #{methods => [get]}}\n", + " ]\n", + " },\n", + " #{\n", + " prefix => \"\",\n", + " security => false,\n", + " routes => [\n", + " {\"/ws\", fun arizona_nova_adapter:handler/1, #{protocol => ws}}\n", + " ]\n", + " }\n", + " ].\n" + ], + rebar3_nova_utils:write_file(Path, Content); +generate_router(Name, _Flags) -> + Path = filename:join([Name, "src", Name ++ "_router.erl"]), + Content = [ + "-module(", + Name, + "_router).\n", + "-behaviour(nova_router).\n\n", + "-export([routes/1]).\n\n", + "routes(_Environment) ->\n", + " [\n", + " #{\n", + " prefix => \"\",\n", + " security => false,\n", + " routes => [\n", + " {\"/\", fun ", + Name, + "_main_controller:index/1, #{methods => [get]}},\n", + " {\"/heartbeat\", fun(_) -> {status, 200} end, #{methods => [get]}}\n", + " ]\n", + " }\n", + " ].\n" + ], + rebar3_nova_utils:write_file(Path, Content). + +%%====================================================================== +%% controller +%%====================================================================== + +generate_controller(Name, #{lfe := true}) -> + Path = filename:join([Name, "src", "controllers", Name ++ "_main_controller.lfe"]), + Content = [ + "(defmodule ", + Name, + "_main_controller\n", + " (export\n", + " (index 1)))\n\n", + "(defun index\n", + " (_)\n", + " `#(status 200 #M() \"nova is running!\"))\n" + ], + rebar3_nova_utils:write_file(Path, Content); +generate_controller(Name, _Flags) -> + Path = filename:join([Name, "src", "controllers", Name ++ "_main_controller.erl"]), + Content = [ + "-module(", + Name, + "_main_controller).\n\n", + "-export([index/1]).\n\n", + "index(_Req) ->\n", + " {ok, [{message, \"Hello world!\"}]}.\n" + ], + rebar3_nova_utils:write_file(Path, Content). + +%%====================================================================== +%% dev_sys.config.src +%%====================================================================== + +generate_dev_sys_config(Name, Flags) -> + Path = filename:join([Name, "config", "dev_sys.config.src"]), + Content = [ + "%% -*- mode: erlang;erlang-indent-level: 4;indent-tabs-mode: nil -*-\n\n", + "[\n", + sys_config_kernel(dev, Flags), + sys_config_nova(Name, dev, Flags), + sys_config_pgo(Name, dev, Flags), + sys_config_arizona(Name, dev, Flags), + sys_config_otel(dev, Flags), + "].\n" + ], + rebar3_nova_utils:write_file(Path, Content). + +%%====================================================================== +%% prod_sys.config.src +%%====================================================================== + +generate_prod_sys_config(Name, Flags) -> + Path = filename:join([Name, "config", "prod_sys.config.src"]), + Content = [ + "%% -*- mode: erlang;erlang-indent-level: 4;indent-tabs-mode: nil -*-\n\n", + "[\n", + sys_config_kernel(prod, Flags), + sys_config_nova(Name, prod, Flags), + sys_config_pgo(Name, prod, Flags), + sys_config_arizona(Name, prod, Flags), + sys_config_otel(prod, Flags), + "].\n" + ], + rebar3_nova_utils:write_file(Path, Content). + +%%---------------------------------------------------------------------- +%% sys.config section builders +%%---------------------------------------------------------------------- + +sys_config_kernel(dev, #{lfe := true}) -> + [ + " {kernel, [\n", + " {logger, [\n", + " {handler, default, logger_std_h,\n", + " #{level => info,\n", + " formatter => {logjam,\n", + " #{colored => true,\n", + " time_designator => $\\s,\n", + " time_offset => \"\",\n", + " time_unit => second,\n", + " strip_tz => true,\n", + " level_capitalize => true\n", + " }\n", + " }\n", + " }\n", + " }\n", + " ]}\n", + " ]},\n" + ]; +sys_config_kernel(dev, _Flags) -> + [ + " {kernel, [\n", + " {logger_level, debug},\n", + " #{formatter => {flatlog, #{\n", + " map_depth => 3,\n", + " term_depth => 50,\n", + " colored => true,\n", + " template => [colored_start, \"[\\033[1m\", level, \"\\033[0m\", colored_start,\"] [\", time, \"]\",\n", + " colored_end, \" \", msg, \" (\", mfa, \")\\n\"]\n", + " }}}\n", + " ]},\n" + ]; +sys_config_kernel(prod, _Flags) -> + [ + " {kernel, [\n", + " {logger_level, info},\n", + " {logger,\n", + " [{handler, default, logger_std_h,\n", + " #{level => error,\n", + " config => #{file => \"log/erlang.log\"}}}\n", + " ]}\n", + " ]},\n" + ]. + +sys_config_nova(Name, dev, _Flags) -> + [ + " {nova, [\n", + " {use_stacktrace, true},\n", + " {environment, dev},\n", + " {cowboy_configuration, #{\n", + " port => 8080\n", + " }},\n", + " {dev_mode, true},\n", + " {bootstrap_application, ", + Name, + "},\n", + " {plugins, [\n", + " {pre_request, nova_request_plugin, #{decode_json_body => true}}\n", + " ]}\n", + " ]},\n" + ]; +sys_config_nova(Name, prod, _Flags) -> + [ + " {nova, [\n", + " {use_stacktrace, false},\n", + " {environment, prod},\n", + " {cowboy_configuration, #{\n", + " port => 8080\n", + " }},\n", + " {dev_mode, false},\n", + " {bootstrap_application, ", + Name, + "},\n", + " {plugins, [\n", + " {pre_request, nova_request_plugin, #{decode_json_body => true}}\n", + " ]}\n", + " ]},\n" + ]. + +sys_config_pgo(Name, dev, #{kura := true}) -> + [ + " {pgo, [\n", + " {pools, [\n", + " {default, #{\n", + " pool_size => 10,\n", + " host => \"127.0.0.1\",\n", + " port => 5432,\n", + " database => \"", + Name, + "_dev\",\n", + " user => \"postgres\",\n", + " password => \"postgres\"\n", + " }}\n", + " ]}\n", + " ]},\n" + ]; +sys_config_pgo(Name, dev, #{pgo := true}) -> + [ + " {pgo, [\n", + " {pools, [\n", + " {default, #{\n", + " pool_size => 10,\n", + " host => \"127.0.0.1\",\n", + " port => 5432,\n", + " database => \"", + Name, + "_dev\",\n", + " user => \"postgres\",\n", + " password => \"postgres\"\n", + " }}\n", + " ]}\n", + " ]},\n" + ]; +sys_config_pgo(Name, prod, #{kura := true}) -> + [ + " {pgo, [\n", + " {pools, [\n", + " {default, #{\n", + " pool_size => ${PGO_POOL_SIZE},\n", + " host => \"${DATABASE_HOST}\",\n", + " port => ${DATABASE_PORT},\n", + " database => \"", + Name, + "\",\n", + " user => \"${DATABASE_USER}\",\n", + " password => \"${DATABASE_PASSWORD}\"\n", + " }}\n", + " ]}\n", + " ]},\n" + ]; +sys_config_pgo(Name, prod, #{pgo := true}) -> + [ + " {pgo, [\n", + " {pools, [\n", + " {default, #{\n", + " pool_size => ${PGO_POOL_SIZE},\n", + " host => \"${DATABASE_HOST}\",\n", + " port => ${DATABASE_PORT},\n", + " database => \"", + Name, + "\",\n", + " user => \"${DATABASE_USER}\",\n", + " password => \"${DATABASE_PASSWORD}\"\n", + " }}\n", + " ]}\n", + " ]},\n" + ]; +sys_config_pgo(_Name, _Env, _Flags) -> + []. + +sys_config_arizona(Name, _Env, #{arizona := true}) -> + [ + " {arizona_core, [\n", + " {otp_app, ", + Name, + "},\n", + " {endpoint, #{\n", + " live_reload => true\n", + " }}\n", + " ]},\n" + ]; +sys_config_arizona(_Name, _Env, _Flags) -> + []. + +sys_config_otel(dev, #{otel := true}) -> + [ + " {opentelemetry, [\n", + " {span_processor, simple},\n", + " {traces_exporter, {otel_exporter_stdout, []}}\n", + " ]},\n" + ]; +sys_config_otel(prod, #{otel := true}) -> + [ + " {opentelemetry, [\n", + " {span_processor, batch},\n", + " {traces_exporter, {opentelemetry_exporter, #{endpoints => [\"${OTEL_ENDPOINT}\"]}}}\n", + " ]},\n" + ]; +sys_config_otel(_Env, _Flags) -> + []. + +%%====================================================================== +%% vm.args.src +%%====================================================================== + +generate_vm_args(Name) -> + Path = filename:join([Name, "config", "vm.args.src"]), + Content = [ + "-sname '", + Name, + "'\n\n", + "-setcookie ", + Name, + "_cookie\n\n", + "+K true\n", + "+A30\n" + ], + rebar3_nova_utils:write_file(Path, Content). + +%%====================================================================== +%% .tool-versions +%%====================================================================== + +generate_tool_versions(Name) -> + Path = filename:join(Name, ".tool-versions"), + Content = [ + "erlang 28.0.4\n", + "rebar 3.25.0\n" + ], + rebar3_nova_utils:write_file(Path, Content). + +%%====================================================================== +%% .gitignore +%%====================================================================== + +generate_gitignore(Name) -> + Path = filename:join(Name, ".gitignore"), + Content = [ + ".rebar3\n", + "_*\n", + ".eunit\n", + "*.o\n", + "*.beam\n", + "*.plt\n", + "*.swp\n", + "*.swo\n", + ".erlang.cookie\n", + "ebin\n", + "log\n", + "erl_crash.dump\n", + ".rebar\n", + "logs\n", + "_build\n", + ".idea\n", + "*.iml\n", + "rebar3.crashdump\n", + "*~\n" + ], + rebar3_nova_utils:write_file(Path, Content). + +%%====================================================================== +%% favicon +%%====================================================================== + +copy_favicon(Name) -> + DestPath = filename:join([Name, "priv", "assets", "favicon.ico"]), + rebar3_nova_utils:copy_priv_file( + filename:join(["templates", "nova", "favicon.ico"]), + DestPath + ). + +%%====================================================================== +%% maybe_generate_view (ErlyDTL template, skip for arizona) +%%====================================================================== + +maybe_generate_view(_Name, #{arizona := true}) -> + ok; +maybe_generate_view(Name, _Flags) -> + DestPath = filename:join([Name, "src", "views", Name ++ "_main.dtl"]), + rebar3_nova_utils:copy_priv_file( + filename:join(["templates", "nova", "controller.dtl"]), + DestPath + ). + +%%====================================================================== +%% maybe_generate_kura +%%====================================================================== + +maybe_generate_kura(Name, #{kura := true}) -> + generate_kura_repo(Name), + generate_docker_compose(Name); +maybe_generate_kura(_Name, _Flags) -> + ok. + +generate_kura_repo(Name) -> + Path = filename:join([Name, "src", Name ++ "_repo.erl"]), + Content = [ + "-module(", + Name, + "_repo).\n\n", + "-behaviour(kura_repo).\n\n", + "-export([otp_app/0]).\n\n", + "otp_app() -> ", + Name, + ".\n" + ], + rebar3_nova_utils:write_file(Path, Content). + +%%====================================================================== +%% maybe_generate_pgo +%%====================================================================== + +maybe_generate_pgo(Name, #{pgo := true}) -> + generate_docker_compose(Name); +maybe_generate_pgo(_Name, _Flags) -> + ok. + +%%====================================================================== +%% docker-compose.yml (shared by kura and pgo) +%%====================================================================== + +generate_docker_compose(Name) -> + Path = filename:join(Name, "docker-compose.yml"), + case filelib:is_regular(Path) of + true -> + ok; + false -> + Content = [ + "services:\n", + " postgres:\n", + " image: postgres:17\n", + " environment:\n", + " POSTGRES_USER: postgres\n", + " POSTGRES_PASSWORD: postgres\n", + " POSTGRES_DB: ", + Name, + "_dev\n", + " ports:\n", + " - \"5432:5432\"\n", + " volumes:\n", + " - pgdata:/var/lib/postgresql/data\n\n", + "volumes:\n", + " pgdata:\n" + ], + rebar3_nova_utils:write_file(Path, Content) + end. + +%%====================================================================== +%% maybe_generate_arizona +%%====================================================================== + +maybe_generate_arizona(Name, #{arizona := true}) -> + generate_home_view(Name), + generate_app_css(Name); +maybe_generate_arizona(_Name, _Flags) -> + ok. + +generate_home_view(Name) -> + Path = filename:join([Name, "src", "views", Name ++ "_home_view.erl"]), + Content = [ + "-module(", + Name, + "_home_view).\n", + "-compile({parse_transform, arizona_parse_transform}).\n", + "-behaviour(arizona_view).\n\n", + "-export([mount/2, render/1]).\n\n", + "mount(_MountArg, _Request) ->\n", + " arizona_view:new(?MODULE, #{message => ~\"Hello from Arizona!\"}, none).\n\n", + "render(Bindings) ->\n", + " arizona_template:from_html(~\"\"\"\n", + "
\n", + "

{arizona_template:get_binding(message, Bindings)}

\n", + "
\n", + " \"\"\").\n" + ], + rebar3_nova_utils:write_file(Path, Content). + +generate_app_css(Name) -> + Path = filename:join([Name, "priv", "assets", "app.css"]), + Content = [ + "* {\n", + " margin: 0;\n", + " padding: 0;\n", + " box-sizing: border-box;\n", + "}\n\n", + "body {\n", + " font-family: system-ui, -apple-system, sans-serif;\n", + " background: #1b2735;\n", + " color: #f5f6fa;\n", + " display: flex;\n", + " justify-content: center;\n", + " align-items: center;\n", + " min-height: 100vh;\n", + "}\n\n", + "#app h1 {\n", + " font-size: 2rem;\n", + " font-weight: 300;\n", + " letter-spacing: 0.1em;\n", + "}\n" + ], + rebar3_nova_utils:write_file(Path, Content). + +%%====================================================================== +%% maybe_generate_ci +%%====================================================================== + +maybe_generate_ci(Name, #{ci := true}) -> + Path = filename:join([Name, ".github", "workflows", "ci.yml"]), + Content = [ + "name: CI\n\n", + "on:\n", + " push:\n", + " branches: [main, master]\n", + " pull_request:\n", + " branches: [main, master]\n\n", + "permissions:\n", + " contents: read\n\n", + "jobs:\n", + " ci:\n", + " uses: Taure/erlang-ci/.github/workflows/erlang-ci.yml@v1\n" + ], + rebar3_nova_utils:write_file(Path, Content); +maybe_generate_ci(_Name, _Flags) -> + ok. + +%%====================================================================== +%% maybe_generate_docker +%%====================================================================== + +maybe_generate_docker(Name, #{docker := true}) -> + Path = filename:join(Name, "Dockerfile"), + Content = [ + "FROM erlang:28 AS builder\n\n", + "WORKDIR /app\n\n", + "COPY rebar.config rebar.lock ./\n", + "RUN rebar3 compile\n\n", + "COPY . .\n", + "RUN rebar3 as prod release\n\n", + "FROM debian:bookworm-slim\n\n", + "RUN apt-get update && apt-get install -y --no-install-recommends \\\n", + " libssl3 libncurses6 libstdc++6 && \\\n", + " rm -rf /var/lib/apt/lists/*\n\n", + "WORKDIR /app\n", + "COPY --from=builder /app/_build/prod/rel/", + Name, + " ./\n\n", + "EXPOSE 8080\n\n", + "CMD [\"bin/", + Name, + "\", \"foreground\"]\n" + ], + rebar3_nova_utils:write_file(Path, Content); +maybe_generate_docker(_Name, _Flags) -> + ok. + +%%====================================================================== +%% Summary +%%====================================================================== + +print_summary(Name, Flags) -> + rebar_api:info("~n==> Project ~s created successfully!~n", [Name]), + rebar_api:info("Next steps:~n", []), + rebar_api:info(" cd ~s~n", [Name]), + NeedsDb = maps:get(kura, Flags) orelse maps:get(pgo, Flags), + case NeedsDb of + true -> + rebar_api:info(" docker compose up -d~n", []); + false -> + ok + end, + case maps:get(kura, Flags) of + true -> + rebar_api:info(" rebar3 kura setup~n", []); + false -> + ok + end, + rebar_api:info(" rebar3 nova serve~n", []). diff --git a/src/rebar3_nova_utils.erl b/src/rebar3_nova_utils.erl index fe15f08..03deff6 100644 --- a/src/rebar3_nova_utils.erl +++ b/src/rebar3_nova_utils.erl @@ -4,7 +4,9 @@ get_app_name/1, get_app_dir/1, ensure_dir/1, + write_file/2, write_file_if_not_exists/2, + copy_priv_file/2, parse_actions/1, load_sys_config/1 ]). @@ -23,19 +25,32 @@ get_app_dir(State) -> ensure_dir(Path) -> filelib:ensure_dir(Path). +-spec write_file(file:filename(), iodata()) -> ok. +write_file(Path, Content) -> + ok = ensure_dir(Path), + ok = file:write_file(Path, Content), + log_info("Created ~s", [Path]), + ok. + -spec write_file_if_not_exists(file:filename(), iodata()) -> ok | skipped. write_file_if_not_exists(Path, Content) -> case filelib:is_regular(Path) of true -> - rebar_api:warn("File already exists, skipping: ~s", [Path]), + log_warn("File already exists, skipping: ~s", [Path]), skipped; false -> - ok = ensure_dir(Path), - ok = file:write_file(Path, Content), - rebar_api:info("Created ~s", [Path]), - ok + write_file(Path, Content) end. +-spec copy_priv_file(file:filename(), file:filename()) -> ok. +copy_priv_file(PrivRelPath, DestPath) -> + PrivDir = code:priv_dir(rebar3_nova), + SrcPath = filename:join(PrivDir, PrivRelPath), + ok = ensure_dir(DestPath), + {ok, _} = file:copy(SrcPath, DestPath), + log_info("Created ~s", [DestPath]), + ok. + -spec parse_actions(string()) -> [atom()]. parse_actions(Str) -> Tokens = string:tokens(Str, ","), @@ -56,3 +71,21 @@ load_sys_config(State) -> rebar_api:warn("Could not read config from ~s", [ConfigPath]), [] end. + +%%---------------------------------------------------------------------- +%% Internal: safe logging (rebar_api may not be available in tests) +%%---------------------------------------------------------------------- + +log_info(Fmt, Args) -> + try + rebar_api:info(Fmt, Args) + catch + error:undef -> io:format(Fmt ++ "~n", Args) + end. + +log_warn(Fmt, Args) -> + try + rebar_api:warn(Fmt, Args) + catch + error:undef -> io:format("Warning: " ++ Fmt ++ "~n", Args) + end. diff --git a/test/rebar3_nova_gen_live_SUITE.erl b/test/rebar3_nova_gen_live_SUITE.erl new file mode 100644 index 0000000..5a49871 --- /dev/null +++ b/test/rebar3_nova_gen_live_SUITE.erl @@ -0,0 +1,328 @@ +-module(rebar3_nova_gen_live_SUITE). + +-include_lib("common_test/include/ct.hrl"). +-include_lib("stdlib/include/assert.hrl"). + +-export([all/0, init_per_testcase/2, end_per_testcase/2]). +-export([ + generates_all_views/1, + generates_schema/1, + generates_migration/1, + generates_test_suite/1, + index_only/1, + no_schema_flag/1, + field_type_mapping/1, + skips_existing_files/1, + views_call_kura_directly/1, + uses_correct_state_api/1 +]). + +all() -> + [ + generates_all_views, + generates_schema, + generates_migration, + generates_test_suite, + index_only, + no_schema_flag, + field_type_mapping, + skips_existing_files, + views_call_kura_directly, + uses_correct_state_api + ]. + +init_per_testcase(_TC, Config) -> + PrivDir = ?config(priv_dir, Config), + OldCwd = file:get_cwd(), + ok = file:set_cwd(PrivDir), + [{old_cwd, OldCwd} | Config]. + +end_per_testcase(_TC, Config) -> + {ok, OldCwd} = ?config(old_cwd, Config), + ok = file:set_cwd(OldCwd), + Config. + +%%====================================================================== +%% Tests +%%====================================================================== + +generates_all_views(_Config) -> + AppName = myapp, + AppDir = "myapp", + ok = file:make_dir(AppDir), + Fields = [{"name", "string"}, {"email", "string"}, {"active", "boolean"}], + Actions = [index, show, new, edit], + + rebar3_nova_gen_live:generate(AppName, AppDir, "users", Fields, Actions), + + assert_file_exists(AppDir, "src/views/myapp_user_index_view.erl"), + assert_file_exists(AppDir, "src/views/myapp_user_show_view.erl"), + assert_file_exists(AppDir, "src/views/myapp_user_new_view.erl"), + assert_file_exists(AppDir, "src/views/myapp_user_edit_view.erl"), + + IndexContent = read_file(AppDir, "src/views/myapp_user_index_view.erl"), + assert_contains(IndexContent, "-module(myapp_user_index_view)"), + assert_contains(IndexContent, "-behaviour(arizona_view)"), + assert_contains(IndexContent, "arizona_parse_transform"), + assert_contains(IndexContent, "kura_repo_worker:all(myapp_repo"), + assert_contains(IndexContent, "Name"), + assert_contains(IndexContent, "Email"), + assert_contains(IndexContent, "Active"), + assert_contains(IndexContent, "handle_event"), + assert_contains(IndexContent, "render_list"), + + ShowContent = read_file(AppDir, "src/views/myapp_user_show_view.erl"), + assert_contains(ShowContent, "-module(myapp_user_show_view)"), + assert_contains(ShowContent, "kura_repo_worker:get(myapp_repo, myapp_user, Id)"), + assert_contains(ShowContent, "
Name
"), + + NewContent = read_file(AppDir, "src/views/myapp_user_new_view.erl"), + assert_contains(NewContent, "-module(myapp_user_new_view)"), + assert_contains(NewContent, "kura_changeset:cast(myapp_user"), + assert_contains(NewContent, "kura_repo_worker:insert(myapp_repo"), + assert_contains(NewContent, " + AppName = myapp, + AppDir = "myapp_schema", + ok = file:make_dir(AppDir), + Fields = [{"name", "string"}, {"age", "integer"}], + + rebar3_nova_gen_live:generate(AppName, AppDir, "users", Fields, [index]), + generate_optional(AppName, AppDir, "users", Fields, #{}), + + assert_file_exists(AppDir, "src/schemas/myapp_user.erl"), + + Content = read_file(AppDir, "src/schemas/myapp_user.erl"), + assert_contains(Content, "-module(myapp_user)"), + assert_contains(Content, "-behaviour(kura_schema)"), + assert_contains(Content, "kura/include/kura.hrl"), + assert_contains(Content, "<<\"users\">>"), + assert_contains(Content, "name = id, type = id, primary_key = true"), + assert_contains(Content, "name = name, type = string"), + assert_contains(Content, "name = age, type = integer"), + assert_contains(Content, "name = inserted_at, type = utc_datetime"), + assert_contains(Content, "name = updated_at, type = utc_datetime"), + + ok. + +generates_migration(_Config) -> + AppName = myapp, + AppDir = "myapp_mig", + ok = file:make_dir(AppDir), + Fields = [{"title", "string"}, {"body", "text"}], + + rebar3_nova_gen_live:generate(AppName, AppDir, "posts", Fields, [index]), + generate_optional(AppName, AppDir, "posts", Fields, #{}), + + %% Find the migration file (has timestamp in name) + MigDir = filename:join([AppDir, "src", "migrations"]), + {ok, Files} = file:list_dir(MigDir), + [MigFile] = [F || F <- Files, string:find(F, "create_posts") =/= nomatch], + + Content = read_file(AppDir, filename:join(["src", "migrations", MigFile])), + assert_contains(Content, "-behaviour(kura_migration)"), + assert_contains(Content, "create_table"), + assert_contains(Content, "<<\"posts\">>"), + assert_contains(Content, "name = title, type = string"), + assert_contains(Content, "name = body, type = text"), + assert_contains(Content, "drop_table"), + + ok. + +generates_test_suite(_Config) -> + AppName = myapp, + AppDir = "myapp_test", + ok = file:make_dir(AppDir), + Fields = [{"name", "string"}], + + rebar3_nova_gen_live:generate(AppName, AppDir, "users", Fields, [index]), + generate_optional(AppName, AppDir, "users", Fields, #{}), + + assert_file_exists(AppDir, "test/myapp_user_live_SUITE.erl"), + + Content = read_file(AppDir, "test/myapp_user_live_SUITE.erl"), + assert_contains(Content, "-module(myapp_user_live_SUITE)"), + assert_contains(Content, "common_test"), + assert_contains(Content, "kura_repo_worker:all(myapp_repo"), + + ok. + +index_only(_Config) -> + AppName = myapp, + AppDir = "myapp_idx", + ok = file:make_dir(AppDir), + Fields = [{"name", "string"}], + + rebar3_nova_gen_live:generate(AppName, AppDir, "users", Fields, [index]), + + assert_file_exists(AppDir, "src/views/myapp_user_index_view.erl"), + assert_file_not_exists(AppDir, "src/views/myapp_user_show_view.erl"), + assert_file_not_exists(AppDir, "src/views/myapp_user_new_view.erl"), + assert_file_not_exists(AppDir, "src/views/myapp_user_edit_view.erl"), + + ok. + +no_schema_flag(_Config) -> + AppName = myapp, + AppDir = "myapp_nos", + ok = file:make_dir(AppDir), + Fields = [{"name", "string"}], + + rebar3_nova_gen_live:generate(AppName, AppDir, "users", Fields, [index]), + generate_optional(AppName, AppDir, "users", Fields, #{no_schema => true}), + + assert_file_exists(AppDir, "src/views/myapp_user_index_view.erl"), + assert_file_not_exists(AppDir, "src/schemas/myapp_user.erl"), + + %% Migration should also be skipped + MigDir = filename:join([AppDir, "src", "migrations"]), + case filelib:is_dir(MigDir) of + false -> + ok; + true -> + {ok, Files} = file:list_dir(MigDir), + ?assertEqual([], Files) + end, + + ok. + +field_type_mapping(_Config) -> + AppName = myapp, + AppDir = "myapp_types", + ok = file:make_dir(AppDir), + Fields = [ + {"name", "string"}, + {"bio", "text"}, + {"age", "integer"}, + {"score", "float"}, + {"active", "boolean"}, + {"birthday", "date"}, + {"created", "utc_datetime"} + ], + + rebar3_nova_gen_live:generate(AppName, AppDir, "users", Fields, [new]), + + Content = read_file(AppDir, "src/views/myapp_user_new_view.erl"), + assert_contains(Content, "type=\"text\" name=\"name\""), + assert_contains(Content, "