diff --git a/Cargo.lock b/Cargo.lock index 6f3161e..0854d79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,6 +90,7 @@ version = "0.2.2" dependencies = [ "anyhow", "clap", + "clap_lex", "config", "normpath", "numeric-sort", @@ -158,9 +159,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" diff --git a/Cargo.toml b/Cargo.toml index 6050843..b2dfc52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ readme = "README.md" [workspace.dependencies] anyhow = "1.0.95" clap = "4.5.26" +clap_lex = "1.1.0" config = { version = "0.15.11", features = ["toml"] } normpath = "1.3" numeric-sort = "0.1.5" diff --git a/README.md b/README.md index a7bdc9c..547bb3e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A helpful command-line interface for AUCPL problem setters. - Generate input test cases from generator scripts - Compare the outputs of two or more solutions - Fuzz solutions to see if there are any bugs or unhandled edge cases +- Shell completions Planned: @@ -22,7 +23,6 @@ Planned: - Uploading problems and test cases to an online judge - Testing code within judge environments - Improve checking/validation of problems, covering more criteria -- Shell auto completions ## Install @@ -104,10 +104,18 @@ Other - `aucpl help`: Show help - `aucpl sync`: Generate or update the problem mappings file -To make `aucpl cd` change your current shell directory, install the shell hook once per shell session: +To make `aucpl cd` change your current shell directory and to also enable dynamic problem/competition completions, install the shell hook once per shell session. + +For bash/zsh: ```sh -eval $(aucpl shellinit) +eval "$(aucpl shellinit)" +``` + +For fish: + +```fish +aucpl shellinit | source ``` Then you can run: @@ -115,4 +123,6 @@ Then you can run: ```sh aucpl cd aucpl cd +aucpl problem test -p +aucpl comp finish ``` diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 2ae005d..ac95b6b 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -11,6 +11,7 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true clap.workspace = true +clap_lex.workspace = true config.workspace = true normpath.workspace = true numeric-sort.workspace = true diff --git a/crates/cli/src/cli/arg_builders.rs b/crates/cli/src/cli/arg_builders.rs new file mode 100644 index 0000000..7e10f75 --- /dev/null +++ b/crates/cli/src/cli/arg_builders.rs @@ -0,0 +1,88 @@ +//! Shared clap argument builders for problem and competition args. + +use clap::{Arg, ArgAction}; + +pub(crate) const PROBLEM_VALUE_NAME: &str = "PROBLEM"; +pub(crate) const COMPETITION_VALUE_NAME: &str = "COMP"; + +const PROBLEM_HELP: &str = "Problem name (this is not the problem title)"; +const COMPETITION_HELP: &str = "Competition name"; + +fn set_arg_metadata(arg: Arg, help: &'static str, value_name: &'static str) -> Arg { + arg.help(help).value_name(value_name).action(ArgAction::Set) +} + +pub(crate) fn configure_problem_arg(arg: Arg) -> Arg { + set_arg_metadata(arg, PROBLEM_HELP, PROBLEM_VALUE_NAME) +} + +pub(crate) fn configure_competition_arg(arg: Arg) -> Arg { + set_arg_metadata(arg, COMPETITION_HELP, COMPETITION_VALUE_NAME) +} + +pub(crate) fn problem_arg_optional() -> Arg { + configure_problem_arg(Arg::new("problem")) +} + +pub(crate) fn problem_option_arg_optional() -> Arg { + problem_arg_optional().short('p').long("problem") +} + +pub(crate) fn problem_option_arg_required() -> Arg { + problem_option_arg_optional().required(true) +} + +pub(crate) fn competition_arg_optional() -> Arg { + configure_competition_arg(Arg::new("comp")) +} + +pub(crate) fn competition_arg_required() -> Arg { + competition_arg_optional().required(true) +} + +pub(crate) fn competition_option_arg_optional() -> Arg { + competition_arg_optional().short('c').long("comp") +} + +pub(crate) fn competition_option_arg_required() -> Arg { + competition_option_arg_optional().required(true) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn problem_option_arg_helper_sets_expected_metadata() { + let arg = problem_option_arg_required(); + + assert_eq!(arg.get_id().as_str(), "problem"); + assert_eq!(arg.get_short(), Some('p')); + assert_eq!(arg.get_long(), Some("problem")); + assert_eq!( + arg.get_value_names() + .and_then(|names| names.first()) + .map(|name| name.as_str()), + Some(PROBLEM_VALUE_NAME) + ); + assert!(matches!(arg.get_action(), ArgAction::Set)); + assert!(arg.is_required_set()); + } + + #[test] + fn competition_option_arg_helper_sets_expected_metadata() { + let arg = competition_option_arg_optional(); + + assert_eq!(arg.get_id().as_str(), "comp"); + assert_eq!(arg.get_short(), Some('c')); + assert_eq!(arg.get_long(), Some("comp")); + assert_eq!( + arg.get_value_names() + .and_then(|names| names.first()) + .map(|name| name.as_str()), + Some(COMPETITION_VALUE_NAME) + ); + assert!(matches!(arg.get_action(), ArgAction::Set)); + assert!(!arg.is_required_set()); + } +} diff --git a/crates/cli/src/cli/cd.rs b/crates/cli/src/cli/cd.rs index acabe46..f5c8d1c 100644 --- a/crates/cli/src/cli/cd.rs +++ b/crates/cli/src/cli/cd.rs @@ -1,9 +1,10 @@ use std::fs; use anyhow::{Context, Result}; -use clap::{Arg, ArgAction, ArgMatches, Command}; +use clap::{ArgMatches, Command}; use normpath::PathExt; +use crate::cli::arg_builders::problem_arg_optional; use crate::config::get_settings; use crate::paths::resolve_stored_path; use crate::problem::sync_mappings::get_problem; @@ -15,11 +16,7 @@ pub fn cli() -> Command { "Print the target directory for a problem or the workspace root. Evaluate `aucpl shellinit` to instead cd to the directory.", ) - .arg( - Arg::new("problem") - .help("Problem name") - .action(ArgAction::Set), - ) + .arg(problem_arg_optional()) } pub fn exec(args: &ArgMatches) -> Result<()> { diff --git a/crates/cli/src/cli/comp.rs b/crates/cli/src/cli/comp.rs index 4f0f399..4e95a37 100644 --- a/crates/cli/src/cli/comp.rs +++ b/crates/cli/src/cli/comp.rs @@ -3,6 +3,10 @@ use std::fs; use anyhow::{Context, Result}; use clap::{Arg, ArgAction, ArgMatches, Command}; +use crate::cli::arg_builders::{ + competition_arg_required, competition_option_arg_optional, competition_option_arg_required, + configure_competition_arg, problem_option_arg_required, +}; use crate::comp::{add, create, finish, list, remove, rename, solve, test}; use crate::config::get_settings; use crate::problem::run::{RunnableCategory, RunnableFile}; @@ -16,18 +20,8 @@ pub fn cli() -> Command { .about("Add a problem to a competition") .arg_required_else_help(true) .args([ - Arg::new("comp") - .short('c') - .long("comp") - .help("Competition name") - .action(ArgAction::Set) - .required(true), - Arg::new("problem") - .short('p') - .long("problem") - .help("Problem name") - .action(ArgAction::Set) - .required(true), + competition_option_arg_required(), + problem_option_arg_required(), ]), ) .subcommand( @@ -44,37 +38,20 @@ pub fn cli() -> Command { .subcommand( Command::new("finish") .about("Finish a competition and archive problems from the competition") - .args([Arg::new("comp") - .help("Competition name") - .action(ArgAction::Set) - .required(true)]), + .args([competition_arg_required()]), ) .subcommand( Command::new("list") .about("List all competitions or list problems in a competition") - .args([Arg::new("comp") - .short('c') - .long("comp") - .help("Competition name") - .action(ArgAction::Set)]), + .args([competition_option_arg_optional()]), ) .subcommand( Command::new("remove") .about("Remove a problem from a competition") .arg_required_else_help(true) .args([ - Arg::new("comp") - .short('c') - .long("comp") - .help("Competition name") - .action(ArgAction::Set) - .required(true), - Arg::new("problem") - .short('p') - .long("problem") - .help("Problem name") - .action(ArgAction::Set) - .required(true), + competition_option_arg_required(), + problem_option_arg_required(), ]), ) .subcommand( @@ -82,10 +59,9 @@ pub fn cli() -> Command { .about("Rename a competition") .arg_required_else_help(true) .args([ - Arg::new("old_name") + configure_competition_arg(Arg::new("old_name")) .long("old-name") .help("Old competition name") - .action(ArgAction::Set) .required(true), Arg::new("new_name") .long("new-name") @@ -99,10 +75,7 @@ pub fn cli() -> Command { .about("Generate output test cases for all problems in a competition") .arg_required_else_help(true) .args([ - Arg::new("comp") - .help("Competition name") - .action(ArgAction::Set) - .required(true), + competition_arg_required(), Arg::new("lang") .long("lang") .help("Language of the solution file (e.g. cpp, py)") @@ -114,10 +87,7 @@ pub fn cli() -> Command { .about("Run tests on all problems in a competition") .arg_required_else_help(true) .args([ - Arg::new("comp") - .help("Competition name") - .action(ArgAction::Set) - .required(true), + competition_arg_required(), Arg::new("lang") .long("lang") .help("Language of the solution file (e.g. cpp, py)") diff --git a/crates/cli/src/cli/complete/mod.rs b/crates/cli/src/cli/complete/mod.rs new file mode 100644 index 0000000..d949d00 --- /dev/null +++ b/crates/cli/src/cli/complete/mod.rs @@ -0,0 +1,134 @@ +//! Shell completion entrypoints and final result rendering. + +use std::collections::BTreeSet; + +use anyhow::Result; +use clap::{value_parser, Arg, ArgAction, ArgMatches, Command}; + +use crate::cli::complete::resolve::{resolve_request, CompletionRequest}; +use crate::cli::complete::values::complete_arg_values; + +mod resolve; +mod values; + +/// Final completion payload ready to be printed to stdout. +struct CompletionResult { + /// Candidate values to emit. + values: Vec, + /// Prefix to prepend to each printed completion. + replacement_prefix: String, +} + +/// Define the hidden internal completion subcommand. +pub fn cli() -> Command { + Command::new("__complete") + .about("Internal: print completion candidates") + .hide(true) + .arg( + Arg::new("cword") + .long("cword") + .help("Index of the current word") + .action(ArgAction::Set) + .value_parser(value_parser!(usize)) + .required(true), + ) + .arg( + Arg::new("words") + .help("Command words as received from the shell") + .action(ArgAction::Set) + .num_args(0..) + .trailing_var_arg(true) + .allow_hyphen_values(true), + ) +} + +/// Execute the hidden completion command by printing matching candidates. +pub fn exec(args: &ArgMatches) -> Result<()> { + let cword = args.get_one::("cword").copied().unwrap_or(0); + let words: Vec = args + .try_get_many::("words")? + .map(|vals| vals.cloned().collect()) + .unwrap_or_default(); + + let root = super::root(); + let result = complete_request(resolve_request(&root, &words, cword)); + + print_completions(&result.values, &result.replacement_prefix); + + Ok(()) +} + +fn filter_prefix_matches(values: I, current: &str) -> Vec +where + I: IntoIterator, +{ + values + .into_iter() + .filter(|value| current.is_empty() || value.starts_with(current)) + .collect() +} + +/// Return visible subcommand names for a command in sorted order. +fn visible_subcommands(cmd: &Command, current: &str) -> Vec { + let mut names: Vec = cmd + .get_subcommands() + .filter(|sub| !sub.is_hide_set()) + .map(|sub| sub.get_name().to_owned()) + .collect(); + names.sort(); + filter_prefix_matches(names, current) +} + +/// Return visible option names and aliases for a command in sorted order. +fn visible_options(cmd: &Command, current: &str) -> Vec { + let mut names = BTreeSet::new(); + + for arg in cmd.get_arguments() { + if arg.is_positional() || arg.is_hide_set() { + continue; + } + + if let Some(shorts) = arg.get_short_and_visible_aliases() { + for short in shorts { + names.insert(format!("-{short}")); + } + } + + if let Some(longs) = arg.get_long_and_visible_aliases() { + for long in longs { + names.insert(format!("--{long}")); + } + } + } + + filter_prefix_matches(names, current) +} + +/// Print completion candidates, optionally re-attaching an inline option prefix. +fn print_completions(values: &[String], replacement_prefix: &str) { + for value in values { + println!("{replacement_prefix}{value}"); + } +} + +/// Convert a completion request into a final printable completion result. +fn complete_request(request: CompletionRequest<'_>) -> CompletionResult { + match request { + CompletionRequest::CommandName { cmd, current } => CompletionResult { + values: visible_subcommands(cmd, ¤t), + replacement_prefix: String::new(), + }, + CompletionRequest::OptionName { cmd, current } => CompletionResult { + values: visible_options(cmd, ¤t), + replacement_prefix: String::new(), + }, + CompletionRequest::ArgValue(target) => CompletionResult { + values: complete_arg_values(target.arg, &target.current_value), + replacement_prefix: target.replacement_prefix, + }, + CompletionRequest::None => CompletionResult { + values: vec![], + replacement_prefix: String::new(), + }, + } +} diff --git a/crates/cli/src/cli/complete/resolve.rs b/crates/cli/src/cli/complete/resolve.rs new file mode 100644 index 0000000..68a7b50 --- /dev/null +++ b/crates/cli/src/cli/complete/resolve.rs @@ -0,0 +1,865 @@ +//! Cursor analysis and clap-lex-backed token resolution for shell completion. + +use clap::{Arg, ArgAction, ArgMatches, Command}; +use clap_lex::RawArgs; + +/// The kind of completion requested at the cursor position. +pub(super) enum CompletionRequest<'a> { + /// Complete subcommand names for the current command. + CommandName { cmd: &'a Command, current: String }, + /// Complete option names for the current command. + OptionName { cmd: &'a Command, current: String }, + /// Complete values for a specific argument. + ArgValue(ValueTarget<'a>), + /// No completion candidates are meaningful at this cursor position. + None, +} + +/// A parsed option token resolved against a clap [`Arg`]. +struct OptionMatch<'a> { + /// The clap argument matched by the token. + arg: &'a Arg, + /// The inline value attached to the option token, if any. + attached_value: Option, + /// Prefix to re-add when returning completions for inline values. + replacement_prefix: String, +} + +impl<'a> OptionMatch<'a> { + /// Return whether this option expects a value. + fn takes_value(&self) -> bool { + arg_takes_value(self.arg) + } + + /// Convert an inline `--opt=value` / `-ovalue` token into a value target. + fn attached_value_target(&self) -> Option> { + if !self.takes_value() { + return None; + } + + Some(ValueTarget::inline( + self.arg, + self.attached_value.clone()?, + self.replacement_prefix.clone(), + )) + } + + /// Convert a standalone option token into the following-word value target. + fn following_value_target(&self, current: &str) -> Option> { + if self.attached_value.is_some() || !self.takes_value() { + return None; + } + + Some(ValueTarget::standalone(self.arg, current)) + } +} + +/// A concrete argument value currently being completed. +pub(super) struct ValueTarget<'a> { + /// The clap argument that owns the value being completed. + pub(super) arg: &'a Arg, + /// The current partial value typed by the user. + pub(super) current_value: String, + /// Prefix to prepend when replacing inline option values. + pub(super) replacement_prefix: String, +} + +impl<'a> ValueTarget<'a> { + /// Build a target for a value typed as a separate shell word. + fn standalone(arg: &'a Arg, current_value: &str) -> Self { + Self { + arg, + current_value: current_value.to_owned(), + replacement_prefix: String::new(), + } + } + + /// Build a target for a value attached inline to its option token. + fn inline(arg: &'a Arg, current_value: String, replacement_prefix: String) -> Self { + Self { + arg, + current_value, + replacement_prefix, + } + } +} + +/// Return whether a clap argument consumes one or more values. +fn arg_takes_value(arg: &Arg) -> bool { + arg.get_num_args() + .unwrap_or_else(|| match arg.get_action() { + ArgAction::Set | ArgAction::Append => 1.into(), + ArgAction::SetTrue + | ArgAction::SetFalse + | ArgAction::Count + | ArgAction::Help + | ArgAction::HelpShort + | ArgAction::HelpLong + | ArgAction::Version => 0.into(), + _ => 1.into(), + }) + .takes_values() +} + +/// Shared clap-lex classification for a single shell token. +struct TokenClass { + /// Whether the token is a literal `--` escape. + is_escape: bool, + /// Whether clap-lex recognizes the token as a negative number. + is_negative_number: bool, + /// Whether clap-lex recognizes the token as a short or long option. + is_option: bool, +} + +/// Classify a single shell token using clap-lex. +#[cfg(test)] +fn classify_token(token: &str) -> Option { + let raw = RawArgs::new([token]); + let mut cursor = raw.cursor(); + let arg = raw.next(&mut cursor)?; + + Some(TokenClass { + is_escape: arg.is_escape(), + is_negative_number: arg.is_negative_number(), + is_option: arg.is_long() || arg.is_short(), + }) +} + +/// Analyze a shell token for a specific clap command. +struct CommandToken<'a, 't> { + /// Original token text. + token: &'t str, + /// Shared clap-lex classification. + class: TokenClass, + /// Resolved clap option match, if this token names an option. + option_match: Option>, +} + +impl<'a, 't> CommandToken<'a, 't> { + /// Parse and analyze a token once for the given command. + fn parse(cmd: &'a Command, token: &'t str) -> Option { + let raw = RawArgs::new([token]); + let mut cursor = raw.cursor(); + let arg = raw.next(&mut cursor)?; + + let class = TokenClass { + is_escape: arg.is_escape(), + is_negative_number: arg.is_negative_number(), + is_option: arg.is_long() || arg.is_short(), + }; + + let option_match = if let Some((name, attached_value)) = arg.to_long() { + let name = name.ok()?; + let attached_value = match attached_value { + Some(value) => Some(value.to_str()?.to_owned()), + None => None, + }; + + cmd.get_arguments() + .find(|arg| matches_long(arg, name)) + .map(|arg| OptionMatch { + arg, + attached_value, + replacement_prefix: format!("--{name}="), + }) + } else if let Some(mut shorts) = arg.to_short() { + let mut consumed = '-'.len_utf8(); + let mut last_match = None; + + while let Some(name) = shorts.next_flag() { + let name = name.ok()?; + consumed += name.len_utf8(); + + let Some(arg) = cmd.get_arguments().find(|arg| matches_short(arg, name)) else { + last_match = None; + break; + }; + let attached_value = if arg_takes_value(arg) { + match shorts.next_value_os() { + Some(value) => Some(value.to_str()?.to_owned()), + None => None, + } + } else { + None + }; + + let option_match = OptionMatch { + arg, + attached_value, + replacement_prefix: token[..consumed].to_owned(), + }; + + if option_match.takes_value() { + last_match = Some(option_match); + break; + } + + last_match = Some(option_match); + } + + last_match + } else { + None + }; + + Some(Self { + token, + class, + option_match, + }) + } + + /// Return whether the token is a literal `--` escape. + fn is_escape(&self) -> bool { + self.class.is_escape + } + + /// Return whether the token should be treated as a non-option word. + fn is_non_option_word(&self) -> bool { + !self.class.is_option + } + + /// Return whether the token is a negative number. + fn is_negative_number(&self) -> bool { + self.class.is_negative_number + } + + /// Return whether the token should trigger option-name completion. + fn requests_option_name_completion(&self) -> bool { + self.class.is_option && !self.class.is_negative_number + } +} + +/// Return whether a shell token is lexed by clap as a negative number. +#[cfg(test)] +fn token_is_negative_number(token: &str) -> bool { + classify_token(token).is_some_and(|token| token.is_negative_number) +} + +/// Return whether the token should trigger option-name completion. +#[cfg(test)] +fn requests_option_name_completion(token: &str) -> bool { + classify_token(token).is_some_and(|token| token.is_option && !token.is_negative_number) +} + +/// Cursor-local view of the current shell words. +struct CursorContext<'a> { + current: String, + prev: Option<&'a str>, +} + +impl<'a> CursorContext<'a> { + fn new(words: &'a [String], cword: usize) -> Self { + Self { + current: words.get(cword).cloned().unwrap_or_default(), + prev: cword + .checked_sub(1) + .and_then(|idx| words.get(idx)) + .map(String::as_str), + } + } +} + +/// Command/escape state derived from the already-typed prefix. +struct PrefixScan<'a> { + /// The active clap command at the cursor position. + cmd: &'a Command, + /// Whether the cursor is after a literal `--` escape. + after_escape: bool, +} + +/// Full prefix analysis used during completion resolution. +struct PrefixAnalysis<'a> { + /// Lightweight token scan used to track command context and `--` handling. + scan: PrefixScan<'a>, + /// Clap parsing of the already-typed prefix, if parsing succeeded. + parsed_matches: Option, +} + +impl<'a> PrefixAnalysis<'a> { + /// Analyze the already-typed command prefix once for the current cursor position. + fn new(root: &'a Command, words: &[String], cword: usize) -> Self { + Self { + scan: scan_prefix(root, words, cword), + parsed_matches: parse_prefix_matches(root, words, cword), + } + } + + /// Return the command active at the cursor position. + fn cmd(&self) -> &'a Command { + self.scan.cmd + } + + /// Return whether the cursor is after a literal `--` escape. + fn after_escape(&self) -> bool { + self.scan.after_escape + } + + /// Resolve a positional value target, if the cursor is completing one. + fn positional_value_target( + &self, + root: &'a Command, + current: &str, + current_token: Option<&CommandToken<'a, '_>>, + ) -> Option> { + let prefix_matches = self.parsed_matches.as_ref()?; + let (parsed_cmd, active_matches) = active_command_context(root, prefix_matches); + + if !std::ptr::eq(parsed_cmd, self.cmd()) { + return None; + } + + positional_value_target( + self.cmd(), + active_matches, + current, + current_token, + self.after_escape(), + ) + } + + /// Fall back to option-name, subcommand, or no completion. + fn fallback_request( + &self, + current: String, + current_token: Option<&CommandToken<'a, '_>>, + ) -> CompletionRequest<'a> { + if self.after_escape() { + CompletionRequest::None + } else if current_token.is_some_and(CommandToken::requests_option_name_completion) { + CompletionRequest::OptionName { + cmd: self.cmd(), + current, + } + } else { + CompletionRequest::CommandName { + cmd: self.cmd(), + current, + } + } + } +} + +/// Walk the already-typed prefix to find the active command and escape state. +fn scan_prefix<'a>(root: &'a Command, words: &[String], cword: usize) -> PrefixScan<'a> { + let mut cmd = root; + let mut escaped = false; + let mut expecting_value = false; + + for token in words.iter().take(cword).skip(1).map(String::as_str) { + if escaped { + continue; + } + + if expecting_value { + expecting_value = false; + continue; + } + + let Some(token) = CommandToken::parse(cmd, token) else { + continue; + }; + + if token.is_escape() { + escaped = true; + continue; + } + + if token.is_non_option_word() { + if let Some(subcommand) = cmd.find_subcommand(token.token) { + cmd = subcommand; + } + continue; + } + + if token.option_match.as_ref().is_some_and(|option_match| { + option_match.takes_value() && option_match.attached_value.is_none() + }) { + expecting_value = true; + } + } + + PrefixScan { + cmd, + after_escape: escaped, + } +} + +/// Return whether parsing state at the cursor is after a literal `--` escape. +#[cfg(test)] +fn is_cursor_after_escape(root: &Command, words: &[String], cword: usize) -> bool { + scan_prefix(root, words, cword).after_escape +} + +/// Return whether the token matches a long option or visible long alias. +fn matches_long(arg: &Arg, name: &str) -> bool { + arg.get_long_and_visible_aliases() + .is_some_and(|longs| longs.into_iter().any(|candidate| candidate == name)) +} + +/// Return whether the token matches a short option or visible short alias. +fn matches_short(arg: &Arg, name: char) -> bool { + arg.get_short_and_visible_aliases() + .is_some_and(|shorts| shorts.into_iter().any(|candidate| candidate == name)) +} + +/// Match a raw shell token to a clap option on the given command. +#[cfg(test)] +fn match_option<'a>(cmd: &'a Command, token: &str) -> Option> { + CommandToken::parse(cmd, token)?.option_match +} + +/// Walk parsed clap matches to determine the active command at the cursor. +fn active_command_context<'a, 'm>( + root: &'a Command, + matches: &'m ArgMatches, +) -> (&'a Command, &'m ArgMatches) { + let mut cmd = root; + let mut current_matches = matches; + + while let Some((name, sub_matches)) = current_matches.subcommand() { + let Some(subcommand) = cmd.find_subcommand(name) else { + break; + }; + + cmd = subcommand; + current_matches = sub_matches; + } + + (cmd, current_matches) +} + +/// Count positional arguments already consumed for the active command. +fn count_consumed_positionals(cmd: &Command, matches: &ArgMatches) -> usize { + cmd.get_positionals() + .map(|arg| { + matches + .indices_of(arg.get_id().as_str()) + .map(|indices| indices.count()) + .unwrap_or(0) + }) + .sum() +} + +/// Return whether the current token may be treated as positional input. +fn allows_positional_completion( + current: &str, + current_token: Option<&CommandToken<'_, '_>>, + after_escape: bool, +) -> bool { + after_escape + || !current.starts_with('-') + || current_token.is_some_and(CommandToken::is_negative_number) +} + +/// Resolve the positional argument value currently being completed, if any. +fn positional_value_target<'a>( + cmd: &'a Command, + matches: &ArgMatches, + current: &str, + current_token: Option<&CommandToken<'a, '_>>, + after_escape: bool, +) -> Option> { + if !allows_positional_completion(current, current_token, after_escape) { + return None; + } + + let arg = cmd + .get_positionals() + .nth(count_consumed_positionals(cmd, matches))?; + + Some(ValueTarget::standalone(arg, current)) +} + +/// Resolve an inline current-token option value like `--problem=fo` or `-pfo`. +fn current_token_option_value_target<'a>( + current_token: Option<&CommandToken<'a, '_>>, + after_escape: bool, +) -> Option> { + if after_escape { + return None; + } + + current_token + .and_then(|token| token.option_match.as_ref()) + .and_then(OptionMatch::attached_value_target) +} + +/// Resolve a next-word option value like `-p fo`. +fn previous_token_option_value_target<'a>( + prev_token: Option<&CommandToken<'a, '_>>, + current: &str, +) -> Option> { + prev_token + .and_then(|token| token.option_match.as_ref()) + .and_then(|option_match| option_match.following_value_target(current)) +} + +/// Resolve the option argument value currently being completed, if any. +fn option_value_target<'a>( + current_token: Option<&CommandToken<'a, '_>>, + prev_token: Option<&CommandToken<'a, '_>>, + current: &str, + after_escape: bool, +) -> Option> { + current_token_option_value_target(current_token, after_escape) + .or_else(|| previous_token_option_value_target(prev_token, current)) +} + +/// Parse the already-typed prefix with clap, tolerating incomplete input. +fn parse_prefix_matches(root: &Command, words: &[String], cword: usize) -> Option { + let prefix_words: Vec = if words.is_empty() || cword == 0 { + vec![crate::BIN_NAME.to_owned()] + } else { + words.iter().take(cword).cloned().collect() + }; + + let mut parser = root.clone().ignore_errors(true); + parser.try_get_matches_from_mut(prefix_words).ok() +} + +fn top_level_command_request<'a>( + root: &'a Command, + words: &[String], + cword: usize, + current: String, +) -> Option> { + if cword == 1 || (words.get(1).map(String::as_str) == Some("help") && cword == 2) { + Some(CompletionRequest::CommandName { cmd: root, current }) + } else { + None + } +} + +/// Classify the cursor position into a single completion request. +pub(super) fn resolve_request<'a>( + root: &'a Command, + words: &[String], + cword: usize, +) -> CompletionRequest<'a> { + let cursor = CursorContext::new(words, cword); + + if let Some(request) = top_level_command_request(root, words, cword, cursor.current.clone()) { + return request; + } + + let prefix = PrefixAnalysis::new(root, words, cword); + let fallback_current = cursor.current.clone(); + let current_token = CommandToken::parse(prefix.cmd(), &cursor.current); + let prev_token = cursor + .prev + .and_then(|prev| CommandToken::parse(prefix.cmd(), prev)); + + if let Some(target) = option_value_target( + current_token.as_ref(), + prev_token.as_ref(), + &cursor.current, + prefix.after_escape(), + ) { + return CompletionRequest::ArgValue(target); + } + + if let Some(target) = + prefix.positional_value_target(root, &cursor.current, current_token.as_ref()) + { + return CompletionRequest::ArgValue(target); + } + + prefix.fallback_request(fallback_current, current_token.as_ref()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_cmd() -> Command { + Command::new("aucpl") + .arg(Arg::new("alpha").short('a').action(ArgAction::SetTrue)) + .arg(Arg::new("beta").short('b').action(ArgAction::Set)) + .arg(Arg::new("gamma").short('g').action(ArgAction::SetTrue)) + .arg(Arg::new("problem").long("problem").action(ArgAction::Set)) + } + + #[test] + fn matches_long_option_with_inline_value() { + let cmd = test_cmd(); + let matched = match_option(&cmd, "--problem=foo").expect("long option should match"); + + assert_eq!(matched.arg.get_id().as_str(), "problem"); + assert_eq!(matched.attached_value.as_deref(), Some("foo")); + assert_eq!(matched.replacement_prefix, "--problem="); + } + + #[test] + fn matches_short_option_with_inline_value() { + let cmd = test_cmd(); + let matched = match_option(&cmd, "-bfoo").expect("short option should match"); + + assert_eq!(matched.arg.get_id().as_str(), "beta"); + assert_eq!(matched.attached_value.as_deref(), Some("foo")); + assert_eq!(matched.replacement_prefix, "-b"); + } + + #[test] + fn respects_short_flag_clusters() { + let cmd = test_cmd(); + let matched = match_option(&cmd, "-abfoo").expect("short cluster should match"); + + assert_eq!(matched.arg.get_id().as_str(), "beta"); + assert_eq!(matched.attached_value.as_deref(), Some("foo")); + assert_eq!(matched.replacement_prefix, "-ab"); + } + + #[test] + fn does_not_treat_flag_cluster_suffix_as_an_attached_value() { + let cmd = Command::new("aucpl") + .arg(Arg::new("alpha").short('a').action(ArgAction::SetTrue)) + .arg(Arg::new("beta").short('b').action(ArgAction::SetTrue)) + .arg(Arg::new("gamma").short('g').action(ArgAction::SetTrue)); + let matched = match_option(&cmd, "-abg").expect("short cluster should match"); + + assert_eq!(matched.arg.get_id().as_str(), "gamma"); + assert_eq!(matched.attached_value, None); + } + + #[test] + fn detects_negative_number_tokens() { + assert!(token_is_negative_number("-1")); + assert!(token_is_negative_number("-3.14")); + assert!(!token_is_negative_number("--problem")); + assert!(!token_is_negative_number("-p")); + } + + #[test] + fn detects_tokens_that_request_option_name_completion() { + assert!(requests_option_name_completion("-p")); + assert!(requests_option_name_completion("--problem")); + assert!(!requests_option_name_completion("-")); + assert!(!requests_option_name_completion("--")); + assert!(!requests_option_name_completion("-1")); + } + + #[test] + fn resolves_negative_number_current_token_as_positional_value() { + let root = Command::new("aucpl").subcommand( + Command::new("create") + .arg(Arg::new("difficulty").action(ArgAction::Set).required(true)) + .arg(Arg::new("name").action(ArgAction::Set).required(true)), + ); + let words = vec!["aucpl".to_owned(), "create".to_owned(), "-1".to_owned()]; + + let request = resolve_request(&root, &words, 2); + + assert!(matches!(request, CompletionRequest::ArgValue(_))); + } + + #[test] + fn resolves_value_completion_for_short_problem_option() { + let root = Command::new("aucpl").subcommand(Command::new("problem").subcommand( + Command::new("test").arg(Arg::new("problem").short('p').action(ArgAction::Set)), + )); + let words = vec![ + "aucpl".to_owned(), + "problem".to_owned(), + "test".to_owned(), + "-p".to_owned(), + "fo".to_owned(), + ]; + + let request = resolve_request(&root, &words, 4); + + match request { + CompletionRequest::ArgValue(target) => { + assert_eq!(target.arg.get_id().as_str(), "problem"); + assert_eq!(target.current_value, "fo"); + assert!(target.replacement_prefix.is_empty()); + } + _ => panic!("expected short option value completion"), + } + } + + #[test] + fn resolves_value_completion_for_inline_problem_option() { + let root = Command::new("aucpl").subcommand(Command::new("problem").subcommand( + Command::new("test").arg(Arg::new("problem").long("problem").action(ArgAction::Set)), + )); + let words = vec![ + "aucpl".to_owned(), + "problem".to_owned(), + "test".to_owned(), + "--problem=fo".to_owned(), + ]; + + let request = resolve_request(&root, &words, 3); + + match request { + CompletionRequest::ArgValue(target) => { + assert_eq!(target.arg.get_id().as_str(), "problem"); + assert_eq!(target.current_value, "fo"); + assert_eq!(target.replacement_prefix, "--problem="); + } + _ => panic!("expected inline option value completion"), + } + } + + #[test] + fn resolves_value_completion_for_short_comp_option() { + let root = Command::new("aucpl").subcommand(Command::new("comp").subcommand( + Command::new("list").arg(Arg::new("comp").short('c').action(ArgAction::Set)), + )); + let words = vec![ + "aucpl".to_owned(), + "comp".to_owned(), + "list".to_owned(), + "-c".to_owned(), + "ac".to_owned(), + ]; + + let request = resolve_request(&root, &words, 4); + + match request { + CompletionRequest::ArgValue(target) => { + assert_eq!(target.arg.get_id().as_str(), "comp"); + assert_eq!(target.current_value, "ac"); + } + _ => panic!("expected competition option value completion"), + } + } + + #[test] + fn resolves_positional_problem_value_for_cd() { + let root = Command::new("aucpl") + .subcommand(Command::new("cd").arg(Arg::new("problem").action(ArgAction::Set))); + let words = vec!["aucpl".to_owned(), "cd".to_owned(), "al".to_owned()]; + + let request = resolve_request(&root, &words, 2); + + match request { + CompletionRequest::ArgValue(target) => { + assert_eq!(target.arg.get_id().as_str(), "problem"); + assert_eq!(target.current_value, "al"); + } + _ => panic!("expected positional problem value completion"), + } + } + + #[test] + fn resolves_unknown_option_prefix_as_option_name_completion() { + let root = Command::new("aucpl").subcommand(Command::new("problem").subcommand( + Command::new("test").arg(Arg::new("problem").long("problem").action(ArgAction::Set)), + )); + let words = vec![ + "aucpl".to_owned(), + "problem".to_owned(), + "test".to_owned(), + "--pro".to_owned(), + ]; + + assert!(matches!( + resolve_request(&root, &words, 3), + CompletionRequest::OptionName { .. } + )); + } + + #[test] + fn does_not_offer_option_name_completion_for_stdio_or_escape_tokens() { + let root = Command::new("aucpl").subcommand(Command::new("create")); + + let dash = resolve_request( + &root, + &["aucpl".to_owned(), "create".to_owned(), "-".to_owned()], + 2, + ); + let escape = resolve_request( + &root, + &["aucpl".to_owned(), "create".to_owned(), "--".to_owned()], + 2, + ); + + assert!(matches!(dash, CompletionRequest::CommandName { .. })); + assert!(matches!(escape, CompletionRequest::CommandName { .. })); + } + + #[test] + fn enters_escape_mode_after_double_dash() { + let root = Command::new("aucpl").subcommand( + Command::new("create") + .arg(Arg::new("name").action(ArgAction::Set).required(true)) + .arg(Arg::new("extra").action(ArgAction::Set)), + ); + let words = vec![ + "aucpl".to_owned(), + "create".to_owned(), + "foo".to_owned(), + "--".to_owned(), + "--problem=bar".to_owned(), + ]; + + assert!(is_cursor_after_escape(&root, &words, 4)); + assert!(matches!( + resolve_request(&root, &words, 4), + CompletionRequest::ArgValue(_) + )); + } + + #[test] + fn double_dash_used_as_option_value_does_not_enter_escape_mode() { + let root = Command::new("aucpl").subcommand( + Command::new("create") + .arg(Arg::new("problem").short('p').action(ArgAction::Set)) + .arg(Arg::new("name").action(ArgAction::Set)), + ); + let words = vec![ + "aucpl".to_owned(), + "create".to_owned(), + "-p".to_owned(), + "--".to_owned(), + "--problem".to_owned(), + ]; + + assert!(!is_cursor_after_escape(&root, &words, 4)); + assert!(matches!( + resolve_request(&root, &words, 4), + CompletionRequest::OptionName { .. } + )); + } + + #[test] + fn produces_no_completions_after_escape_without_a_positional_target() { + let root = Command::new("aucpl").subcommand(Command::new("create")); + let words = vec![ + "aucpl".to_owned(), + "create".to_owned(), + "--".to_owned(), + "--problem".to_owned(), + ]; + + assert!(matches!( + resolve_request(&root, &words, 3), + CompletionRequest::None + )); + } + + #[test] + fn completes_option_values_even_when_prefix_parsing_stops_at_a_missing_value() { + let root = Command::new("aucpl").subcommand(Command::new("problem").subcommand( + Command::new("test").arg(Arg::new("problem").short('p').action(ArgAction::Set)), + )); + let words = vec![ + "aucpl".to_owned(), + "problem".to_owned(), + "test".to_owned(), + "-p".to_owned(), + ]; + + let request = resolve_request(&root, &words, 4); + + match request { + CompletionRequest::ArgValue(target) => { + assert_eq!(target.arg.get_id().as_str(), "problem"); + assert!(target.current_value.is_empty()); + } + _ => panic!("expected option value completion"), + } + } +} diff --git a/crates/cli/src/cli/complete/values.rs b/crates/cli/src/cli/complete/values.rs new file mode 100644 index 0000000..36a0608 --- /dev/null +++ b/crates/cli/src/cli/complete/values.rs @@ -0,0 +1,322 @@ +//! Completion value providers for clap arguments. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File}; +use std::path::{Path, PathBuf}; + +use clap::{Arg, ValueHint}; +use serde_json::Value; + +use crate::cli::arg_builders::{COMPETITION_VALUE_NAME, PROBLEM_VALUE_NAME}; +use crate::comp::COMPETITIONS_FILE; +use crate::config::get_settings; +use crate::problem::sync_mappings::get_all_problem_names; +use crate::util::get_project_root; + +/// Dynamic completion categories backed by project data. +enum CompletionKind { + /// Complete problem names from the problem mapping file. + Problem, + /// Complete competition names from the competitions file. + Competition, +} + +/// The source used to produce values for an argument. +enum ValueProvider { + /// Values come from project-specific dynamic data. + Dynamic(CompletionKind), + /// Values are statically declared in clap. + Static(Vec), + /// Values should be completed as filesystem paths. + Path(ValueHint), + /// No completions are available for this argument. + None, +} + +fn filter_prefix_matches(mut values: Vec, current: &str) -> Vec { + if current.is_empty() { + return values; + } + + values.retain(|value| value.starts_with(current)); + values +} + +/// Load competition names from the project's competitions metadata file. +fn get_competition_names(problems_dir: &Path) -> Vec { + let comp_file_path = problems_dir.join(COMPETITIONS_FILE); + if !fs::exists(&comp_file_path).unwrap_or(false) { + return vec![]; + } + + let comp_file = match File::open(&comp_file_path) { + Ok(file) => file, + Err(_) => return vec![], + }; + + let data: BTreeMap = match serde_json::from_reader(comp_file) { + Ok(data) => data, + Err(_) => return vec![], + }; + + data.keys().cloned().collect() +} + +/// Resolve the configured problems directory for the current project, if any. +fn project_problems_dir() -> Option { + let project_root = get_project_root().ok()?; + let settings = get_settings().ok()?; + Some(project_root.join(&settings.problems_dir)) +} + +/// Infer the dynamic value category from an argument's configured value name. +fn completion_kind(arg: &Arg) -> Option { + let value_name = arg + .get_value_names() + .and_then(|names| names.first()) + .map(|name| name.as_str()); + + match value_name { + Some(PROBLEM_VALUE_NAME) => Some(CompletionKind::Problem), + Some(COMPETITION_VALUE_NAME) => Some(CompletionKind::Competition), + _ => None, + } +} + +/// Extract visible static possible values declared in clap for an argument. +fn static_possible_values(arg: &Arg) -> Vec { + let mut values: Vec = arg + .get_possible_values() + .into_iter() + .filter(|value| !value.is_hide_set()) + .map(|value| value.get_name().to_owned()) + .collect(); + + values.sort(); + values.dedup(); + values +} + +/// Determine how completions should be produced for a clap argument. +fn value_provider(arg: &Arg) -> ValueProvider { + if let Some(kind) = completion_kind(arg) { + return ValueProvider::Dynamic(kind); + } + + let values = static_possible_values(arg); + if !values.is_empty() { + return ValueProvider::Static(values); + } + + match arg.get_value_hint() { + ValueHint::AnyPath | ValueHint::FilePath | ValueHint::DirPath => { + ValueProvider::Path(arg.get_value_hint()) + } + _ => ValueProvider::None, + } +} + +/// Load filtered dynamic completion values for a project-backed completion kind. +fn dynamic_values(kind: CompletionKind, current: &str) -> Vec { + let Some(problems_dir) = project_problems_dir() else { + return vec![]; + }; + + let mut values = match kind { + CompletionKind::Problem => get_all_problem_names(&problems_dir).unwrap_or_default(), + CompletionKind::Competition => get_competition_names(&problems_dir), + }; + + values.sort(); + filter_prefix_matches(values, current) +} + +/// Complete filesystem path candidates according to the provided [`ValueHint`]. +fn path_candidates(current: &str, hint: ValueHint) -> Vec { + let allow_files = matches!(hint, ValueHint::AnyPath | ValueHint::FilePath); + let allow_dirs = matches!( + hint, + ValueHint::AnyPath | ValueHint::FilePath | ValueHint::DirPath + ); + + if !allow_files && !allow_dirs { + return vec![]; + } + + let (base_dir, display_prefix, leaf_prefix) = if current.is_empty() { + (PathBuf::from("."), String::new(), "") + } else if current.ends_with('/') { + (PathBuf::from(current), current.to_owned(), "") + } else if let Some(idx) = current.rfind('/') { + ( + PathBuf::from(¤t[..=idx]), + current[..=idx].to_owned(), + ¤t[idx + 1..], + ) + } else { + (PathBuf::from("."), String::new(), current) + }; + + let entries = match fs::read_dir(&base_dir) { + Ok(entries) => entries, + Err(_) => return vec![], + }; + + let mut values = BTreeSet::new(); + + for entry in entries.flatten() { + let file_name = entry.file_name(); + let Some(file_name) = file_name.to_str() else { + continue; + }; + + if !leaf_prefix.is_empty() && !file_name.starts_with(leaf_prefix) { + continue; + } + + if leaf_prefix.is_empty() && !current.starts_with('.') && file_name.starts_with('.') { + continue; + } + + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(_) => continue, + }; + + if file_type.is_dir() { + if allow_dirs { + values.insert(format!("{display_prefix}{file_name}/")); + } + continue; + } + + if allow_files { + values.insert(format!("{display_prefix}{file_name}")); + } + } + + values.into_iter().collect() +} + +/// Produce completion candidates for the argument value under the cursor. +pub(super) fn complete_arg_values(arg: &Arg, current: &str) -> Vec { + match value_provider(arg) { + ValueProvider::Dynamic(kind) => dynamic_values(kind, current), + ValueProvider::Static(values) => filter_prefix_matches(values, current), + ValueProvider::Path(hint) => path_candidates(current, hint), + ValueProvider::None => vec![], + } +} + +#[cfg(test)] +mod tests { + use std::env; + use std::path::{Path, PathBuf}; + use std::sync::{Mutex, OnceLock}; + + use clap::{Arg, ArgAction, ValueHint}; + use tempfile::TempDir; + + use super::*; + use crate::cli::arg_builders::{competition_arg_optional, problem_arg_optional}; + use crate::config::SETTINGS_FILE_DEFAULT_CONTENTS; + use crate::problem::PROBLEM_MAPPINGS_FILE; + + static CWD_LOCK: OnceLock> = OnceLock::new(); + + fn cwd_lock() -> &'static Mutex<()> { + CWD_LOCK.get_or_init(|| Mutex::new(())) + } + + struct CurrentDirGuard { + previous: PathBuf, + } + + impl CurrentDirGuard { + fn enter(path: &Path) -> Self { + let previous = env::current_dir().expect("current dir should be readable"); + env::set_current_dir(path).expect("current dir should be changeable"); + Self { previous } + } + } + + impl Drop for CurrentDirGuard { + fn drop(&mut self) { + env::set_current_dir(&self.previous).expect("current dir should be restorable"); + } + } + + fn with_test_project(test: impl FnOnce(&Path)) { + let _guard = cwd_lock().lock().expect("cwd lock should not be poisoned"); + let tempdir = TempDir::new().expect("tempdir should be created"); + let project_root = tempdir.path(); + let problems_dir = project_root.join("problems"); + + fs::write( + project_root.join(crate::config::SETTINGS_FILE_NAME), + SETTINGS_FILE_DEFAULT_CONTENTS, + ) + .expect("settings file should be written"); + fs::create_dir_all(&problems_dir).expect("problems dir should be created"); + + let _cwd = CurrentDirGuard::enter(project_root); + test(&problems_dir); + } + + #[test] + fn problem_names_are_prefix_filtered() { + with_test_project(|problems_dir| { + fs::write( + problems_dir.join(PROBLEM_MAPPINGS_FILE), + r#"{"alpha":"problems/new/0800/alpha","beta":"problems/new/0800/beta","alpine":"problems/new/1000/alpine"}"#, + ) + .expect("problem mappings should be written"); + + let values = complete_arg_values(&problem_arg_optional(), "al"); + + assert_eq!(values, vec!["alpha", "alpine"]); + }); + } + + #[test] + fn competition_names_are_prefix_filtered() { + with_test_project(|problems_dir| { + fs::write( + problems_dir.join(COMPETITIONS_FILE), + r#"{"acpc-warmup":{"finished":false,"problems":[]},"beta-round":{"finished":true,"problems":["beta"]},"acpc-finals":{"finished":false,"problems":["alpha"]}}"#, + ) + .expect("competitions file should be written"); + + let values = complete_arg_values(&competition_arg_optional(), "acpc-"); + + assert_eq!(values, vec!["acpc-finals", "acpc-warmup"]); + }); + } + + #[test] + fn static_possible_values_are_prefix_filtered() { + let arg = Arg::new("lang") + .action(ArgAction::Set) + .value_parser(["cpp", "py", "java"]); + + let values = complete_arg_values(&arg, "p"); + + assert_eq!(values, vec!["py"]); + } + + #[test] + fn path_completion_keeps_directory_trailing_slashes() { + let _guard = cwd_lock().lock().expect("cwd lock should not be poisoned"); + let tempdir = TempDir::new().expect("tempdir should be created"); + fs::create_dir(tempdir.path().join("alpha")).expect("dir should be created"); + fs::write(tempdir.path().join("beta.txt"), "x").expect("file should be created"); + let _cwd = CurrentDirGuard::enter(tempdir.path()); + + let arg = Arg::new("path") + .action(ArgAction::Set) + .value_hint(ValueHint::AnyPath); + let values = complete_arg_values(&arg, "a"); + + assert_eq!(values, vec!["alpha/"]); + } +} diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs index 4cb111d..3df8235 100644 --- a/crates/cli/src/cli/mod.rs +++ b/crates/cli/src/cli/mod.rs @@ -1,17 +1,23 @@ -use clap::Command; +use clap::{Arg, ArgAction, Command}; +use crate::{ABOUT, BIN_NAME, NAME, VERSION}; + +pub(crate) mod arg_builders; pub mod cd; pub mod comp; +pub mod complete; pub mod init; pub mod problem; pub mod publish; pub mod shellinit; +mod shellinit_scripts; pub mod sync; pub fn builtin() -> Vec { vec![ cd::cli(), comp::cli(), + complete::cli(), init::cli(), problem::cli(), publish::cli(), @@ -19,3 +25,31 @@ pub fn builtin() -> Vec { sync::cli(), ] } + +/// Build the top-level CLI command tree used for parsing and introspection. +pub fn root() -> Command { + let about_text = format!("{NAME} {VERSION}\n{ABOUT}"); + let after_help_text = + format!("See '{BIN_NAME}' help for more information on a command"); + + let mut root = Command::new(NAME) + .bin_name(BIN_NAME) + .name(NAME) + .version(VERSION) + .about(about_text) + .after_help(after_help_text) + .arg_required_else_help(true) + .subcommands(builtin()) + .subcommand_required(true) + .arg( + Arg::new("verbose") + .short('v') + .long("verbose") + .help("Enable verbose output with detailed error information") + .action(ArgAction::SetTrue) + .global(true), + ); + + root.build(); + root +} diff --git a/crates/cli/src/cli/problem.rs b/crates/cli/src/cli/problem.rs index d7b8944..c91cdbf 100644 --- a/crates/cli/src/cli/problem.rs +++ b/crates/cli/src/cli/problem.rs @@ -1,8 +1,9 @@ use std::fs; use anyhow::{bail, Context, Result}; -use clap::{value_parser, Arg, ArgAction, ArgMatches, Command}; +use clap::{value_parser, Arg, ArgAction, ArgMatches, Command, ValueHint}; +use crate::cli::arg_builders::problem_option_arg_optional; use crate::config::get_settings; use crate::problem::fuzz; use crate::problem::run::{RunnableCategory, RunnableFile}; @@ -15,24 +16,12 @@ pub fn cli() -> Command { .subcommand( Command::new("archive") .about("Archive a problem") - .arg( - Arg::new("problem") - .short('p') - .long("problem") - .help("Problem name (this is not the problem title)") - .action(ArgAction::Set) - ), + .arg(problem_option_arg_optional()), ) .subcommand( Command::new("check") .about("Check that the problem folder and test files are valid") - .arg( - Arg::new("problem") - .short('p') - .long("problem") - .help("Problem name (this is not the problem title)") - .action(ArgAction::Set) - ), + .arg(problem_option_arg_optional()), ) .subcommand( Command::new("compare") @@ -41,12 +30,9 @@ pub fn cli() -> Command { Arg::new("file") .long("file") .help("Name of the solution file") + .value_hint(ValueHint::FilePath) .action(ArgAction::Append), - Arg::new("problem") - .short('p') - .long("problem") - .help("Problem name (this is not the problem title)") - .action(ArgAction::Set), + problem_option_arg_optional(), ]), ) .subcommand( @@ -74,20 +60,18 @@ pub fn cli() -> Command { Arg::new("file") .long("file") .help("Name of the solution file") + .value_hint(ValueHint::FilePath) .action(ArgAction::Append), Arg::new("generator-file") .long("generator-file") .help("Name of the generator file") + .value_hint(ValueHint::FilePath) .action(ArgAction::Set), Arg::new("generator-lang") .long("generator-lang") .help("Language of the generator file (e.g. cpp, py)") .action(ArgAction::Set), - Arg::new("problem") - .short('p') - .long("problem") - .help("Problem name (this is not the problem title)") - .action(ArgAction::Set), + problem_option_arg_optional(), ]), ) .subcommand( @@ -97,19 +81,18 @@ pub fn cli() -> Command { Arg::new("file") .long("file") .help("Name of the generator file") + .value_hint(ValueHint::FilePath) .action(ArgAction::Set), Arg::new("lang") .long("lang") .help("Language of the generator file (e.g. cpp, py)") .action(ArgAction::Set), - Arg::new("problem") - .short('p') - .long("problem") - .help("Problem name (this is not the problem title)") - .action(ArgAction::Set), + problem_option_arg_optional(), Arg::new("test-name") .long("test-name") - .help("Name of the test case (default: \"generated\", which generates \"tests/generated.in\")") + .help( + "Name of the test case (default: \"generated\", which generates \"tests/generated.in\")", + ) .action(ArgAction::Set), ]), ) @@ -124,11 +107,7 @@ pub fn cli() -> Command { .action(ArgAction::Set) .value_parser(value_parser!(u16)) .required(true), - Arg::new("problem") - .short('p') - .long("problem") - .help("Problem name (this is not the problem title)") - .action(ArgAction::Set), + problem_option_arg_optional(), ]), ) .subcommand( @@ -138,16 +117,13 @@ pub fn cli() -> Command { Arg::new("file") .long("file") .help("Name of the solution file") + .value_hint(ValueHint::FilePath) .action(ArgAction::Set), Arg::new("lang") .long("lang") .help("Language of the solution file (e.g. cpp, py)") .action(ArgAction::Set), - Arg::new("problem") - .short('p') - .long("problem") - .help("Problem name (this is not the problem title)") - .action(ArgAction::Set), + problem_option_arg_optional(), ]), ) .subcommand( @@ -157,16 +133,13 @@ pub fn cli() -> Command { Arg::new("file") .long("file") .help("Name of the solution file") + .value_hint(ValueHint::FilePath) .action(ArgAction::Set), Arg::new("lang") .long("lang") .help("Language of the solution file (e.g. cpp, py)") .action(ArgAction::Set), - Arg::new("problem") - .short('p') - .long("problem") - .help("Problem name (this is not the problem title)") - .action(ArgAction::Set) + problem_option_arg_optional(), ]), ) .subcommand_required(true) diff --git a/crates/cli/src/cli/shellinit.rs b/crates/cli/src/cli/shellinit.rs index c352bec..653fa6c 100644 --- a/crates/cli/src/cli/shellinit.rs +++ b/crates/cli/src/cli/shellinit.rs @@ -1,6 +1,8 @@ use anyhow::Result; use clap::{ArgMatches, Command}; +use crate::cli::shellinit_scripts::{BASH, FISH, ZSH}; + pub fn cli() -> Command { Command::new("shellinit") .about("Print shell initialization snippet for aucpl command integration") @@ -9,9 +11,24 @@ pub fn cli() -> Command { pub fn exec(args: &ArgMatches) -> Result<()> { _ = args; - println!( - r#"aucpl() {{ if [ "$1" = "cd" ]; then shift; local target; target="$(command aucpl cd "$@")" || return $?; builtin cd -- "$target"; else command aucpl "$@"; fi; }}"# - ); + let shell = std::env::var("SHELL").unwrap_or_default(); + let is_fish = std::env::var_os("FISH_VERSION").is_some(); + let is_zsh = std::env::var_os("ZSH_VERSION").is_some() || shell.ends_with("/zsh"); + + if is_fish { + let fish_script = FISH; + println!("{}", fish_script); + return Ok(()); + } + + if is_zsh { + let zsh_script = ZSH; + println!("{}", zsh_script); + return Ok(()); + } + + let bash_script = BASH; + println!("{}", bash_script); Ok(()) } diff --git a/crates/cli/src/cli/shellinit_scripts/aucpl.bash b/crates/cli/src/cli/shellinit_scripts/aucpl.bash new file mode 100644 index 0000000..ddcad9c --- /dev/null +++ b/crates/cli/src/cli/shellinit_scripts/aucpl.bash @@ -0,0 +1,19 @@ +aucpl() { + if [ "$1" = "cd" ]; then + shift; + local target; + target="$(command aucpl cd "$@")" || return $?; + builtin cd -- "$target"; + else + command aucpl "$@"; + fi; +}; + +_aucpl_complete_bash() { + COMPREPLY=(); + while IFS= read -r reply; do + COMPREPLY+=("$reply"); + done < <(command aucpl __complete --cword "$COMP_CWORD" -- "${COMP_WORDS[@]}" 2>/dev/null); +}; + +complete -o default -F _aucpl_complete_bash aucpl; diff --git a/crates/cli/src/cli/shellinit_scripts/aucpl.fish b/crates/cli/src/cli/shellinit_scripts/aucpl.fish new file mode 100644 index 0000000..8c89a35 --- /dev/null +++ b/crates/cli/src/cli/shellinit_scripts/aucpl.fish @@ -0,0 +1,18 @@ +function aucpl + if test (count $argv) -gt 0; and test "$argv[1]" = "cd" + set -e argv[1]; + set -l target (command aucpl cd $argv); or return $status; + builtin cd -- $target; + else + command aucpl $argv; + end; +end; + +function __aucpl_complete_fish + set -l tokens (commandline -opc); + set -l current (commandline -ct); + set -l cword (count $tokens); + command aucpl __complete --cword $cword -- $tokens $current 2>/dev/null; +end; + +complete -c aucpl -f -a "(__aucpl_complete_fish)"; diff --git a/crates/cli/src/cli/shellinit_scripts/aucpl.zsh b/crates/cli/src/cli/shellinit_scripts/aucpl.zsh new file mode 100644 index 0000000..600621c --- /dev/null +++ b/crates/cli/src/cli/shellinit_scripts/aucpl.zsh @@ -0,0 +1,18 @@ +aucpl() { + if [ "$1" = "cd" ]; then + shift; + local target; + target="$(command aucpl cd "$@")" || return $?; + builtin cd -- "$target"; + else + command aucpl "$@"; + fi; +}; + +_aucpl_complete_zsh() { + local -a suggestions; + suggestions=("${(@f)$(command aucpl __complete --cword "$((CURRENT-1))" -- "${words[@]}" 2>/dev/null)}"); + compadd -a suggestions; +}; + +compdef _aucpl_complete_zsh aucpl; diff --git a/crates/cli/src/cli/shellinit_scripts/mod.rs b/crates/cli/src/cli/shellinit_scripts/mod.rs new file mode 100644 index 0000000..1fca992 --- /dev/null +++ b/crates/cli/src/cli/shellinit_scripts/mod.rs @@ -0,0 +1,18 @@ +//! Provides compile-time inclusion of the shell init scripts. + +/// Macro to include a script file from `src/cli/shellinit_scripts` at compile time. +/// Usage example: `include_shell!("aucpl.bash")`. +#[macro_export] +macro_rules! include_shell { + ($file:expr) => { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/cli/shellinit_scripts/", + $file + )) + }; +} + +pub const BASH: &str = include_shell!("aucpl.bash"); +pub const FISH: &str = include_shell!("aucpl.fish"); +pub const ZSH: &str = include_shell!("aucpl.zsh"); diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index cad47d3..13681fa 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -2,7 +2,6 @@ use std::process::ExitCode; use std::sync::OnceLock; use anyhow::Result; -use clap::{Arg, ArgAction, Command}; use owo_colors::OwoColorize; mod cli; @@ -65,29 +64,7 @@ fn print_error(err: &anyhow::Error, verbose: bool) { /// Main entry point with proper error handling fn run() -> Result<()> { - let about_text = format!("{NAME} {VERSION}\n{ABOUT}"); - let after_help_text = - format!("See '{BIN_NAME} help ' for more information on a command"); - - let cli = Command::new(NAME) - .bin_name(BIN_NAME) - .name(NAME) - .version(VERSION) - .about(about_text) - .after_help(after_help_text) - .arg_required_else_help(true) - .subcommands(cli::builtin()) - .subcommand_required(true) - .arg( - Arg::new("verbose") - .short('v') - .long("verbose") - .help("Enable verbose output with detailed error information") - .action(ArgAction::SetTrue) - .global(true), - ); - - let matches = cli.get_matches(); + let matches = cli::root().get_matches(); // Set global verbose flag set_verbose(matches.get_flag("verbose")); @@ -95,6 +72,7 @@ fn run() -> Result<()> { match matches.subcommand() { Some(("cd", cmd)) => cli::cd::exec(cmd)?, Some(("comp", cmd)) => cli::comp::exec(cmd)?, + Some(("__complete", cmd)) => cli::complete::exec(cmd)?, Some(("init", cmd)) => cli::init::exec(cmd)?, Some(("problem", cmd)) => cli::problem::exec(cmd)?, Some(("publish", cmd)) => cli::publish::exec(cmd)?,