Skip to main content

src/proute/discover.gleam

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("/")
}