Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
352 changes: 352 additions & 0 deletions src/stdlib/option_parser.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,352 @@
require "option_parser"

class CompletionOptionParser < OptionParser
# Stores metadata about a registered flag for completion generation.
record FlagInfo,
short_flag : String?,
long_flag : String?,
value_type : FlagValue,
description : String,
is_subcommand : Bool = false

@flag_info = [] of FlagInfo

def on(short_flag : String, long_flag : String, description : String, & : String ->)
super

short_flag, short_value_type = parse_flag_definition(short_flag)
long_flag, long_value_type = parse_flag_definition(long_flag)

# Pick the "most required" argument type between both flags
if short_value_type.required? || long_value_type.required?
value_type = FlagValue::Required
elsif short_value_type.optional? || long_value_type.optional?
value_type = FlagValue::Optional
else
value_type = FlagValue::None
end

@flag_info << FlagInfo.new(
short_flag.empty? ? nil : short_flag,
long_flag,
value_type,
description
)
end

def on(short_flag : String, long_flag : String, description : String, &block : String ->)
super

short_flag, short_value_type = parse_flag_definition(short_flag)
long_flag, long_value_type = parse_flag_definition(long_flag)

# Pick the "most required" argument type between both flags
if short_value_type.required? || long_value_type.required?
value_type = FlagValue::Required
elsif short_value_type.optional? || long_value_type.optional?
value_type = FlagValue::Optional
else
value_type = FlagValue::None
end

@flag_info << FlagInfo.new(
short_flag.empty? ? nil : short_flag,
long_flag,
value_type,
description
)
end

# Generates a bash completion script for this parser.
#
# The *program_name* is used as the command name to register completions for.
#
# Example:
#
# ```
# require "option_parser"
#
# parser = OptionParser.new do |opts|
# opts.banner = "Usage: myapp [options]"
# opts.on("-v", "--verbose", "Enable verbose output") { }
# opts.on("-o FILE", "--output=FILE", "Output file") { }
# end
#
# puts parser.bash_completion("myapp")
# ```
#
# To install the completion script, save the output to a file and source it
# in your shell configuration (e.g., `~/.bashrc`), or place it in
# `/etc/bash_completion.d/` or `~/.local/share/bash-completion/completions/`.
def bash_completion(program_name : String) : String
String.build do |io|
bash_completion(program_name, io)
end
end

# :ditto:
def bash_completion(program_name : String, io : IO) : Nil
func_name = "_#{program_name.gsub(/[^a-zA-Z0-9_]/, "_")}"

io << "# Bash completion for " << program_name << "\n"
io << "# Generated by Crystal's OptionParser\n\n"

io << func_name << "()\n"
io << "{\n"
io << " local cur prev opts\n"
io << " COMPREPLY=()\n"
io << " cur=\"${COMP_WORDS[COMP_CWORD]}\"\n"
io << " prev=\"${COMP_WORDS[COMP_CWORD-1]}\"\n\n"

# Build list of all options
options = [] of String
subcommands = [] of String

@flag_info.each do |info|
if info.is_subcommand
subcommands << (info.short_flag || info.long_flag).not_nil!
else
if short = info.short_flag
options << short
end
if long = info.long_flag
options << long
end
end
end

if subcommands.empty?
# Simple case: just flags, no subcommands
io << " opts=\"" << options.join(" ") << "\"\n\n"
io << " if [[ ${cur} == -* ]] ; then\n"
io << " COMPREPLY=( $(compgen -W \"${opts}\" -- \"${cur}\") )\n"
io << " return 0\n"
io << " fi\n"
else
# Has subcommands
io << " opts=\"" << options.join(" ") << "\"\n"
io << " subcommands=\"" << subcommands.join(" ") << "\"\n\n"
io << " if [[ ${COMP_CWORD} -eq 1 ]] ; then\n"
io << " COMPREPLY=( $(compgen -W \"${subcommands} ${opts}\" -- \"${cur}\") )\n"
io << " return 0\n"
io << " fi\n\n"
io << " if [[ ${cur} == -* ]] ; then\n"
io << " COMPREPLY=( $(compgen -W \"${opts}\" -- \"${cur}\") )\n"
io << " return 0\n"
io << " fi\n"
end

io << "}\n\n"
io << "complete -F " << func_name << " " << program_name << "\n"
end

# Generates a zsh completion script for this parser.
#
# The *program_name* is used as the command name to register completions for.
#
# Example:
#
# ```
# require "option_parser"
#
# parser = OptionParser.new do |opts|
# opts.banner = "Usage: myapp [options]"
# opts.on("-v", "--verbose", "Enable verbose output") { }
# opts.on("-o FILE", "--output=FILE", "Output file") { }
# end
#
# puts parser.zsh_completion("myapp")
# ```
#
# To install the completion script, save the output to a file named `_myapp`
# in a directory in your `$fpath` (e.g., `~/.zsh/completions/`), or source it
# directly in your `~/.zshrc`.
def zsh_completion(program_name : String) : String
String.build do |io|
zsh_completion(program_name, io)
end
end

# :ditto:
def zsh_completion(program_name : String, io : IO) : Nil
io << "#compdef " << program_name << "\n\n"
io << "# Zsh completion for " << program_name << "\n"
io << "# Generated by Crystal's OptionParser\n\n"

func_name = "_#{program_name.gsub(/[^a-zA-Z0-9_]/, "_")}"

io << func_name << "() {\n"
io << " local -a args\n"
io << " args=(\n"

subcommands = [] of FlagInfo

@flag_info.each do |info|
if info.is_subcommand
subcommands << info
next
end

# Escape special characters in description for zsh
desc = info.description.gsub("'", "'\\''").gsub("[", "\\[").gsub("]", "\\]")

short = info.short_flag
long = info.long_flag

# Build the exclusion group and argument spec
if short && long
# Both short and long flags
exclusion = "'(#{short} #{long})'"
io << " " << exclusion << "'{" << short << "," << long << "}'[" << desc << "]"
io << "':'" if info.value_type.required? || info.value_type.optional?
elsif long
# Only long flag
io << " '(" << long << ")'" << long << "[" << desc << "]"
io << "':'" if info.value_type.required? || info.value_type.optional?
elsif short
# Only short flag
io << " '(" << short << ")'" << short << "[" << desc << "]"
io << "':'" if info.value_type.required? || info.value_type.optional?
end
io << "\n"
end

unless subcommands.empty?
io << " '1:command:->command'\n"
io << " '*::arg:->args'\n"
end

io << " )\n\n"
io << " _arguments -s -S $args\n"

unless subcommands.empty?
io << "\n case $state in\n"
io << " (command)\n"
io << " local -a commands\n"
io << " commands=(\n"
subcommands.each do |info|
name = (info.short_flag || info.long_flag).not_nil!
desc = info.description.gsub("'", "'\\''").gsub(":", "\\:")
io << " '" << name << ":" << desc << "'\n"
end
io << " )\n"
io << " _describe -t commands 'command' commands\n"
io << " ;;\n"
io << " esac\n"
end

io << "}\n\n"
io << func_name << "\n"
end

# Generates a fish completion script for this parser.
#
# The *program_name* is used as the command name to register completions for.
#
# Example:
#
# ```
# require "option_parser"
#
# parser = OptionParser.new do |opts|
# opts.banner = "Usage: myapp [options]"
# opts.on("-v", "--verbose", "Enable verbose output") { }
# opts.on("-o FILE", "--output=FILE", "Output file") { }
# end
#
# puts parser.fish_completion("myapp")
# ```
#
# To install the completion script, save the output to a file named `myapp.fish`
# in `~/.config/fish/completions/` or `/usr/share/fish/completions/`.
def fish_completion(program_name : String) : String
String.build do |io|
fish_completion(program_name, io)
end
end

# :ditto:
def fish_completion(program_name : String, io : IO) : Nil
io << "# Fish completion for " << program_name << "\n"
io << "# Generated by Crystal's OptionParser\n\n"

subcommands = [] of FlagInfo
flags = [] of FlagInfo

@flag_info.each do |info|
if info.is_subcommand
subcommands << info
else
flags << info
end
end

if subcommands.empty?
# Simple case: just flags, no subcommands
flags.each do |info|
write_fish_flag_completion(io, program_name, info, nil)
end
else
# Has subcommands - define them and add conditions
subcommand_names = subcommands.map { |s| (s.short_flag || s.long_flag).not_nil! }
io << "set -l " << program_name << "_commands " << subcommand_names.join(" ") << "\n\n"

# Add subcommand completions
subcommands.each do |info|
name = (info.short_flag || info.long_flag).not_nil!
desc = escape_fish_description(info.description)
io << "complete -c " << program_name
io << " -n \"not __fish_seen_subcommand_from $" << program_name << "_commands\""
io << " -a " << quote_fish_arg(name)
io << " -d " << quote_fish_arg(desc)
io << "\n"
end

io << "\n"

# Add global flag completions (available regardless of subcommand)
flags.each do |info|
write_fish_flag_completion(io, program_name, info, nil)
end
end
end

private def write_fish_flag_completion(io : IO, program_name : String, info : FlagInfo, subcommand : String?) : Nil
io << "complete -c " << program_name

if subcommand
io << " -n \"__fish_seen_subcommand_from " << subcommand << "\""
end

short = info.short_flag
long = info.long_flag

if short
# Remove the leading dash for fish -s option
io << " -s " << short[1..]
end

if long
# Remove the leading dashes for fish -l option
io << " -l " << long[2..]
end

desc = escape_fish_description(info.description)
io << " -d " << quote_fish_arg(desc)

# Add -r flag if argument is required
if info.value_type.required?
io << " -r"
end

io << "\n"
end

private def escape_fish_description(desc : String) : String
desc.gsub("\\", "\\\\").gsub("'", "\\'")
end

private def quote_fish_arg(arg : String) : String
"'" + arg + "'"
end
end
Loading