Current section
Files
Jump to
Current section
Files
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") ->
route = build_route(full_path, base_path)
[route | acc]
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)
# Parse each segment into route parts
{pattern, param_names} = parse_segments(segments)
# Build module name
module_parts = segments_to_module_parts(segments)
%{
file_path: file_path,
pattern: pattern,
param_names: param_names,
module_parts: module_parts,
has_catchall: Enum.any?(pattern, &match?({:catchall, _}, &1)),
segment_count: length(pattern)
}
end
defp parse_segments(segments) do
{pattern, param_names} =
Enum.reduce(segments, {[], []}, fn segment, {pattern, params} ->
case parse_segment(segment) do
{:dynamic, name} ->
{pattern ++ [{:dynamic, name}], params ++ [name]}
{:catchall, name} ->
{pattern ++ [{:catchall, name}], params ++ [name]}
{:static, value} ->
{pattern ++ [{:static, value}], params}
end
end)
{pattern, param_names}
end
defp parse_segment(segment) do
cond do
# 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, "]") ->
# [...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([{: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)
# 1. Static routes first
# 2. Routes with fewer dynamic segments
# 3. Routes without catch-all before routes with catch-all
# 4. Longer routes before shorter routes (more specific)
defp route_priority(route) do
static_count = Enum.count(route.pattern, &match?({:static, _}, &1))
dynamic_count = Enum.count(route.pattern, &match?({:dynamic, _}, &1))
catchall_penalty = if route.has_catchall, do: 1000, else: 0
# Lower score = higher priority
{-static_count, dynamic_count, catchall_penalty, -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
end