//// Command-line entrypoint for Gleamdoc. import argv import gleam/dynamic/decode 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.3.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: interface.SymbolKind, target: interface.Target, ) 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, parse_kind(kind), parse_target(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 parse_kind(kind: String) -> interface.SymbolKind { case kind { "" -> interface.All "module" -> interface.Module "fn" -> interface.Fn "const" -> interface.Const "type" -> interface.Type "variant" -> interface.Variant "alias" -> interface.Alias _ -> interface.All } } fn parse_target(target: String) -> interface.Target { case target { "" -> interface.AllTargets "erlang" -> interface.Erlang "javascript" -> interface.Javascript _ -> interface.AllTargets } } 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(error) { "Invalid package interface JSON at " <> path <> ": " <> json_error_message(error) }) } fn json_error_message(error: json.DecodeError) -> String { case error { json.UnableToDecode(errors) -> decode_errors_message(errors) json.UnexpectedEndOfInput -> "unexpected end of JSON input" json.UnexpectedByte(byte) -> "unexpected byte: " <> byte json.UnexpectedSequence(seq) -> "unexpected sequence: " <> seq } } fn decode_errors_message(errors: List(decode.DecodeError)) -> String { errors |> list.map(fn(error) { let decode.DecodeError(expected:, found:, path:) = error "expected " <> expected <> " but got " <> found <> " at " <> decode_path(path) }) |> string.join(with: "; ") } fn decode_path(path: List(String)) -> String { "[" <> string.join(path, with: ".") <> "]" } @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