From 8e60e12877f67c336cf17d848a2edfe0774a9a4e Mon Sep 17 00:00:00 2001 From: Robert Attard Date: Mon, 17 Jun 2024 16:40:59 -0400 Subject: [PATCH] move help functionality into glint/internal/help --- src/glint.gleam | 354 ++++++---------------------------- src/glint/internal/help.gleam | 294 ++++++++++++++++++++++++++++ 2 files changed, 349 insertions(+), 299 deletions(-) create mode 100644 src/glint/internal/help.gleam diff --git a/src/glint.gleam b/src/glint.gleam index 09b841a..d5e19cd 100644 --- a/src/glint.gleam +++ b/src/glint.gleam @@ -1,5 +1,4 @@ import gleam -import gleam/bool import gleam/dict import gleam/float import gleam/int @@ -8,10 +7,9 @@ import gleam/list import gleam/option.{type Option, None, Some} import gleam/result import gleam/string -import gleam_community/ansi import gleam_community/colour.{type Colour} import glint/constraint -import glint/internal/utils +import glint/internal/help import snag.{type Snag} // --- CONFIGURATION --- @@ -434,7 +432,7 @@ pub fn group_flag( @internal pub fn execute(glint: Glint(a), args: List(String)) -> Result(Out(a), String) { // create help flag to check for - let help_flag = prefix <> help_flag.meta.name + let help_flag = flag_prefix <> help.help_flag.meta.name // check if help flag is present let #(help, args) = case list.pop(args, fn(s) { s == help_flag }) { @@ -443,7 +441,7 @@ pub fn execute(glint: Glint(a), args: List(String)) -> Result(Out(a), String) { } // split flags out from the args list - let #(flags, args) = list.partition(args, string.starts_with(_, prefix)) + let #(flags, args) = list.partition(args, string.starts_with(_, flag_prefix)) // search for command and execute do_execute(glint.cmd, glint.config, args, flags, help, []) @@ -634,22 +632,12 @@ pub fn default_pretty_help() -> PrettyHelp { ) } -// --- constants for setting up sections of the help message --- - -const flags_heading = "FLAGS:" - -const subcommands_heading = "SUBCOMMANDS:" - -const usage_heading = "USAGE:" - /// Helper for filtering out empty strings /// fn is_not_empty(s: String) -> Bool { s != "" } -const help_flag = FlagHelp(Metadata("help", "Print help information"), "") - // -- HELP: FUNCTIONS -- /// generate the help text for a command @@ -660,81 +648,59 @@ fn cmd_help(path: List(String), cmd: CommandNode(a), config: Config) -> String { |> list.reverse |> string.join(" ") |> build_app_help(config, _, cmd) - |> app_help_to_string -} - -/// Style heading text with the provided rgb colouring -/// this is only intended for use within glint itself. -/// -fn heading_style(heading: String, colour: Colour) -> String { - heading - |> ansi.bold - |> ansi.underline - |> ansi.italic - |> ansi.hex(colour.to_rgb_hex(colour)) - |> ansi.reset -} - -// ----- HELP ----- - -// --- HELP: TYPES --- -// - -type AppHelp { - AppHelp(config: Config, command: CommandHelp) -} - -/// Common metadata for commands and flags -/// -type Metadata { - Metadata(name: String, description: String) -} - -/// Help type for flag metadata -/// -type FlagHelp { - FlagHelp(meta: Metadata, type_: String) -} - -/// Help type for command metadata -type CommandHelp { - CommandHelp( - // Every command has a name and description - meta: Metadata, - // A command can have >= 0 flags associated with it - flags: List(FlagHelp), - // A command can have >= 0 subcommands associated with it - subcommands: List(Metadata), - // A command can have a set number of unnamed arguments - unnamed_args: Option(ArgsCount), - // A command can specify named arguments - named_args: List(String), - ) + |> help.app_help_to_string } // -- HELP - FUNCTIONS - BUILDERS -- -fn build_app_help(config: Config, command_name: String, node: CommandNode(_)) { - AppHelp(config: config, command: build_command_help(command_name, node)) +fn build_app_help( + config: Config, + command_name: String, + node: CommandNode(_), +) -> help.App { + help.App( + config: help.Config( + name: config.name, + usage_colour: option.map(config.pretty_help, fn(p) { p.usage }), + flags_colour: option.map(config.pretty_help, fn(p) { p.flags }), + subcommands_colour: option.map(config.pretty_help, fn(p) { p.subcommands }), + as_module: config.as_module, + description: config.description, + indent_width: config.indent_width, + max_output_width: config.max_output_width, + min_first_column_width: config.min_first_column_width, + column_gap: config.column_gap, + flag_prefix: flag_prefix, + ), + command: build_command_help(command_name, node), + ) } /// build the help representation for a subtree of commands /// -fn build_command_help(name: String, node: CommandNode(_)) -> CommandHelp { - let #(description, flags, unnamed_args, named_args) = case node.contents { - None -> #(node.description, [], None, []) - Some(cmd) -> #( - node.description, - build_flags_help(merge(node.group_flags, cmd.flags)), - cmd.unnamed_args, - cmd.named_args, - ) - } +fn build_command_help(name: String, node: CommandNode(_)) -> help.Command { + let #(description, flags, unnamed_args, named_args) = + node.contents + |> option.map(fn(cmd) { + #( + node.description, + build_flags_help(merge(node.group_flags, cmd.flags)), + cmd.unnamed_args, + cmd.named_args, + ) + }) + |> option.unwrap(#(node.description, [], None, [])) - CommandHelp( - meta: Metadata(name: name, description: description), + help.Command( + meta: help.Metadata(name: name, description: description), flags: flags, subcommands: build_subcommands_help(node.subcommands), - unnamed_args: unnamed_args, + unnamed_args: { + use args <- option.map(unnamed_args) + case args { + EqArgs(n) -> help.EqArgs(n) + MinArgs(n) -> help.MinArgs(n) + } + }, named_args: named_args, ) } @@ -755,11 +721,11 @@ fn flag_type_info(flag: FlagEntry) { /// build the help representation for a list of flags /// -fn build_flags_help(flags: Flags) -> List(FlagHelp) { +fn build_flags_help(flags: Flags) -> List(help.Flag) { use acc, name, flag <- fold(flags, []) [ - FlagHelp( - meta: Metadata(name: name, description: flag.description), + help.Flag( + meta: help.Metadata(name: name, description: flag.description), type_: flag_type_info(flag), ), ..acc @@ -770,229 +736,19 @@ fn build_flags_help(flags: Flags) -> List(FlagHelp) { /// fn build_subcommands_help( subcommands: dict.Dict(String, CommandNode(_)), -) -> List(Metadata) { +) -> List(help.Metadata) { use acc, name, node <- dict.fold(subcommands, []) - [Metadata(name: name, description: node.description), ..acc] -} - -// -- HELP - FUNCTIONS - STRINGIFIERS -- -fn app_help_to_string(help: AppHelp) -> String { - let command = case help.command.meta.name { - "" -> "" - s -> "Command: " <> s - } - - let command_description = - help.command.meta.description - |> utils.wordwrap(help.config.max_output_width) - |> string.join("\n") - - [ - option.unwrap(help.config.description, ""), - command, - command_description, - command_help_to_usage_string(help.command, help.config), - flags_help_to_string(help.command.flags, help.config), - subcommands_help_to_string(help.command.subcommands, help.config), - ] - |> list.filter(is_not_empty) - |> string.join("\n\n") -} - -// -- HELP - FUNCTIONS - STRINGIFIERS - USAGE -- - -/// convert a List(FlagHelp) to a list of strings for use in usage text -/// -fn flags_help_to_usage_strings(help: List(FlagHelp)) -> List(String) { - help - |> list.map(flag_help_to_string) - |> list.sort(string.compare) -} - -/// generate the usage help text for the flags of a command -/// -fn flags_help_to_usage_string(help: List(FlagHelp)) -> String { - use <- bool.guard(help == [], "") - let content = - help - |> flags_help_to_usage_strings - |> string.join(" ") - - "[ " <> content <> " ]" -} - -/// convert an ArgsCount to a string for usage text -/// -fn args_count_to_usage_string(count: ArgsCount) -> String { - case count { - EqArgs(0) -> "" - EqArgs(1) -> "[ 1 argument ]" - EqArgs(n) -> "[ " <> int.to_string(n) <> " arguments ]" - MinArgs(n) -> "[ " <> int.to_string(n) <> " or more arguments ]" - } -} - -/// convert a CommandHelp to a styled usage block -/// -fn command_help_to_usage_string(help: CommandHelp, config: Config) -> String { - let app_name = case config.name { - Some(name) if config.as_module -> "gleam run -m " <> name - Some(name) -> name - None -> "gleam run" - } - - let flags = flags_help_to_usage_string(help.flags) - let subcommands = case - list.map(help.subcommands, fn(sc) { sc.name }) - |> list.sort(string.compare) - |> string.join(" | ") - { - "" -> "" - subcommands -> "( " <> subcommands <> " )" - } - - let named_args = - help.named_args - |> list.map(fn(s) { "<" <> s <> ">" }) - |> string.join(" ") - - let unnamed_args = - option.map(help.unnamed_args, args_count_to_usage_string) - |> option.unwrap("[ ARGS ]") - - // The max width of the usage accounts for the constant indent - let max_usage_width = config.max_output_width - config.indent_width - - let content = - [app_name, help.meta.name, subcommands, named_args, unnamed_args, flags] - |> list.filter(is_not_empty) - |> string.join(" ") - |> utils.wordwrap(max_usage_width) - |> string.join("\n" <> string.repeat(" ", config.indent_width * 2)) - - case config.pretty_help { - None -> usage_heading - Some(pretty) -> heading_style(usage_heading, pretty.usage) - } - <> "\n" - <> string.repeat(" ", config.indent_width) - <> content -} - -// -- HELP - FUNCTIONS - STRINGIFIERS - FLAGS -- - -/// generate the usage help string for a command -/// -fn flags_help_to_string(help: List(FlagHelp), config: Config) -> String { - use <- bool.guard(help == [], "") - - let longest_flag_length = - help - |> list.map(flag_help_to_string) - |> utils.max_string_length - |> int.max(config.min_first_column_width) - - let heading = case config.pretty_help { - None -> flags_heading - Some(pretty) -> heading_style(flags_heading, pretty.flags) - } - - let f = fn(help) { - help_content_to_wrapped_string( - flag_help_to_string(help), - help.meta.description, - longest_flag_length, - config, - ) - } - - let content = - utils.to_spaced_indented_string([help_flag, ..help], config.indent_width, f) - - heading <> content -} - -/// generate the help text for a flag without a description -/// -fn flag_help_to_string(help: FlagHelp) -> String { - prefix - <> help.meta.name - <> case help.type_ { - "" -> "" - _ -> "=<" <> help.type_ <> ">" - } -} - -fn help_content_to_wrapped_string( - left: String, - right: String, - left_length: Int, - config: Config, -) -> #(String, Bool) { - let left_length = left_length + config.column_gap - - let left_formatted = string.pad_right(left, left_length, " ") - - let lines = - config.max_output_width - |> int.subtract(left_length + config.indent_width) - |> int.max(config.min_first_column_width) - |> utils.wordwrap(right, _) - - let right_formatted = - string.join( - lines, - "\n" <> string.repeat(" ", config.indent_width + left_length), - ) - - let wrapped = case lines { - [] | [_] -> False - _ -> True - } - - #(left_formatted <> right_formatted, wrapped) -} - -// -- HELP - FUNCTIONS - STRINGIFIERS - SUBCOMMANDS -- - -/// generate the styled help text for a list of subcommands -/// -fn subcommands_help_to_string(help: List(Metadata), config: Config) -> String { - use <- bool.guard(help == [], "") - - let longest_subcommand_length = - help - |> list.map(fn(h) { h.name }) - |> utils.max_string_length - |> int.max(config.min_first_column_width) - - let heading = case config.pretty_help { - None -> subcommands_heading - Some(pretty) -> heading_style(subcommands_heading, pretty.subcommands) - } - - let f = fn(help: Metadata) { - help_content_to_wrapped_string( - help.name, - help.description, - longest_subcommand_length, - config, - ) - } - - let content = utils.to_spaced_indented_string(help, config.indent_width, f) - - heading <> content + [help.Metadata(name: name, description: node.description), ..acc] } // ----- FLAGS ----- /// FlagEntry inputs must start with this prefix /// -const prefix = "--" +const flag_prefix = "--" /// The separation character for flag names and their values -const delimiter = "=" +const flag_delimiter = "=" /// Supported flag types. /// @@ -1265,9 +1021,9 @@ fn new_flags() -> Flags { /// This function is only intended to be used from glint.execute_root /// fn update_flags(in flags: Flags, with flag_input: String) -> snag.Result(Flags) { - let flag_input = string.drop_left(flag_input, string.length(prefix)) + let flag_input = string.drop_left(flag_input, string.length(flag_prefix)) - case string.split_once(flag_input, delimiter) { + case string.split_once(flag_input, flag_delimiter) { Ok(data) -> update_flag_value(flags, data) Error(_) -> attempt_toggle_flag(flags, flag_input) } diff --git a/src/glint/internal/help.gleam b/src/glint/internal/help.gleam new file mode 100644 index 0000000..1a22d44 --- /dev/null +++ b/src/glint/internal/help.gleam @@ -0,0 +1,294 @@ +import gleam/bool +import gleam/int +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/string +import gleam_community/ansi +import gleam_community/colour.{type Colour} +import glint/internal/utils + +/// Style heading text with the provided rgb colouring +/// this is only intended for use within glint itself. +/// +fn heading_style(heading: String, colour: Colour) -> String { + heading + |> ansi.bold + |> ansi.underline + |> ansi.italic + |> ansi.hex(colour.to_rgb_hex(colour)) +} + +// --- HELP: CONSTANTS --- +// +pub const help_flag = Flag(Metadata("help", "Print help information"), "") + +const flags_heading = "FLAGS:" + +const subcommands_heading = "SUBCOMMANDS:" + +const usage_heading = "USAGE:" + +// --- HELP: TYPES --- + +pub type ArgsCount { + MinArgs(Int) + EqArgs(Int) +} + +pub type Config { + Config( + name: Option(String), + usage_colour: Option(Colour), + flags_colour: Option(Colour), + subcommands_colour: Option(Colour), + as_module: Bool, + description: Option(String), + indent_width: Int, + max_output_width: Int, + min_first_column_width: Int, + column_gap: Int, + flag_prefix: String, + ) +} + +pub type App { + App(config: Config, command: Command) +} + +/// Common metadata for commands and flags +/// +pub type Metadata { + Metadata(name: String, description: String) +} + +/// Help type for flag metadata +/// +pub type Flag { + Flag(meta: Metadata, type_: String) +} + +/// Help type for command metadata +pub type Command { + Command( + // Every command has a name and description + meta: Metadata, + // A command can have >= 0 flags associated with it + flags: List(Flag), + // A command can have >= 0 subcommands associated with it + subcommands: List(Metadata), + // A command can have a set number of unnamed arguments + unnamed_args: Option(ArgsCount), + // A command can specify named arguments + named_args: List(String), + ) +} + +// -- HELP - FUNCTIONS - STRINGIFIERS -- +pub fn app_help_to_string(help: App) -> String { + let command = case help.command.meta.name { + "" -> "" + s -> "Command: " <> s + } + + let command_description = + help.command.meta.description + |> utils.wordwrap(help.config.max_output_width) + |> string.join("\n") + + [ + option.unwrap(help.config.description, ""), + command, + command_description, + command_help_to_usage_string(help.command, help.config), + flags_help_to_string(help.command.flags, help.config), + subcommands_help_to_string(help.command.subcommands, help.config), + ] + |> list.filter(fn(s) { s != "" }) + |> string.join("\n\n") +} + +// -- HELP - FUNCTIONS - STRINGIFIERS - USAGE -- + +/// convert a List(Flag) to a list of strings for use in usage text +/// +fn flags_help_to_usage_strings(help: List(Flag), config: Config) -> List(String) { + help + |> list.map(flag_help_to_string(_, config)) + |> list.sort(string.compare) +} + +/// generate the usage help text for the flags of a command +/// +fn flags_help_to_usage_string(config: Config, help: List(Flag)) -> String { + use <- bool.guard(help == [], "") + let content = + help + |> flags_help_to_usage_strings(config) + |> string.join(" ") + + "[ " <> content <> " ]" +} + +/// convert an ArgsCount to a string for usage text +/// +fn args_count_to_usage_string(count: ArgsCount) -> String { + case count { + EqArgs(0) -> "" + EqArgs(1) -> "[ 1 argument ]" + EqArgs(n) -> "[ " <> int.to_string(n) <> " arguments ]" + MinArgs(n) -> "[ " <> int.to_string(n) <> " or more arguments ]" + } +} + +/// convert a Command to a styled usage block +/// +fn command_help_to_usage_string(help: Command, config: Config) -> String { + let app_name = case config.name { + Some(name) if config.as_module -> "gleam run -m " <> name + Some(name) -> name + None -> "gleam run" + } + + let flags = flags_help_to_usage_string(config, help.flags) + let subcommands = case + list.map(help.subcommands, fn(sc) { sc.name }) + |> list.sort(string.compare) + |> string.join(" | ") + { + "" -> "" + subcommands -> "( " <> subcommands <> " )" + } + + let named_args = + help.named_args + |> list.map(fn(s) { "<" <> s <> ">" }) + |> string.join(" ") + + let unnamed_args = + option.map(help.unnamed_args, args_count_to_usage_string) + |> option.unwrap("[ ARGS ]") + + // The max width of the usage accounts for the constant indent + let max_usage_width = config.max_output_width - config.indent_width + + let content = + [app_name, help.meta.name, subcommands, named_args, unnamed_args, flags] + |> list.filter(fn(s) { s != "" }) + |> string.join(" ") + |> utils.wordwrap(max_usage_width) + |> string.join("\n" <> string.repeat(" ", config.indent_width * 2)) + + case config.usage_colour { + None -> usage_heading + Some(pretty) -> heading_style(usage_heading, pretty) + } + <> "\n" + <> string.repeat(" ", config.indent_width) + <> content +} + +// -- HELP - FUNCTIONS - STRINGIFIERS - FLAGS -- + +/// generate the usage help string for a command +/// +fn flags_help_to_string(help: List(Flag), config: Config) -> String { + use <- bool.guard(help == [], "") + + let longest_flag_length = + help + |> list.map(flag_help_to_string(_, config)) + |> utils.max_string_length + |> int.max(config.min_first_column_width) + + let heading = case config.flags_colour { + None -> flags_heading + Some(pretty) -> heading_style(flags_heading, pretty) + } + + let f = fn(help) { + help_content_to_wrapped_string( + flag_help_to_string(help, config), + help.meta.description, + longest_flag_length, + config, + ) + } + + let content = + utils.to_spaced_indented_string([help_flag, ..help], config.indent_width, f) + + heading <> content +} + +/// generate the help text for a flag without a description +/// +fn flag_help_to_string(help: Flag, config: Config) -> String { + config.flag_prefix + <> help.meta.name + <> case help.type_ { + "" -> "" + _ -> "=<" <> help.type_ <> ">" + } +} + +fn help_content_to_wrapped_string( + left: String, + right: String, + left_length: Int, + config: Config, +) -> #(String, Bool) { + let left_length = left_length + config.column_gap + + let left_formatted = string.pad_right(left, left_length, " ") + + let lines = + config.max_output_width + |> int.subtract(left_length + config.indent_width) + |> int.max(config.min_first_column_width) + |> utils.wordwrap(right, _) + + let right_formatted = + string.join( + lines, + "\n" <> string.repeat(" ", config.indent_width + left_length), + ) + + let wrapped = case lines { + [] | [_] -> False + _ -> True + } + + #(left_formatted <> right_formatted, wrapped) +} + +// -- HELP - FUNCTIONS - STRINGIFIERS - SUBCOMMANDS -- + +/// generate the styled help text for a list of subcommands +/// +fn subcommands_help_to_string(help: List(Metadata), config: Config) -> String { + use <- bool.guard(help == [], "") + + let longest_subcommand_length = + help + |> list.map(fn(h) { h.name }) + |> utils.max_string_length + |> int.max(config.min_first_column_width) + + let heading = case config.subcommands_colour { + None -> subcommands_heading + Some(pretty) -> heading_style(subcommands_heading, pretty) + } + + let f = fn(help: Metadata) { + help_content_to_wrapped_string( + help.name, + help.description, + longest_subcommand_length, + config, + ) + } + + let content = utils.to_spaced_indented_string(help, config.indent_width, f) + + heading <> content +}