///|
/// Error classes produced while parsing CLI flags.
///
/// The CLI distinguishes between usage mistakes made by the caller and support
/// errors where the syntax is valid but the requested feature/value is not
/// recognized. The distinction maps directly to different exit codes.
priv enum CliErrorKind {
Usage
Support
}
///|
/// Rich parse error used internally by the CLI layer before it is converted
/// into a `CliOutcome`.
priv struct CliError {
kind : CliErrorKind
message : String
}
///|
/// Three-state color selection used by `--color`.
///
/// `Auto` defers the decision to environment inspection so the parser can keep
/// rendering policy separate from string formatting logic.
priv enum CliColorOption {
Auto
Always
Never
}
///|
/// Fully normalized command-line configuration consumed by `@driver.run_cli`.
///
/// All stringly CLI arguments are converted here into typed checker and
/// formatter options so the driver only executes already-validated requests.
priv struct CliConfig {
spec : @checker.CheckSpec
format : @formatter.OutputFormat
format_options : @formatter.FormatOptions
files : Array[String]
action : @driver.CliAction
}
///|
/// Parses raw CLI arguments into a typed configuration using core argparse.
fn parse_cli_args(args : Array[String]) -> Result[CliConfig, CliError] {
let options = @driver.cli_value_options()
let (argv, action) = normalize_cli_args(args, options)
let matches = @driver.cli_command().parse(argv~, env={}) catch {
error => return Err(argparse_error(error))
}
let mut spec = @checker.CheckSpec::default()
let mut format = @formatter.OutputFormat::default()
let mut color = CliColorOption::Auto
let mut wiki_link_count = 3
for name, _ in matches.flags {
match name {
"check-sourced" => spec = spec.with_check_sourced(true)
"external-sources" => spec = spec.with_external_sources(true)
"norc" => spec = spec.with_ignore_rc(true)
_ => ()
}
}
for name, values in matches.values {
for index, raw in values {
match name {
"format" =>
if index == 0 {
if @formatter.parse_output_format(raw) is Some(parsed) {
format = parsed
} else {
return Err(unknown_format_error(raw))
}
}
"shell" =>
if parse_shell(raw) is Some(parsed) {
spec = spec.with_shell_override(Some(parsed))
} else {
return Err(support_error("Unknown shell: " + raw))
}
"severity" =>
if parse_severity(raw) is Some(parsed) {
spec = spec.with_min_severity(parsed)
} else {
return Err(unknown_severity_error())
}
"include" =>
match parse_code_list(raw) {
Ok(parsed) =>
spec = spec.with_include_codes(
append_ints(spec.include_codes, parsed),
)
Err(error) => return Err(error)
}
"exclude" =>
match parse_code_list(raw) {
Ok(parsed) =>
spec = spec.with_exclude_codes(
append_ints(spec.exclude_codes, parsed),
)
Err(error) => return Err(error)
}
"enable" =>
spec = spec.with_optional_checks(
append_optional_checks(spec.optional_checks, split_csv(raw)),
)
"source-path" =>
for entry in split_source_paths(raw) {
spec = spec.add_source_path(entry)
}
"rcfile" => spec = spec.with_config_path_override(Some(raw))
"color" =>
match parse_color_option(raw) {
Ok(parsed) => color = parsed
Err(error) => return Err(error)
}
"wiki-link-count" =>
match parse_nonnegative_int(raw) {
Ok(parsed) => wiki_link_count = parsed
Err(error) => return Err(error)
}
"extended-analysis" =>
if parse_bool_value(raw) is Some(parsed) {
spec = spec.with_extended_analysis(parsed)
} else {
return Err(
usage_error("Invalid boolean, expected true/false: " + raw),
)
}
_ => ()
}
}
}
let files = matches.values.get("files").unwrap_or([])
if files.is_empty() && action is Run {
files.push("-")
}
Ok({
spec,
format,
format_options: @formatter.FormatOptions::default()
.with_wiki_link_count(wiki_link_count)
.with_enable_color(resolve_color(color)),
files,
action,
})
}
///|
/// Argparse options require a value, while ShellCheck's color flag accepts an
/// attached value or defaults to "always" without consuming the next token.
/// Leave option values and everything following `--` untouched. Record the
/// first meta action here because Matches does not expose occurrence order.
fn normalize_cli_args(
args : Array[String],
options : Array[(String, Char?, String)],
) -> (Array[String], @driver.CliAction) {
let normalized : Array[String] = []
let mut action = @driver.CliAction::Run
for index = 0; index < args.length(); {
let arg = args[index]
if arg == "--" {
for rest in args[index:] {
normalized.push(rest)
}
break
}
let mut token = arg
let mut takes_next = false
if arg.has_prefix("--") {
let (flag, inline) = split_flag_value(arg)
match flag {
"--color" if inline is None => token = "--color=always"
"--check-sourced" | "--external-sources" | "--norc" =>
// The existing CLI ignores inline values on switches.
token = flag
"--list-optional" => {
token = flag
action = choose_action(action, ListOptional)
}
"--version" => {
token = flag
action = choose_action(action, ShowVersion)
}
"--help" => {
token = flag
action = choose_action(action, ShowHelp)
}
_ =>
takes_next = inline is None &&
options.any(option => flag == "--" + option.0)
}
} else if arg.has_prefix("-") && arg != "-" {
let chars = arg.iter()
ignore(chars.next())
while chars.next() is Some(short) {
if short == 'V' {
action = choose_action(action, ShowVersion)
}
if options.any(option => option.1 == Some(short)) {
let rest = String::from_iter(chars)
if short == 'C' && rest == "" {
token = arg + "always"
} else {
takes_next = rest == ""
}
break
}
}
}
normalized.push(token)
if takes_next && index + 1 < args.length() {
normalized.push(args[index + 1])
continue index + 2
}
continue index + 1
}
(normalized, action)
}
///|
/// Argparse exposes display-ready errors. Adapt its syntax failures to the
/// established ShellCheck-compatible messages and usage exit code.
fn argparse_error(error : Error) -> CliError {
let message = error.to_string().split("\n").next().unwrap_or("").to_owned()
if message.strip_prefix("error: unexpected argument '") is Some(rest) &&
rest.strip_suffix("' found") is Some(flag) {
return usage_error("Unknown option: " + flag.to_owned())
}
if message.strip_prefix("error: a value is required for '") is Some(rest) &&
rest.strip_suffix("' but none was supplied") is Some(flag) {
return usage_error("Missing value for " + flag.to_owned())
}
usage_error(error.to_string())
}
///|
/// Keeps the first requested non-run action.
///
/// This makes `--help --version` deterministic: the first terminal action wins
/// and later ones are ignored instead of fighting over output.
fn choose_action(
current : @driver.CliAction,
next : @driver.CliAction,
) -> @driver.CliAction {
match current {
Run => next
_ => current
}
}
///|
/// Splits a long option into the flag name and optional inline value at the
/// first `=`.
fn split_flag_value(arg : String) -> (String, String?) {
let view = arg[:]
let mut i = 0
while i < view.length() {
if view[i] == '=' {
return (
arg.view(end_offset=i).to_owned(),
Some(arg.view(start_offset=i + 1).to_owned()),
)
}
i += 1
}
(arg, None)
}
///|
/// Parses color values after bare `--color` / `-C` have defaulted to "always".
fn parse_color_option(value : String) -> Result[CliColorOption, CliError] {
match value {
"auto" => Ok(Auto)
"always" => Ok(Always)
"never" => Ok(Never)
_ =>
Err(
support_error(
"Unknown value for --color. Valid options are: auto, always, never",
),
)
}
}
///|
/// Resolves the user-facing color mode into a concrete enable/disable flag.
///
/// `Auto` follows the common CLI convention of honoring `NO_COLOR` first and
/// otherwise enabling color only for non-dumb terminals.
fn resolve_color(color : CliColorOption) -> Bool {
match color {
Always => true
Never => false
Auto =>
if @env.get_env_var("NO_COLOR") is Some(_) {
false
} else if @env.get_env_var("TERM") is Some(term) {
term != "" && term != "dumb"
} else {
false
}
}
}
///|
/// Parses a strict boolean literal used by `--extended-analysis`.
fn parse_bool_value(text : String) -> Bool? {
match text {
"true" => Some(true)
"false" => Some(false)
_ => None
}
}
///|
/// Parses a shell dialect name accepted by the CLI.
fn parse_shell(text : String) -> @semantics.ShellDialect? {
match text {
"sh" => Some(Sh)
"bash" => Some(Bash)
"dash" => Some(Dash)
"ksh" => Some(Ksh)
"busybox-sh" | "busybox" => Some(BusyboxSh)
_ => None
}
}
///|
/// Delegates severity parsing to the checker layer so the CLI and library API
/// share one canonical severity vocabulary.
fn parse_severity(text : String) -> @checker.DiagnosticSeverity? {
@checker.parse_diagnostic_severity(text)
}
///|
/// Parses a comma-separated list of ShellCheck codes.
///
/// Each item may optionally include the `SC` prefix, but anything that is not a
/// clean non-negative integer is rejected.
fn parse_code_list(text : String) -> Result[Array[Int32], CliError] {
let codes : Array[Int32] = []
for value in split_csv(text) {
if parse_code(value) is Some(code) {
codes.push(code)
} else {
return Err(usage_error("Invalid number: " + value))
}
}
Ok(codes)
}
///|
/// Parses one ShellCheck code in either `1234` or `SC1234` form.
fn parse_code(text : String) -> Int32? {
let trimmed = text.trim().to_owned()
let digits = if trimmed.has_prefix("SC") {
trimmed.view(start_offset=2).to_owned()
} else {
trimmed
}
if digits == "" {
return None
}
let view = digits[:]
let mut value = 0
let mut i = 0
while i < view.length() {
let ch = view[i]
if ch < '0' || ch > '9' {
return None
}
// Manual accumulation avoids depending on any looser integer parser that
// might accept signs or whitespace the CLI does not want to allow.
value = value * 10 + (ch - '0').to_int()
i += 1
}
Some(value)
}
///|
/// Parses a non-negative integer and reuses the same validation rules as code
/// parsing so diagnostics stay consistent.
fn parse_nonnegative_int(text : String) -> Result[Int32, CliError] {
if parse_code(text) is Some(value) {
Ok(value)
} else {
Err(usage_error("Invalid number: " + text))
}
}
///|
/// Splits a comma-separated list and discards empty / whitespace-only entries.
fn split_csv(text : String) -> Array[String] {
let values : Array[String] = []
for value in text.split(",") {
let trimmed = value.to_owned().trim().to_owned()
if trimmed != "" {
values.push(trimmed)
}
}
values
}
///|
/// Splits `--source-path` values.
///
/// ShellCheck accepts both `:` and `;` separated forms, so this parser uses `;`
/// when present and otherwise falls back to `:`.
fn split_source_paths(text : String) -> Array[String] {
let separator = if text.contains(";") { ";" } else { ":" }
let values : Array[String] = []
for value in text.split(separator) {
let trimmed = value.to_owned().trim().to_owned()
if trimmed != "" {
values.push(trimmed)
}
}
values
}
///|
/// Returns a fresh array containing the contents of `lhs` followed by `rhs`.
fn append_ints(lhs : Array[Int32], rhs : Array[Int32]) -> Array[Int32] {
let values : Array[Int32] = []
for value in lhs {
values.push(value)
}
for value in rhs {
values.push(value)
}
values
}
///|
/// Appends optional-check names while preserving existing order and uniqueness.
///
/// The special `all` token expands to every supported optional check exactly
/// once at the point where it appears.
fn append_optional_checks(
current : Array[String],
additions : Array[String],
) -> Array[String] {
let values : Array[String] = []
for value in current {
values.push(value)
}
let supported = @checker.supported_optional_checks()
for value in additions {
if value == "all" {
for name in supported {
if !values.contains(name) {
values.push(name)
}
}
} else if !values.contains(value) {
values.push(value)
}
}
values
}
///|
/// Constructs a usage error that should terminate with the CLI usage exit code.
fn usage_error(message : String) -> CliError {
{ kind: Usage, message, }
}
///|
/// Constructs a support error for syntactically valid but unsupported input.
fn support_error(message : String) -> CliError {
{ kind: Support, message, }
}
///|
/// Formats the list of supported renderer names for an unknown `--format`
/// request.
fn unknown_format_error(format : String) -> CliError {
support_error(
"Unknown format " +
format +
(
#|
#|Supported formats:
#| checkstyle
#| diff
#| gcc
#| json
#| json1
#| quiet
#| tty
),
)
}
///|
/// Reuses ShellCheck's severity vocabulary in the error text.
fn unknown_severity_error() -> CliError {
support_error(
"Unknown value for --severity. Valid options are: error, warning, info, style",
)
}
///|
/// Maps parser error classes to shell exit codes.
fn cli_exit_code(kind : CliErrorKind) -> Int32 {
match kind {
Usage => 3
Support => 4
}
}