Current section

Files

Jump to
nex_core lib nex route_discovery.ex
Raw

lib/nex/route_discovery.ex

defmodule Nex.RouteDiscovery do
@moduledoc """
Discovers and matches dynamic routes from file system structure.
Supports:
- `[param]` - Single dynamic parameter (e.g., `users/[id].ex` matches `/users/123`)
- `[...param]` - Catch-all parameter (e.g., `docs/[...path].ex` matches `/docs/a/b/c`)
- Mixed routes (e.g., `files/[category]/[...path].ex`)
- Nested dynamic routes (e.g., `users/[id]/profile.ex`)
"""
@doc """
Discovers all routes from the given source directory.
Returns a list of route definitions.
"""
def discover_routes(src_path, type \\ :pages) do
base_path = Path.join(src_path, to_string(type))
if File.dir?(base_path) do
discover_routes_recursive(base_path, base_path, [])
|> Enum.sort_by(&route_priority/1)
else
[]
end
end
defp discover_routes_recursive(current_path, base_path, acc) do
case File.ls(current_path) do
{:ok, entries} ->
Enum.reduce(entries, acc, fn entry, acc ->
full_path = Path.join(current_path, entry)
cond do
File.dir?(full_path) ->
discover_routes_recursive(full_path, base_path, acc)
String.ends_with?(entry, ".ex") ->
if reserved_file?(entry) do
acc
else
route = build_route(full_path, base_path)
[route | acc]
end
true ->
acc
end
end)
{:error, _} ->
acc
end
end
defp build_route(file_path, base_path) do
# Get relative path from base
relative = Path.relative_to(file_path, base_path)
# Remove .ex extension
relative = String.replace_suffix(relative, ".ex", "")
# Split into segments
segments = Path.split(relative)
# Special case: "index" at the end means it matches the parent path
# e.g., "index" -> [], "users/index" -> ["users"]
segments =
case List.last(segments) do
"index" -> List.delete_at(segments, -1)
_ -> segments
end
# Parse each segment into route parts
{pattern, param_names} = parse_segments(segments)
# Build module name (always include Index for index files)
module_parts = segments_to_module_parts(Path.split(relative))
%{
file_path: file_path,
pattern: pattern,
param_names: param_names,
module_parts: module_parts,
has_catchall:
Enum.any?(pattern, fn
{:catchall, _} -> true
{:optional_catchall, _} -> true
_ -> false
end),
segment_count: length(pattern)
}
end
defp parse_segments(segments) do
{pattern_rev, param_names_rev} =
Enum.reduce(segments, {[], []}, fn segment, {pattern, params} ->
case parse_segment(segment) do
{:dynamic, name} ->
{[{:dynamic, name} | pattern], [name | params]}
{:catchall, name} ->
{[{:catchall, name} | pattern], [name | params]}
{:optional_catchall, name} ->
{[{:optional_catchall, name} | pattern], [name | params]}
{:static, value} ->
{[{:static, value} | pattern], params}
end
end)
{Enum.reverse(pattern_rev), Enum.reverse(param_names_rev)}
end
defp parse_segment(segment) do
cond do
# Optional catch-all: [[...param]]
String.starts_with?(segment, "[[...") and String.ends_with?(segment, "]]") ->
name = segment |> String.slice(5..-3//1)
{:optional_catchall, name}
# Catch-all: [...param]
String.starts_with?(segment, "[...") and String.ends_with?(segment, "]") ->
name = segment |> String.slice(4..-2//1)
{:catchall, name}
# Dynamic: [param]
String.starts_with?(segment, "[") and String.ends_with?(segment, "]") ->
name = segment |> String.slice(1..-2//1)
{:dynamic, name}
# Static
true ->
{:static, segment}
end
end
defp segments_to_module_parts(segments) do
Enum.map(segments, fn segment ->
cond do
String.starts_with?(segment, "[[...") and String.ends_with?(segment, "]]") ->
name = segment |> String.slice(5..-3//1)
Macro.camelize(name)
String.starts_with?(segment, "[...") and String.ends_with?(segment, "]") ->
# [...path] -> Path
name = segment |> String.slice(4..-2//1)
Macro.camelize(name)
String.starts_with?(segment, "[") and String.ends_with?(segment, "]") ->
# [id] -> Id
name = segment |> String.slice(1..-2//1)
Macro.camelize(name)
true ->
Macro.camelize(segment)
end
end)
end
@doc """
Matches a URL path against discovered routes.
Returns {:ok, module_name, params} or :error
"""
def match_route(routes, url_path, app_module, prefix) do
# Try each route in priority order
Enum.find_value(routes, :error, fn route ->
case match_pattern(route.pattern, url_path, %{}) do
{:ok, params} ->
module_name = build_module_name(app_module, prefix, route.module_parts)
{:ok, module_name, params}
:error ->
nil
end
end)
end
defp match_pattern([], [], params), do: {:ok, params}
defp match_pattern([], _remaining, _params), do: :error
defp match_pattern([{:catchall, name} | _], remaining, params) do
# Catch-all consumes all remaining segments
{:ok, Map.put(params, name, remaining)}
end
defp match_pattern([{:optional_catchall, name} | _], remaining, params) do
# Optional catch-all consumes remaining segments (may be empty)
{:ok, Map.put(params, name, remaining)}
end
defp match_pattern([{:dynamic, name} | rest_pattern], [segment | rest_path], params) do
# Dynamic matches any single segment
match_pattern(rest_pattern, rest_path, Map.put(params, name, segment))
end
defp match_pattern([{:static, value} | rest_pattern], [segment | rest_path], params) do
# Static must match exactly
if value == segment do
match_pattern(rest_pattern, rest_path, params)
else
:error
end
end
defp match_pattern(_pattern, [], _params), do: :error
defp build_module_name(app_module, prefix, module_parts) do
[app_module, prefix | module_parts] |> Enum.join(".")
end
# Route priority for sorting (lower = higher priority)
# Segment precedence: static > dynamic > catch-all > optional catch-all
defp route_priority(route) do
specificity =
Enum.map(route.pattern, fn
{:static, _} -> 0
{:dynamic, _} -> 1
{:catchall, _} -> 2
{:optional_catchall, _} -> 3
end)
{specificity, -route.segment_count}
end
@doc """
Gets or initializes the route cache for an application.
Routes are cached in ETS for performance.
"""
def get_routes(src_path, type) do
cache_key = {src_path, type}
case :ets.whereis(:nex_route_cache) do
:undefined ->
:ets.new(:nex_route_cache, [:named_table, :public, :set])
routes = discover_routes(src_path, type)
:ets.insert(:nex_route_cache, {cache_key, routes})
routes
_table ->
case :ets.lookup(:nex_route_cache, cache_key) do
[{^cache_key, routes}] ->
routes
[] ->
routes = discover_routes(src_path, type)
:ets.insert(:nex_route_cache, {cache_key, routes})
routes
end
end
end
@doc """
Clears the route cache. Called when files change during development.
"""
def clear_cache do
case :ets.whereis(:nex_route_cache) do
:undefined -> :ok
_table -> :ets.delete_all_objects(:nex_route_cache)
end
:ok
end
# =============================================================================
# Unified Route Resolution API
# =============================================================================
@doc """
Unified route resolution entry point.
## Examples
resolve(:pages, ["users", "123"])
# => {:ok, MyApp.Pages.Users.Id, %{"id" => "123"}}
resolve(:api, ["users"])
# => {:ok, MyApp.Api.Users, %{}}
resolve(:action, ["todos", "create_todo"])
# => {:ok, MyApp.Pages.Todos, "create_todo", %{}}
"""
def resolve(type, path)
def resolve(type, path) when type in [:pages, :api] do
app_module = get_app_module()
src_path = get_src_path()
prefix = if type == :pages, do: "Pages", else: "Api"
routes = get_routes(src_path, type)
case match_route(routes, path, app_module, prefix) do
{:ok, module_name, params} ->
case safe_to_existing_module(module_name) do
{:ok, module} -> {:ok, module, params}
:error -> :error
end
:error ->
:error
end
end
def resolve(:action, path, referer_path \\ []) do
app_module = get_app_module()
src_path = get_src_path()
case path do
[] ->
# POST / → Index.index
case safe_to_existing_module("#{app_module}.Pages.Index") do
{:ok, module} -> {:ok, module, "index", %{}}
:error -> :error
end
[action] ->
# POST /action_name → resolve against the current page when available.
# Root-page actions still resolve to Pages.Index.
case safe_to_existing_atom(action) do
{:ok, action_atom} ->
resolve_single_segment_action(app_module, action, action_atom, referer_path)
:error ->
:error
end
segments ->
# POST /todos/123/delete → resolve module path, last segment is action
{module_path, [action]} = Enum.split(segments, -1)
routes = get_routes(src_path, :pages)
case match_route(routes, module_path, app_module, "Pages") do
{:ok, module_name, params} ->
case safe_to_existing_module(module_name) do
{:ok, module} -> {:ok, module, action, params}
:error -> :error
end
:error ->
:error
end
end
end
defp reserved_file?(filename) do
String.starts_with?(filename, "_") or filename in ["404.ex", "500.ex"]
end
defp resolve_single_segment_action(app_module, action, action_atom, referer_path) do
case referer_path do
[] ->
resolve_index_action(app_module, action, action_atom)
_path ->
resolve_action_in_current_page(app_module, action, action_atom, referer_path)
end
end
defp resolve_index_action(app_module, action, action_atom) do
case safe_to_existing_module("#{app_module}.Pages.Index") do
{:ok, module} ->
if function_exported?(module, action_atom, 1) do
{:ok, module, action, %{}}
else
:error
end
:error ->
:error
end
end
defp resolve_action_in_current_page(app_module, action, action_atom, referer_path) do
case get_current_page_module(app_module, referer_path) do
{:ok, module} ->
if function_exported?(module, action_atom, 1) do
{:ok, module, action, %{}}
else
:error
end
:error ->
:error
end
end
# Config helpers
defp get_app_module, do: Nex.Config.app_module()
defp get_src_path, do: Nex.Config.src_path()
# Safe atom/module conversion - now delegated to Nex.Utils
defp safe_to_existing_atom(string), do: Nex.Utils.safe_to_existing_atom(string)
defp safe_to_existing_module(module_name), do: Nex.Utils.safe_to_existing_module(module_name)
# Get current page module from referer path
defp get_current_page_module(app_module, referer_path) do
case referer_path do
[] ->
# No referer or root path, use Index
safe_to_existing_module("#{app_module}.Pages.Index")
path ->
# Try to resolve the referer path to a page module
# e.g., ["requests"] -> Pages.Requests
routes = get_routes(get_src_path(), :pages)
case match_route(routes, path, app_module, "Pages") do
{:ok, module_name, _params} ->
safe_to_existing_module(module_name)
:error ->
# Fallback: try to construct module name directly from path
# e.g., ["requests"] -> "Pages.Requests"
module_name =
path
|> Enum.map(&Macro.camelize/1)
|> then(&[app_module, "Pages" | &1])
|> Enum.join(".")
safe_to_existing_module(module_name)
end
end
end
end