import gleam/dict.{type Dict}
import gleam/list
import gleam/result
import gleam/string
import proute/config
import proute/names
import simplifile
pub type MountRoutes {
MountRoutes(mount: config.Mount, routes: List(PageRoute))
}
pub type PageRoute {
PageRoute(
kind: RouteKind,
constructor: String,
path: String,
segments: List(RouteSegment),
params: List(RouteParam),
source_file: String,
page_module: String,
)
}
pub type RouteKind {
Home
NotFound
Static
Dynamic
}
pub type RouteSegment {
StaticSegment(value: String)
DynamicSegment(name: String)
}
pub type RouteParam {
RouteParam(name: String, type_: String)
}
pub type DiscoverError {
PagesDirectoryUnreadable(path: String)
MissingNotFound(mount_name: String, pages: String)
UnsupportedCatchAll(source_file: String)
InvalidPageModulePath(source_file: String)
InvalidPageSegment(source_file: String, segment: String)
InvalidRouteParam(source_file: String, param: String)
DuplicateRouteParam(source_file: String, param: String)
InvalidConstructor(source_file: String, constructor: String)
DuplicatePath(path: String, first_file: String, second_file: String)
DuplicateConstructor(
constructor: String,
first_file: String,
second_file: String,
)
DuplicateHelper(helper: String, first_file: String, second_file: String)
}
pub fn discover_mount(
mount: config.Mount,
) -> Result(MountRoutes, DiscoverError) {
use files <- result.try(walk_pages(mount.pages))
use routes <- result.try(
files
|> list.filter(string.ends_with(_, ".gleam"))
|> list.filter(fn(file) { !is_wire_module(file) })
|> list.try_map(route_from_file(_, mount)),
)
use _ <- result.try(require_not_found(mount, routes))
use _ <- result.try(reject_duplicate_paths(routes, dict.new()))
use _ <- result.try(reject_duplicate_constructors(routes, dict.new()))
use _ <- result.try(reject_duplicate_helpers(routes, dict.new()))
Ok(MountRoutes(mount: mount, routes: sort_routes(routes)))
}
fn is_wire_module(source_file: String) -> Bool {
string.ends_with(source_file, "/wire.gleam")
}
pub fn describe_error(error: DiscoverError) -> String {
case error {
PagesDirectoryUnreadable(path) ->
"Could not read pages directory " <> string.inspect(path) <> "."
MissingNotFound(mount_name, pages) ->
"Mount "
<> string.inspect(mount_name)
<> " at "
<> string.inspect(pages)
<> " is missing not_found_.gleam. Add not_found_.gleam so proute can generate the NotFound route."
UnsupportedCatchAll(source_file) ->
"Unsupported catch-all page "
<> string.inspect(source_file)
<> ": all_.gleam is reserved but not generated yet."
InvalidPageModulePath(source_file) ->
"Invalid page module path "
<> string.inspect(source_file)
<> ": page files must live under a src directory so generated imports can be module-relative."
InvalidPageSegment(source_file, segment) ->
"Invalid page path segment "
<> string.inspect(segment)
<> " in "
<> string.inspect(source_file)
<> ": use valid Gleam module path segments."
InvalidRouteParam(source_file, param) ->
"Invalid route parameter "
<> string.inspect(param)
<> " in "
<> string.inspect(source_file)
<> ": dynamic route parameters must be valid Gleam labels."
DuplicateRouteParam(source_file, param) ->
"Duplicate route parameter "
<> string.inspect(param)
<> " in "
<> string.inspect(source_file)
<> "."
InvalidConstructor(source_file, constructor) ->
"Invalid route constructor "
<> string.inspect(constructor)
<> " from "
<> string.inspect(source_file)
<> "."
DuplicatePath(path, first_file, second_file) ->
"Duplicate route path "
<> string.inspect(path)
<> " from "
<> string.inspect(first_file)
<> " and "
<> string.inspect(second_file)
<> "."
DuplicateConstructor(constructor, first_file, second_file) ->
"Duplicate route constructor "
<> string.inspect(constructor)
<> " from "
<> string.inspect(first_file)
<> " and "
<> string.inspect(second_file)
<> "."
DuplicateHelper(helper, first_file, second_file) ->
"Duplicate route helper "
<> string.inspect(helper)
<> " from "
<> string.inspect(first_file)
<> " and "
<> string.inspect(second_file)
<> "."
}
}
pub fn page_module_path(source_file: String) -> String {
names.module_path_from_file(source_file)
|> result.unwrap(
source_file
|> drop_suffix(".gleam")
|> string.replace("\\", "/")
|> drop_prefix("src/"),
)
}
fn walk_pages(root: String) -> Result(List(String), DiscoverError) {
case simplifile.read_directory(root) {
Error(_) -> Error(PagesDirectoryUnreadable(root))
Ok(entries) -> walk_entries(root, entries |> list.sort(string.compare), [])
}
}
fn walk_entries(
directory: String,
entries: List(String),
files: List(String),
) -> Result(List(String), DiscoverError) {
case entries {
[] -> Ok(list.reverse(files))
[entry, ..rest] -> {
let path = join_path([directory, entry])
case simplifile.is_directory(path) {
Ok(True) -> {
use nested <- result.try(walk_pages(path))
walk_entries(
directory,
rest,
list.append(list.reverse(nested), files),
)
}
_ -> walk_entries(directory, rest, [path, ..files])
}
}
}
}
fn route_from_file(
source_file: String,
mount: config.Mount,
) -> Result(PageRoute, DiscoverError) {
let relative = relative_source_path(source_file, mount.pages)
let without_extension = drop_suffix(relative, ".gleam")
let raw_segments = string.split(without_extension, "/")
case list.last(raw_segments) {
Ok("all_") -> Error(UnsupportedCatchAll(source_file))
Ok("not_found_") -> {
use _ <- result.try(validate_raw_segments(source_file, raw_segments))
use page_module <- result.try(
names.module_path_from_file(source_file)
|> result.map_error(fn(_) { InvalidPageModulePath(source_file) }),
)
Ok(not_found_route(source_file, mount, page_module))
}
Ok(_) -> {
use _ <- result.try(validate_raw_segments(source_file, raw_segments))
use page_module <- result.try(
names.module_path_from_file(source_file)
|> result.map_error(fn(_) { InvalidPageModulePath(source_file) }),
)
let route = regular_route(source_file, mount, raw_segments, page_module)
use _ <- result.try(validate_route(source_file, route))
Ok(route)
}
Error(_) -> Error(PagesDirectoryUnreadable(mount.pages))
}
}
fn regular_route(
source_file: String,
mount: config.Mount,
raw_segments: List(String),
page_module: String,
) -> PageRoute {
let route_segments = route_segments(raw_segments)
let constructor = mount.constructor_prefix <> constructor_name(raw_segments)
PageRoute(
kind: route_kind(route_segments),
constructor: constructor,
path: route_path(mount.route_root, route_segments),
segments: route_segments,
params: dynamic_params(route_segments),
source_file: source_file,
page_module: page_module,
)
}
fn not_found_route(
source_file: String,
mount: config.Mount,
page_module: String,
) -> PageRoute {
let segments = [StaticSegment("not_found")]
PageRoute(
kind: NotFound,
constructor: "NotFound",
path: route_path(mount.route_root, segments),
segments: segments,
params: [],
source_file: source_file,
page_module: page_module,
)
}
fn route_segments(raw_segments: List(String)) -> List(RouteSegment) {
raw_segments
|> list.index_map(fn(segment, index) {
case segment == "home_" && index == list.length(raw_segments) - 1 {
True -> []
False -> [route_segment(segment)]
}
})
|> list.flatten
}
fn route_segment(segment: String) -> RouteSegment {
case string.ends_with(segment, "_") {
True -> DynamicSegment(drop_suffix(segment, "_"))
False -> StaticSegment(segment)
}
}
fn route_kind(segments: List(RouteSegment)) -> RouteKind {
case segments {
[] -> Home
_ ->
case list.any(segments, is_dynamic_segment) {
True -> Dynamic
False -> Static
}
}
}
fn is_dynamic_segment(segment: RouteSegment) -> Bool {
case segment {
DynamicSegment(_) -> True
StaticSegment(_) -> False
}
}
fn dynamic_params(segments: List(RouteSegment)) -> List(RouteParam) {
segments
|> list.filter_map(fn(segment) {
case segment {
DynamicSegment(name) -> Ok(RouteParam(name: name, type_: "String"))
StaticSegment(_) -> Error(Nil)
}
})
}
fn constructor_name(raw_segments: List(String)) -> String {
raw_segments
|> list.filter(fn(segment) { segment != "home_" })
|> list.map(constructor_word)
|> string.join("_")
|> names.pascal_case
|> default_constructor("Home")
}
fn default_constructor(value: String, default: String) -> String {
case value {
"" -> default
_ -> value
}
}
fn constructor_word(segment: String) -> String {
drop_suffix(segment, "_")
}
fn route_path(route_root: String, segments: List(RouteSegment)) -> String {
let suffix =
segments
|> list.map(route_segment_path)
|> string.join("/")
case route_root, suffix {
"/", "" -> "/"
"/", suffix -> "/" <> suffix
route_root, "" -> route_root
route_root, suffix -> route_root <> "/" <> suffix
}
}
fn route_segment_path(segment: RouteSegment) -> String {
case segment {
StaticSegment(value) -> value
DynamicSegment(name) -> ":" <> name
}
}
fn reject_duplicate_paths(
routes: List(PageRoute),
seen: Dict(String, PageRoute),
) -> Result(Nil, DiscoverError) {
case routes {
[] -> Ok(Nil)
[route, ..rest] -> {
let key = route_pattern_path(route)
case dict.get(seen, key) {
Ok(first) ->
Error(DuplicatePath(
path: key,
first_file: first.source_file,
second_file: route.source_file,
))
Error(_) -> reject_duplicate_paths(rest, dict.insert(seen, key, route))
}
}
}
}
fn require_not_found(
mount: config.Mount,
routes: List(PageRoute),
) -> Result(Nil, DiscoverError) {
case list.any(routes, fn(route) { route.kind == NotFound }) {
True -> Ok(Nil)
False -> Error(MissingNotFound(mount.name, mount.pages))
}
}
fn reject_duplicate_constructors(
routes: List(PageRoute),
seen: Dict(String, PageRoute),
) -> Result(Nil, DiscoverError) {
case routes {
[] -> Ok(Nil)
[route, ..rest] ->
case dict.get(seen, route.constructor) {
Ok(first) ->
Error(DuplicateConstructor(
constructor: route.constructor,
first_file: first.source_file,
second_file: route.source_file,
))
Error(_) ->
reject_duplicate_constructors(
rest,
dict.insert(seen, route.constructor, route),
)
}
}
}
fn reject_duplicate_helpers(
routes: List(PageRoute),
seen: Dict(String, PageRoute),
) -> Result(Nil, DiscoverError) {
case routes {
[] -> Ok(Nil)
[route, ..rest] -> {
let helper = names.helper_name(route.constructor)
case names.is_valid_label(helper), dict.get(seen, helper) {
False, _ ->
Error(InvalidConstructor(route.source_file, route.constructor))
True, Ok(first) ->
Error(DuplicateHelper(
helper: helper,
first_file: first.source_file,
second_file: route.source_file,
))
True, Error(_) ->
reject_duplicate_helpers(rest, dict.insert(seen, helper, route))
}
}
}
}
fn sort_routes(routes: List(PageRoute)) -> List(PageRoute) {
routes
|> list.sort(fn(a, b) { string.compare(route_sort_key(a), route_sort_key(b)) })
}
fn route_sort_key(route: PageRoute) -> String {
case route.kind {
Home -> "0:" <> route.path
Static | Dynamic ->
"1:"
<> {
route.segments
|> list.map(route_segment_sort_key)
|> fn(keys) { list.append(keys, ["0"]) }
|> string.join("/")
}
<> ":"
<> route.path
NotFound -> "2:" <> route.path
}
}
fn route_segment_sort_key(segment: RouteSegment) -> String {
case segment {
StaticSegment(value) -> "1:" <> value
DynamicSegment(_) -> "2:"
}
}
fn relative_source_path(source_file: String, root: String) -> String {
let prefix = root <> "/"
case string.starts_with(source_file, prefix) {
True -> string.drop_start(source_file, string.length(prefix))
False -> source_file
}
}
fn drop_suffix(value: String, suffix: String) -> String {
case string.ends_with(value, suffix) {
True -> string.drop_end(value, string.length(suffix))
False -> value
}
}
fn drop_prefix(value: String, prefix: String) -> String {
case string.starts_with(value, prefix) {
True -> string.drop_start(value, string.length(prefix))
False -> value
}
}
fn join_path(parts: List(String)) -> String {
parts
|> list.filter(fn(part) { part != "" })
|> string.join("/")
}
fn validate_raw_segments(
source_file: String,
raw_segments: List(String),
) -> Result(Nil, DiscoverError) {
raw_segments
|> list.try_each(fn(segment) {
case names.is_valid_module_segment(segment) {
True -> Ok(Nil)
False -> Error(InvalidPageSegment(source_file, segment))
}
})
}
fn validate_route(
source_file: String,
route: PageRoute,
) -> Result(Nil, DiscoverError) {
use _ <- result.try(validate_constructor(source_file, route.constructor))
validate_params(source_file, route.params, dict.new())
}
fn validate_constructor(
source_file: String,
constructor: String,
) -> Result(Nil, DiscoverError) {
case names.is_valid_type_name(constructor) {
True -> Ok(Nil)
False -> Error(InvalidConstructor(source_file, constructor))
}
}
fn validate_params(
source_file: String,
params: List(RouteParam),
seen: Dict(String, Nil),
) -> Result(Nil, DiscoverError) {
case params {
[] -> Ok(Nil)
[param, ..rest] ->
case names.is_valid_label(param.name), dict.has_key(seen, param.name) {
False, _ -> Error(InvalidRouteParam(source_file, param.name))
True, True -> Error(DuplicateRouteParam(source_file, param.name))
True, False ->
validate_params(source_file, rest, dict.insert(seen, param.name, Nil))
}
}
}
fn route_pattern_path(route: PageRoute) -> String {
route.path
|> string.split("/")
|> list.map(fn(segment) {
case string.starts_with(segment, ":") {
True -> ":_"
False -> segment
}
})
|> string.join("/")
}