Skip to main content

src/gleamdoc.gleam

//// Command-line entrypoint for Gleamdoc.

import argv
import gleam/int
import gleam/io
import gleam/json
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/package_interface
import gleam/result
import gleam/string
import gleamdoc/index
import gleamdoc/interface
import gleamdoc/output
import glint
import simplifile
import snag

const version = "0.2.0"

type Format {
  Text
  Json
}

type Scope {
  Scope(package: Option(String), dependencies: Bool)
}

type Action {
  Lookup(query: String, format: Format, scope: Scope)
  Search(
    query: String,
    format: Format,
    scope: Scope,
    limit: Int,
    kind: String,
    target: String,
  )
  Packages(format: Format, dependencies: Bool)
  Index(format: Format, scope: Scope)
}

/// Runs the Gleamdoc command-line application.
pub fn main() -> Nil {
  case argv.load().arguments {
    ["--version"] | ["-v"] -> io.println("gleamdoc " <> version)
    arguments ->
      glint.run_and_handle(from: cli(), for: arguments, with: execute)
  }
}

fn cli() -> glint.Glint(Action) {
  glint.new()
  |> glint.with_name("gleamdoc")
  |> glint.global_help(
    "Sparse, terminal-first documentation lookup for Gleam packages.",
  )
  |> glint.add(at: [], do: lookup_command())
  |> glint.add(at: ["search"], do: search_command())
  |> glint.add(at: ["packages"], do: packages_command())
  |> glint.add(at: ["index"], do: index_command())
}

fn lookup_command() -> glint.Command(Action) {
  use <- glint.command_help(
    "Look up an exact public module, function, constant, type, variant, or alias.",
  )
  use query <- glint.named_arg("query")
  use json <- glint.flag(json_flag())
  use package <- glint.flag(package_flag())
  use no_deps <- glint.flag(no_deps_flag())
  use <- glint.unnamed_args(glint.EqArgs(0))
  use named, _, flags <- glint.command()
  let assert Ok(json) = json(flags)
  let assert Ok(package) = package(flags)
  let assert Ok(no_deps) = no_deps(flags)

  Lookup(
    query(named),
    output_format(json),
    Scope(package: optional_package(package), dependencies: !no_deps),
  )
}

fn search_command() -> glint.Command(Action) {
  use <- glint.command_help(
    "Search symbol names, modules, packages, signatures, and documentation.",
  )
  use json <- glint.flag(json_flag())
  use limit <- glint.flag(limit_flag())
  use kind <- glint.flag(kind_flag())
  use target <- glint.flag(target_flag())
  use package <- glint.flag(package_flag())
  use no_deps <- glint.flag(no_deps_flag())
  use <- glint.unnamed_args(glint.MinArgs(1))
  use _, terms, flags <- glint.command()
  let assert Ok(json) = json(flags)
  let assert Ok(limit) = limit(flags)
  let assert Ok(kind) = kind(flags)
  let assert Ok(target) = target(flags)
  let assert Ok(package) = package(flags)
  let assert Ok(no_deps) = no_deps(flags)

  Search(
    string.join(terms, with: " "),
    output_format(json),
    Scope(package: optional_package(package), dependencies: !no_deps),
    limit,
    kind,
    target,
  )
}

fn packages_command() -> glint.Command(Action) {
  use <- glint.command_help("List indexed packages and module counts.")
  use json <- glint.flag(json_flag())
  use no_deps <- glint.flag(no_deps_flag())
  use <- glint.unnamed_args(glint.EqArgs(0))
  use _, _, flags <- glint.command()
  let assert Ok(json) = json(flags)
  let assert Ok(no_deps) = no_deps(flags)

  Packages(output_format(json), dependencies: !no_deps)
}

fn index_command() -> glint.Command(Action) {
  use <- glint.command_help(
    "Generate package-interface caches used by lookup and search commands.",
  )
  use json <- glint.flag(json_flag())
  use package <- glint.flag(package_flag())
  use no_deps <- glint.flag(no_deps_flag())
  use <- glint.unnamed_args(glint.EqArgs(0))
  use _, _, flags <- glint.command()
  let assert Ok(json) = json(flags)
  let assert Ok(package) = package(flags)
  let assert Ok(no_deps) = no_deps(flags)

  Index(
    output_format(json),
    Scope(package: optional_package(package), dependencies: !no_deps),
  )
}

fn json_flag() -> glint.Flag(Bool) {
  glint.bool_flag("json")
  |> glint.flag_default(False)
  |> glint.flag_help("Emit machine-readable JSON.")
}

fn package_flag() -> glint.Flag(String) {
  glint.string_flag("package")
  |> glint.flag_default("")
  |> glint.flag_help("Restrict lookup or search to one package.")
}

fn no_deps_flag() -> glint.Flag(Bool) {
  glint.bool_flag("no-deps")
  |> glint.flag_default(False)
  |> glint.flag_help("Only inspect the current project.")
}

fn limit_flag() -> glint.Flag(Int) {
  glint.int_flag("limit")
  |> glint.flag_default(50)
  |> glint.flag_constraint(fn(limit) {
    case limit > 0 {
      True -> Ok(limit)
      False -> snag.error("--limit must be greater than zero")
    }
  })
  |> glint.flag_help("Maximum number of search results.")
}

fn kind_flag() -> glint.Flag(String) {
  glint.string_flag("kind")
  |> glint.flag_default("")
  |> glint.flag_constraint(fn(kind) {
    case
      list.contains(
        ["", "module", "fn", "const", "type", "variant", "alias"],
        kind,
      )
    {
      True -> Ok(kind)
      False ->
        snag.error("--kind must be module, fn, const, type, variant, or alias")
    }
  })
  |> glint.flag_help("Restrict search to one symbol kind.")
}

fn target_flag() -> glint.Flag(String) {
  glint.string_flag("target")
  |> glint.flag_default("")
  |> glint.flag_constraint(fn(target) {
    case list.contains(["", "erlang", "javascript"], target) {
      True -> Ok(target)
      False -> snag.error("--target must be erlang or javascript")
    }
  })
  |> glint.flag_help("Restrict search to functions and constants for a target.")
}

fn output_format(json: Bool) -> Format {
  case json {
    True -> Json
    False -> Text
  }
}

fn optional_package(package: String) -> Option(String) {
  case string.trim(package) {
    "" -> None
    package -> Some(package)
  }
}

fn execute(action: Action) -> Nil {
  let #(scope, refresh) = load_options(action)
  case load_packages(scope, refresh) {
    Error(message) -> fail(action_format(action), "error: " <> message)
    Ok(all_packages) -> execute_with_packages(action, all_packages)
  }
}

fn load_options(action: Action) -> #(Scope, Bool) {
  case action {
    Lookup(query, _, scope) -> #(lookup_load_scope(scope, query), False)
    Search(_, _, scope, _, _, _) -> #(scope, False)
    Packages(_, dependencies) -> #(
      Scope(package: None, dependencies: dependencies),
      False,
    )
    Index(_, scope) -> #(scope, True)
  }
}

fn lookup_load_scope(scope: Scope, query: String) -> Scope {
  let Scope(package:, dependencies:) = scope
  case package, string.split_once(query, on: ":") {
    None, Ok(#(package, _)) -> Scope(package: Some(package), dependencies:)
    _, _ -> scope
  }
}

fn action_format(action: Action) -> Format {
  case action {
    Lookup(_, format, _) -> format
    Search(_, format, _, _, _, _) -> format
    Packages(format, _) -> format
    Index(format, _) -> format
  }
}

fn execute_with_packages(
  action: Action,
  all_packages: List(package_interface.Package),
) -> Nil {
  case action {
    Lookup(query, format, scope) -> {
      case scope_error(all_packages, scope) {
        Some(message) -> fail(format, message)
        None -> {
          let packages = select_packages(all_packages, scope)
          case interface.lookup_packages(packages, query) {
            [] -> fail(format, "No public symbol found for `" <> query <> "`.")
            entries ->
              print(
                format,
                interface.render_all(entries),
                interface.entries_json(entries),
                output.Documentation,
              )
          }
        }
      }
    }
    Search(query, format, scope, limit, kind, target) -> {
      case scope_error(all_packages, scope) {
        Some(message) -> fail(format, message)
        None -> {
          let packages = select_packages(all_packages, scope)
          case
            interface.search_packages_filtered(
              packages,
              query,
              limit,
              kind: kind,
              target: target,
            )
          {
            [] -> fail(format, "No public symbols matched `" <> query <> "`.")
            entries ->
              print(
                format,
                interface.render_search_results(entries),
                interface.entries_json(entries),
                output.SearchResults,
              )
          }
        }
      }
    }
    Packages(format, dependencies) -> {
      let packages = case dependencies {
        True -> all_packages
        False -> list.take(all_packages, 1)
      }
      print(
        format,
        interface.render_packages(packages),
        interface.packages_json(packages),
        output.Packages,
      )
    }
    Index(format, _) -> {
      let packages = all_packages
      print(
        format,
        "Indexed "
          <> int.to_string(list.length(packages))
          <> " package interfaces.\n\n"
          <> interface.render_packages(packages),
        interface.packages_json(packages),
        output.IndexResult,
      )
    }
  }
}

fn scope_error(
  packages: List(package_interface.Package),
  scope: Scope,
) -> Option(String) {
  let Scope(package:, ..) = scope
  case package {
    None -> None
    Some(wanted) ->
      case select_packages(packages, scope) {
        [] -> Some("Package `" <> wanted <> "` is not indexed in this scope.")
        _ -> None
      }
  }
}

fn select_packages(
  packages: List(package_interface.Package),
  scope: Scope,
) -> List(package_interface.Package) {
  let Scope(package:, dependencies:) = scope
  let packages = case dependencies {
    True -> packages
    False -> list.take(packages, 1)
  }
  case package {
    None -> packages
    Some(wanted) ->
      packages
      |> list.filter(fn(package) {
        let package_interface.Package(name:, ..) = package
        name == wanted
      })
  }
}

fn print(
  format: Format,
  text: String,
  json_value: json.Json,
  style: output.Style,
) -> Nil {
  case format {
    Text ->
      case stdout_supports_color() {
        True -> text |> output.text(style) |> io.println
        False -> io.println(text)
      }
    Json -> json_value |> json.to_string |> io.println
  }
}

fn fail(format: Format, message: String) -> Nil {
  case format {
    Text ->
      case stderr_supports_color() {
        True -> message |> output.error |> io.println_error
        False -> io.println_error(message)
      }
    Json ->
      json.object([
        #("schema_version", json.int(1)),
        #("error", json.string(message)),
        #("results", json.preprocessed_array([])),
      ])
      |> json.to_string
      |> io.println
  }
  exit_status(1)
}

fn load_packages(
  scope: Scope,
  refresh: Bool,
) -> Result(List(package_interface.Package), String) {
  let Scope(package:, dependencies:) = scope
  use paths <- result.try(index.package_interface_paths(
    dependencies,
    option.unwrap(package, ""),
    refresh,
  ))
  load_package_files(paths, [])
}

fn load_package_files(
  paths: List(String),
  packages: List(package_interface.Package),
) -> Result(List(package_interface.Package), String) {
  case paths {
    [] -> Ok(list.reverse(packages))
    [path, ..rest] -> {
      use package <- result.try(load_package_file(path))
      load_package_files(rest, [package, ..packages])
    }
  }
}

fn load_package_file(
  path: String,
) -> Result(package_interface.Package, String) {
  use source <- result.try(
    simplifile.read(path)
    |> result.map_error(fn(_) { "Could not read " <> path <> "." }),
  )
  json.parse(source, using: package_interface.decoder())
  |> result.map_error(fn(_) {
    "The generated package interface at " <> path <> " was invalid."
  })
}

@external(erlang, "gleamdoc_ffi", "exit_status")
fn exit_status(status: Int) -> Nil

@external(erlang, "gleamdoc_ffi", "stdout_supports_color")
fn stdout_supports_color() -> Bool

@external(erlang, "gleamdoc_ffi", "stderr_supports_color")
fn stderr_supports_color() -> Bool