From c0a29ccd2cd56e75c2b422b3cd9ca65d76a554da Mon Sep 17 00:00:00 2001 From: Weicao-CatilGrass <1992414357@qq.com> Date: Sat, 9 May 2026 13:21:01 +0800 Subject: Move `comp` module from `asset` to crate root --- mingling_core/src/asset.rs | 4 - mingling_core/src/asset/comp.rs | 338 ------------------------------ mingling_core/src/asset/comp/flags.rs | 45 ---- mingling_core/src/asset/comp/shell_ctx.rs | 328 ----------------------------- mingling_core/src/asset/comp/suggest.rs | 178 ---------------- mingling_core/src/comp.rs | 338 ++++++++++++++++++++++++++++++ mingling_core/src/comp/flags.rs | 45 ++++ mingling_core/src/comp/shell_ctx.rs | 328 +++++++++++++++++++++++++++++ mingling_core/src/comp/suggest.rs | 178 ++++++++++++++++ mingling_core/src/lib.rs | 9 +- 10 files changed, 896 insertions(+), 895 deletions(-) delete mode 100644 mingling_core/src/asset/comp.rs delete mode 100644 mingling_core/src/asset/comp/flags.rs delete mode 100644 mingling_core/src/asset/comp/shell_ctx.rs delete mode 100644 mingling_core/src/asset/comp/suggest.rs create mode 100644 mingling_core/src/comp.rs create mode 100644 mingling_core/src/comp/flags.rs create mode 100644 mingling_core/src/comp/shell_ctx.rs create mode 100644 mingling_core/src/comp/suggest.rs diff --git a/mingling_core/src/asset.rs b/mingling_core/src/asset.rs index 1ac37cb..e7945ce 100644 --- a/mingling_core/src/asset.rs +++ b/mingling_core/src/asset.rs @@ -1,10 +1,6 @@ #[doc(hidden)] pub mod chain; -#[cfg(feature = "comp")] -#[doc(hidden)] -pub mod comp; - #[doc(hidden)] pub mod dispatcher; diff --git a/mingling_core/src/asset/comp.rs b/mingling_core/src/asset/comp.rs deleted file mode 100644 index 4fb17c7..0000000 --- a/mingling_core/src/asset/comp.rs +++ /dev/null @@ -1,338 +0,0 @@ -mod flags; -mod shell_ctx; -mod suggest; - -use std::collections::BTreeSet; -use std::fmt::Display; - -#[doc(hidden)] -pub use flags::*; -#[doc(hidden)] -pub use shell_ctx::*; -#[doc(hidden)] -pub use suggest::*; - -use crate::{ProgramCollect, debug, only_debug, this, trace}; - -#[cfg(not(feature = "dispatch_tree"))] -use crate::exec::match_user_input; - -/// Trait for implementing completion logic. -/// -/// This trait defines the interface for generating command-line completions. -/// Types implementing this trait can provide custom completion suggestions -/// based on the current shell context. -pub trait Completion { - type Previous; - fn comp(ctx: &ShellContext) -> Suggest; -} - -/// Trait for extracting user input arguments for completion. -/// -/// When the `feat comp` feature is enabled, the `dispatcher!` macro will -/// automatically implement this trait for `Entry` types to extract the -/// arguments from user input for completion suggestions. -pub trait CompletionEntry { - fn get_input(self) -> Vec; -} - -/// A helper struct for handling command-line completion logic. -/// -/// This struct provides static methods for executing completions based on -/// the current shell context and rendering the resulting suggestions in a -/// format appropriate for the target shell. -pub struct CompletionHelper; -impl CompletionHelper { - pub fn exec_completion

(ctx: &ShellContext) -> Suggest - where - P: ProgramCollect + Display + PartialEq + 'static, - { - only_debug! { - crate::debug::init_env_logger(); - trace_ctx(ctx); - }; - - let args = ctx.all_words.iter().skip(1).cloned().collect::>(); - trace!("arguments=\"{}\"", args.join(", ")); - - #[cfg(not(feature = "dispatch_tree"))] - let program = this::

(); - - #[cfg(not(feature = "dispatch_tree"))] - let suggest = if let Ok((dispatcher, args)) = match_user_input(program, &args) { - trace!( - "dispatcher matched, dispatcher=\"{}\"", - dispatcher.node().to_string(), - ); - let begin = dispatcher.begin(args); - if let crate::ChainProcess::Ok((any, _)) = begin { - trace!("entry type: {}", any.member_id); - let result = P::do_comp(&any, ctx); - trace!("do_comp result: {:?}", result); - Some(result) - } else { - trace!("begin not Ok"); - None - } - } else { - trace!("no dispatcher matched"); - None - }; - #[cfg(feature = "dispatch_tree")] - let suggest = if let Ok(any) = P::dispatch_args_trie(&args) { - trace!("entry type: {}", any.member_id); - - let dispatcher_not_found = >::member_id(); - - if dispatcher_not_found == any.member_id { - trace!("begin not Ok"); - None - } else { - let result = P::do_comp(&any, ctx); - trace!("do_comp result: {:?}", result); - Some(result) - } - } else { - trace!("no dispatcher matched"); - None - }; - - match suggest { - Some(suggest) => { - trace!("using custom completion: {:?}", suggest); - suggest - } - None => { - trace!("using default completion"); - default_completion::

(ctx) - } - } - } - - pub fn render_suggest

(ctx: ShellContext, suggest: Suggest) - where - P: ProgramCollect + Display + 'static, - { - trace!("render_suggest called with: {:?}", suggest); - match suggest { - Suggest::FileCompletion => { - trace!("rendering file completion"); - println!("_file_"); - std::process::exit(0); - } - Suggest::Suggest(suggestions) => { - trace!("rendering {} suggestions", suggestions.len()); - match ctx.shell_flag { - ShellFlag::Zsh | ShellFlag::Powershell => { - trace!("using zsh/pwsh format"); - print_suggest_with_description(suggestions) - } - ShellFlag::Fish => { - trace!("using fish format"); - print_suggest_with_description_fish(suggestions) - } - _ => { - trace!("using default format"); - print_suggest(suggestions) - } - } - } - } - } -} - -fn default_completion

(ctx: &ShellContext) -> Suggest -where - P: ProgramCollect + Display + 'static, -{ - let cmd_nodes: Vec = this::

() - .get_nodes() - .into_iter() - .filter(|(s, _)| s != "__comp") - .map(|(s, _)| s) - .collect(); - debug!("cmd_nodes: {:?}", cmd_nodes); - - // If the current position is less than 1, do not perform completion - if ctx.word_index < 1 { - debug!("word_index < 1, returning file suggestions"); - return file_suggest(); - }; - - // Get the current input path - debug!( - "input_path before filter: {:?}", - &ctx.all_words.get(1..ctx.word_index).unwrap_or(&[]) - ); - - let input_path: Vec<&str> = ctx - .all_words - .get(1..ctx.word_index) - .unwrap_or(&[]) - .iter() - .filter(|s| !s.is_empty()) - .map(|s| s.as_str()) - .collect(); - debug!("input_path after filter: {:?}", input_path); - - debug!( - "default_completion: input_path = {:?}, word_index = {}, all_words = {:?}", - input_path, ctx.word_index, ctx.all_words - ); - - // Filter command nodes that match the input path - let mut suggestions = Vec::new(); - - // Special case: if input_path is empty, return all first-level commands - if input_path.is_empty() { - for node in cmd_nodes { - let node_parts: Vec<&str> = node.split(' ').collect(); - if !node_parts.is_empty() && !suggestions.contains(&node_parts[0].to_string()) { - suggestions.push(node_parts[0].to_string()); - } - } - } else { - // Get the current word - let current_word = input_path.last().unwrap(); - - // First, handle partial match completion for the current word - // Only perform current word completion when current_word is not empty - if input_path.len() == 1 && !ctx.current_word.is_empty() { - for node in &cmd_nodes { - let node_parts: Vec<&str> = node.split(' ').collect(); - if !node_parts.is_empty() - && node_parts[0].starts_with(current_word) - && !suggestions.contains(&node_parts[0].to_string()) - { - suggestions.push(node_parts[0].to_string()); - } - } - - // If suggestions for the current word are found, return directly - if !suggestions.is_empty() { - suggestions.sort(); - suggestions.dedup(); - debug!( - "default_completion: current word suggestions = {:?}", - suggestions - ); - return suggestions.into(); - } - } - - // Handle next-level command suggestions - for node in cmd_nodes { - let node_parts: Vec<&str> = node.split(' ').collect(); - - debug!("Checking node: '{}', parts: {:?}", node, node_parts); - - // If input path is longer than node parts, skip - if input_path.len() > node_parts.len() { - continue; - } - - // Check if input path matches the beginning of node parts - let mut matches = true; - for i in 0..input_path.len() { - if i >= node_parts.len() { - matches = false; - break; - } - - if i == input_path.len() - 1 { - if !node_parts[i].starts_with(input_path[i]) { - matches = false; - break; - } - } else if input_path[i] != node_parts[i] { - matches = false; - break; - } - } - - if matches && input_path.len() <= node_parts.len() { - if input_path.len() == node_parts.len() && !ctx.current_word.is_empty() { - suggestions.push(node_parts[input_path.len() - 1].to_string()); - } else if input_path.len() < node_parts.len() { - suggestions.push(node_parts[input_path.len()].to_string()); - } - } - } - } - - // Remove duplicates and sort - suggestions.sort(); - suggestions.dedup(); - - debug!("default_completion: suggestions = {:?}", suggestions); - - if suggestions.is_empty() { - file_suggest() - } else { - suggestions.into() - } -} - -fn file_suggest() -> Suggest { - trace!("file_suggest called"); - Suggest::FileCompletion -} - -fn print_suggest(suggestions: BTreeSet) { - trace!("print_suggest called with {} items", suggestions.len()); - let mut sorted_suggestions: Vec = suggestions.into_iter().collect(); - sorted_suggestions.sort(); - - for suggest in sorted_suggestions { - println!("{}", suggest.suggest()); - } - std::process::exit(0); -} - -fn print_suggest_with_description(suggestions: BTreeSet) { - trace!( - "print_suggest_with_description called with {} items", - suggestions.len() - ); - let mut sorted_suggestions: Vec = suggestions.into_iter().collect(); - sorted_suggestions.sort(); - - for suggest in sorted_suggestions { - match suggest.description() { - Some(desc) => println!("{}$({})", suggest.suggest(), desc), - None => println!("{}", suggest.suggest()), - } - } - std::process::exit(0); -} - -fn print_suggest_with_description_fish(suggestions: BTreeSet) { - trace!( - "print_suggest_with_description_fish called with {} items", - suggestions.len() - ); - let mut sorted_suggestions: Vec = suggestions.into_iter().collect(); - sorted_suggestions.sort(); - - for suggest in sorted_suggestions { - match suggest.description() { - Some(desc) => println!("{}\t{}", suggest.suggest(), desc), - None => println!("{}", suggest.suggest()), - } - } - std::process::exit(0); -} - -#[cfg(feature = "debug")] -fn trace_ctx(ctx: &ShellContext) { - trace!("=== SHELL CTX BEGIN ==="); - trace!("command_line={}", ctx.command_line); - trace!("cursor_position={}", ctx.cursor_position); - trace!("current_word={}", ctx.current_word); - trace!("previous_word={}", ctx.previous_word); - trace!("command_name={}", ctx.command_name); - trace!("word_index={}", ctx.word_index); - trace!("all_words={:?}", ctx.all_words); - trace!("shell_flag={:?}", ctx.shell_flag); - trace!("=== SHELL CTX END ==="); -} diff --git a/mingling_core/src/asset/comp/flags.rs b/mingling_core/src/asset/comp/flags.rs deleted file mode 100644 index 452126b..0000000 --- a/mingling_core/src/asset/comp/flags.rs +++ /dev/null @@ -1,45 +0,0 @@ -use just_fmt::snake_case; - -/// Represents the shell environment for which the output format is intended. -/// -/// This enum defines the supported shell types that can be used for -/// generating shell-specific command syntax, scripts, or completions. -#[derive(Default, Debug, Clone)] -#[cfg_attr(feature = "general_renderer", derive(serde::Serialize))] -pub enum ShellFlag { - /// Represents the Bash shell. - #[default] - Bash, - /// Represents the Zsh shell. - Zsh, - /// Represents the Fish shell. - Fish, - /// Represents PowerShell. - Powershell, - /// A custom or unsupported shell type, identified by the provided string. - Other(String), -} - -impl From for ShellFlag { - fn from(s: String) -> Self { - match s.trim().to_lowercase().as_str() { - "zsh" => ShellFlag::Zsh, - "bash" => ShellFlag::Bash, - "fish" => ShellFlag::Fish, - "pwsh" | "ps1" | "powershell" => ShellFlag::Powershell, - other => ShellFlag::Other(snake_case!(other)), - } - } -} - -impl From for String { - fn from(flag: ShellFlag) -> Self { - match flag { - ShellFlag::Zsh => "zsh".to_string(), - ShellFlag::Bash => "bash".to_string(), - ShellFlag::Fish => "fish".to_string(), - ShellFlag::Powershell => "powershell".to_string(), - ShellFlag::Other(s) => s, - } - } -} diff --git a/mingling_core/src/asset/comp/shell_ctx.rs b/mingling_core/src/asset/comp/shell_ctx.rs deleted file mode 100644 index dd2aa86..0000000 --- a/mingling_core/src/asset/comp/shell_ctx.rs +++ /dev/null @@ -1,328 +0,0 @@ -use std::collections::HashSet; - -use crate::{Flag, ShellFlag, Suggest}; - -/// Context passed from the shell to the completion system, -/// providing information about the current command line state -/// to guide how completions should be generated. -#[derive(Default, Debug)] -#[cfg_attr(feature = "general_renderer", derive(serde::Serialize))] -pub struct ShellContext { - /// The full command line (-f / --command-line) - pub command_line: String, - - /// Cursor position (-C / --cursor-position) - pub cursor_position: usize, - - /// Current word (-w / --current-word) - pub current_word: String, - - /// Previous word (-p / --previous-word) - pub previous_word: String, - - /// Command name (-c / --command-name) - pub command_name: String, - - /// Word index (-i / --word-index) - pub word_index: usize, - - /// All words (-a / --all-words) - pub all_words: Vec, - - /// Flag to indicate completion context (-F / --shell-flag) - pub shell_flag: ShellFlag, -} - -impl TryFrom> for ShellContext { - type Error = String; - - fn try_from(args: Vec) -> Result { - use std::collections::HashMap; - - // Parse arguments into a map for easy lookup - let mut arg_map = HashMap::new(); - let mut i = 0; - while i < args.len() { - if args[i].starts_with('-') { - let key = args[i].clone(); - if i + 1 < args.len() && !args[i + 1].starts_with('-') { - arg_map.insert(key, args[i + 1].clone()); - i += 2; - } else { - arg_map.insert(key, String::new()); - i += 1; - } - } else { - i += 1; - } - } - - // Extract values with defaults - let command_line = arg_map.get("-f").cloned().unwrap_or_default(); - let cursor_position = arg_map - .get("-C") - .and_then(|s| s.parse().ok()) - .unwrap_or_default(); - let current_word = arg_map.get("-w").cloned().unwrap_or_default(); - let previous_word = arg_map.get("-p").cloned().unwrap_or_default(); - let command_name = arg_map.get("-c").cloned().unwrap_or_default(); - let word_index = arg_map - .get("-i") - .and_then(|s| s.parse().ok()) - .unwrap_or_default(); - let shell_flag = arg_map - .get("-F") - .cloned() - .map(ShellFlag::from) - .unwrap_or(ShellFlag::Other("unknown".to_string())); - - let all_words = command_line - .split_whitespace() - .map(|s| s.replace('^', "-")) - .collect(); - - Ok(ShellContext { - command_line: command_line.replace('^', "-"), - cursor_position, - current_word: current_word.replace('^', "-"), - previous_word: previous_word.replace('^', "-"), - command_name: command_name.replace('^', "-"), - word_index, - all_words, - shell_flag, - }) - } -} - -impl ShellContext { - /// Checks if a flag appears exactly once in the command line arguments. - /// - /// This method is useful for determining whether a flag should be processed - /// when it should only be applied once, even if it appears multiple times - /// in the command line. It returns `true` if the flag is present and - /// appears exactly once among all words in the shell context. - /// - /// # Example - /// - /// ``` - /// # use mingling_core::ShellContext; - /// # use mingling_macros::suggest; - /// # use mingling::comp_tools::ShellContextHelper; - /// - /// let ctx = ShellContext::default(); - /// let helper = ShellContextHelper::from(ctx); - /// - /// // Check if either "--insert" or "-i" appears exactly once - /// if helper.filling_argument_first(["--insert", "-i"]) { - /// // Perform action that should only happen once, example: - /// // return suggest! { - /// // "A", "B", "C" - /// // } - /// } - /// ``` - pub fn filling_argument_first(&self, flag: impl Into) -> bool { - let flag = flag.into(); - if self.filling_argument(&flag) { - let mut flag_appears = 0; - for w in self.all_words.iter() { - for f in flag.iter() { - if *f == w { - flag_appears += 1; - } - } - } - if flag_appears < 2 { - return true; - } - } - return false; - } - - /// Checks if the previous word in the command line arguments matches any of the given flags. - /// - /// This method determines whether a flag is currently being processed - /// by checking the word immediately before the cursor position. It returns - /// `true` if the previous word matches any of the provided flag strings. - /// - /// # Example - /// - /// ``` - /// # use mingling_core::ShellContext; - /// # use mingling_macros::suggest; - /// # use mingling::comp_tools::ShellContextHelper; - /// - /// let ctx = ShellContext::default(); - /// let helper = ShellContextHelper::from(ctx); - /// - /// // Check if the previous word is either "--file" or "-f" - /// if helper.filling_argument(["--file", "-f"]) { - /// // The user is likely expecting a file argument next, example: - /// // return suggest! { - /// // "src/main.rs", "Cargo.toml", "README.md" - /// // } - /// } - /// ``` - pub fn filling_argument(&self, flag: impl Into) -> bool { - for f in flag.into().iter() { - if self.previous_word == **f { - return true; - } - } - return false; - } - - /// Checks if the user is currently typing a flag argument. - /// - /// This method determines whether the current word being typed starts with - /// a dash (`-`), indicating that the user is likely in the process of - /// entering a command-line flag. On Windows, an empty current word is also - /// considered as typing a flag to accommodate shell behavior differences. - /// It returns `true` if the current word begins with a dash character. - /// - /// # Platform-specific behavior - /// - /// - **Windows**: Returns `true` if `current_word` is empty or starts with `-` - /// - **Other platforms**: Returns `true` only if `current_word` starts with `-` - /// - /// # Example - /// - /// ``` - /// # use mingling_core::ShellContext; - /// # use mingling_macros::suggest; - /// # use mingling::comp_tools::ShellContextHelper; - /// - /// let ctx = ShellContext::default(); - /// let helper = ShellContextHelper::from(ctx); - /// - /// // Check if the user is typing a flag - /// if helper.typing_argument() { - /// // The user is likely entering a flag, example: - /// // return suggest! { - /// // "--help", "--version", "--verbose" - /// // } - /// } - /// ``` - pub fn typing_argument(&self) -> bool { - #[cfg(target_os = "windows")] - { - self.current_word.is_empty() - } - #[cfg(not(target_os = "windows"))] - { - self.current_word.starts_with("-") - } - } - - /// Filters out already typed flag arguments from suggestion results. - /// - /// This method removes any suggestions that match flag arguments already present - /// in the command line. It is useful for preventing duplicate flag suggestions - /// when the user has already typed certain flags. The method processes both - /// regular suggestion sets and file completion suggestions differently. - pub fn strip_typed_argument(&self, suggest: Suggest) -> Suggest { - let typed = Self::get_typed_arguments(&self); - match suggest { - Suggest::Suggest(mut set) => { - set.retain(|item| !typed.contains(item.suggest())); - Suggest::Suggest(set) - } - Suggest::FileCompletion => Suggest::FileCompletion, - } - } - - /// Retrieves all flag arguments from the command line. - /// - /// This method collects all words in the shell context that start with a dash (`-`), - /// which typically represent command-line flags or options. It returns a vector - /// containing these flag strings, converted to owned `String` values. - pub fn get_typed_arguments(&self) -> HashSet { - self.all_words - .iter() - .filter(|word| word.starts_with("-")) - .map(|word| word.to_string()) - .collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_try_from_full_args() { - let args = vec![ - "-f".to_string(), - "git commit ^m 'test'".to_string(), - "-C".to_string(), - "12".to_string(), - "-w".to_string(), - "commit".to_string(), - "-p".to_string(), - "git".to_string(), - "-c".to_string(), - "git".to_string(), - "-i".to_string(), - "1".to_string(), - "-F".to_string(), - "bash".to_string(), - ]; - - let context = ShellContext::try_from(args).unwrap(); - assert_eq!(context.command_line, "git commit -m 'test'"); - assert_eq!(context.cursor_position, 12); - assert_eq!(context.current_word, "commit"); - assert_eq!(context.previous_word, "git"); - assert_eq!(context.command_name, "git"); - assert_eq!(context.word_index, 1); - assert_eq!(context.all_words, vec!["git", "commit", "-m", "'test'"]); - assert!(matches!(context.shell_flag, ShellFlag::Bash)); - } - - #[test] - fn test_try_from_partial_args() { - let args = vec![ - "-f".to_string(), - "ls ^la".to_string(), - "-C".to_string(), - "5".to_string(), - ]; - - let context = ShellContext::try_from(args).unwrap(); - assert_eq!(context.command_line, "ls -la"); - assert_eq!(context.cursor_position, 5); - assert_eq!(context.current_word, ""); - assert_eq!(context.previous_word, ""); - assert_eq!(context.command_name, ""); - assert_eq!(context.word_index, 0); - assert_eq!(context.all_words, vec!["ls", "-la"]); - assert!(matches!(context.shell_flag, ShellFlag::Other(ref s) if s == "unknown")); - } - - #[test] - fn test_try_from_empty_args() { - let args = vec![]; - let context = ShellContext::try_from(args).unwrap(); - assert_eq!(context.command_line, ""); - assert_eq!(context.cursor_position, 0); - assert_eq!(context.current_word, ""); - assert_eq!(context.previous_word, ""); - assert_eq!(context.command_name, ""); - assert_eq!(context.word_index, 0); - assert!(context.all_words.is_empty()); - assert!(matches!(context.shell_flag, ShellFlag::Other(ref s) if s == "unknown")); - } - - #[test] - fn test_try_from_flag_without_value() { - let args = vec!["-F".to_string()]; - let context = ShellContext::try_from(args).unwrap(); - assert!(matches!(context.shell_flag, ShellFlag::Other(ref s) if s == "")); - } - - #[test] - fn test_all_words_splitting() { - let args = vec!["-f".to_string(), " cmd arg1 arg2 ".to_string()]; - let context = ShellContext::try_from(args).unwrap(); - assert_eq!(context.all_words, vec!["cmd", "arg1", "arg2"]); - } -} diff --git a/mingling_core/src/asset/comp/suggest.rs b/mingling_core/src/asset/comp/suggest.rs deleted file mode 100644 index 6d64341..0000000 --- a/mingling_core/src/asset/comp/suggest.rs +++ /dev/null @@ -1,178 +0,0 @@ -use std::collections::BTreeSet; - -use crate::ShellContext; - -/// A completion suggestion that tells the shell how to perform completion. -/// This can be either a set of specific suggestion items or a request for file completion. -#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "general_renderer", derive(serde::Serialize))] -pub enum Suggest { - /// A set of specific suggestion items for the shell to display. - Suggest(BTreeSet), - - /// A request for the shell to perform file‑path completion. - #[default] - FileCompletion, -} - -impl Suggest { - /// Creates a new Suggest variant containing a BTreeSet of suggestions. - pub fn new() -> Self { - Self::Suggest(BTreeSet::new()) - } - - /// Creates a FileCompletion variant. - pub fn file_comp() -> Self { - Self::FileCompletion - } - - /// Filters out already typed flag arguments from suggestion results. - pub fn strip_typed_argument(self, ctx: &ShellContext) -> Self { - ctx.strip_typed_argument(self) - } -} - -impl From for Suggest -where - T: IntoIterator, - T::Item: Into, -{ - fn from(items: T) -> Self { - let suggests = items - .into_iter() - .map(|item| SuggestItem::new(item.into())) - .collect(); - Suggest::Suggest(suggests) - } -} - -impl std::ops::Deref for Suggest { - type Target = BTreeSet; - - fn deref(&self) -> &Self::Target { - match self { - Self::Suggest(suggests) => suggests, - Self::FileCompletion => panic!("Cannot deref FileCompletion variant"), - } - } -} - -impl std::ops::DerefMut for Suggest { - fn deref_mut(&mut self) -> &mut Self::Target { - match self { - Self::Suggest(suggests) => suggests, - Self::FileCompletion => panic!("Cannot deref_mut FileCompletion variant"), - } - } -} - -/// Represents a single suggestion item for shell completion. -/// -/// This enum has two variants: -/// - `Simple(String)`: A suggestion without any description. -/// - `WithDescription(String, String)`: A suggestion with an associated description. -/// -/// The first `String` always holds the suggestion text, and the second `String` (if present) -/// holds an optional description providing additional context. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "general_renderer", derive(serde::Serialize))] -pub enum SuggestItem { - /// A simple suggestion with only the suggestion text. - Simple(String), - /// A suggestion with both text and a description. - WithDescription(String, String), -} - -impl Default for SuggestItem { - fn default() -> Self { - SuggestItem::Simple(String::new()) - } -} - -impl PartialOrd for SuggestItem { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for SuggestItem { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.suggest().cmp(other.suggest()) - } -} - -impl SuggestItem { - /// Creates a new simple suggestion without description. - pub fn new(suggest: String) -> Self { - Self::Simple(suggest) - } - - /// Creates a new suggestion with a description. - pub fn new_with_desc(suggest: String, description: String) -> Self { - Self::WithDescription(suggest, description) - } - - /// Adds a description to this suggestion, replacing any existing description. - pub fn with_desc(self, description: String) -> Self { - match self { - Self::Simple(suggest) => Self::WithDescription(suggest, description), - Self::WithDescription(suggest, _) => Self::WithDescription(suggest, description), - } - } - - /// Returns the suggestion text. - pub fn suggest(&self) -> &String { - match self { - Self::Simple(suggest) => suggest, - Self::WithDescription(suggest, _) => suggest, - } - } - - /// Updates the suggestion text. - pub fn set_suggest(&mut self, new_suggest: String) { - match self { - Self::Simple(suggest) => *suggest = new_suggest, - Self::WithDescription(suggest, _) => *suggest = new_suggest, - } - } - - /// Returns the description if present. - pub fn description(&self) -> Option<&String> { - match self { - Self::Simple(_) => None, - Self::WithDescription(_, description) => Some(description), - } - } - - /// Sets or replaces the description. - pub fn set_description(&mut self, description: String) { - match self { - Self::Simple(suggest) => *self = Self::WithDescription(suggest.clone(), description), - Self::WithDescription(_, desc) => *desc = description, - } - } - - /// Removes and returns the description if present. - pub fn remove_desc(&mut self) -> Option { - match self { - Self::Simple(_) => None, - Self::WithDescription(suggest, description) => { - let desc = std::mem::take(description); - *self = Self::Simple(std::mem::take(suggest)); - Some(desc) - } - } - } -} - -impl From for SuggestItem { - fn from(suggest: String) -> Self { - Self::new(suggest) - } -} - -impl From<(String, String)> for SuggestItem { - fn from((suggest, description): (String, String)) -> Self { - Self::new_with_desc(suggest, description) - } -} diff --git a/mingling_core/src/comp.rs b/mingling_core/src/comp.rs new file mode 100644 index 0000000..4fb17c7 --- /dev/null +++ b/mingling_core/src/comp.rs @@ -0,0 +1,338 @@ +mod flags; +mod shell_ctx; +mod suggest; + +use std::collections::BTreeSet; +use std::fmt::Display; + +#[doc(hidden)] +pub use flags::*; +#[doc(hidden)] +pub use shell_ctx::*; +#[doc(hidden)] +pub use suggest::*; + +use crate::{ProgramCollect, debug, only_debug, this, trace}; + +#[cfg(not(feature = "dispatch_tree"))] +use crate::exec::match_user_input; + +/// Trait for implementing completion logic. +/// +/// This trait defines the interface for generating command-line completions. +/// Types implementing this trait can provide custom completion suggestions +/// based on the current shell context. +pub trait Completion { + type Previous; + fn comp(ctx: &ShellContext) -> Suggest; +} + +/// Trait for extracting user input arguments for completion. +/// +/// When the `feat comp` feature is enabled, the `dispatcher!` macro will +/// automatically implement this trait for `Entry` types to extract the +/// arguments from user input for completion suggestions. +pub trait CompletionEntry { + fn get_input(self) -> Vec; +} + +/// A helper struct for handling command-line completion logic. +/// +/// This struct provides static methods for executing completions based on +/// the current shell context and rendering the resulting suggestions in a +/// format appropriate for the target shell. +pub struct CompletionHelper; +impl CompletionHelper { + pub fn exec_completion

(ctx: &ShellContext) -> Suggest + where + P: ProgramCollect + Display + PartialEq + 'static, + { + only_debug! { + crate::debug::init_env_logger(); + trace_ctx(ctx); + }; + + let args = ctx.all_words.iter().skip(1).cloned().collect::>(); + trace!("arguments=\"{}\"", args.join(", ")); + + #[cfg(not(feature = "dispatch_tree"))] + let program = this::

(); + + #[cfg(not(feature = "dispatch_tree"))] + let suggest = if let Ok((dispatcher, args)) = match_user_input(program, &args) { + trace!( + "dispatcher matched, dispatcher=\"{}\"", + dispatcher.node().to_string(), + ); + let begin = dispatcher.begin(args); + if let crate::ChainProcess::Ok((any, _)) = begin { + trace!("entry type: {}", any.member_id); + let result = P::do_comp(&any, ctx); + trace!("do_comp result: {:?}", result); + Some(result) + } else { + trace!("begin not Ok"); + None + } + } else { + trace!("no dispatcher matched"); + None + }; + #[cfg(feature = "dispatch_tree")] + let suggest = if let Ok(any) = P::dispatch_args_trie(&args) { + trace!("entry type: {}", any.member_id); + + let dispatcher_not_found = >::member_id(); + + if dispatcher_not_found == any.member_id { + trace!("begin not Ok"); + None + } else { + let result = P::do_comp(&any, ctx); + trace!("do_comp result: {:?}", result); + Some(result) + } + } else { + trace!("no dispatcher matched"); + None + }; + + match suggest { + Some(suggest) => { + trace!("using custom completion: {:?}", suggest); + suggest + } + None => { + trace!("using default completion"); + default_completion::

(ctx) + } + } + } + + pub fn render_suggest

(ctx: ShellContext, suggest: Suggest) + where + P: ProgramCollect + Display + 'static, + { + trace!("render_suggest called with: {:?}", suggest); + match suggest { + Suggest::FileCompletion => { + trace!("rendering file completion"); + println!("_file_"); + std::process::exit(0); + } + Suggest::Suggest(suggestions) => { + trace!("rendering {} suggestions", suggestions.len()); + match ctx.shell_flag { + ShellFlag::Zsh | ShellFlag::Powershell => { + trace!("using zsh/pwsh format"); + print_suggest_with_description(suggestions) + } + ShellFlag::Fish => { + trace!("using fish format"); + print_suggest_with_description_fish(suggestions) + } + _ => { + trace!("using default format"); + print_suggest(suggestions) + } + } + } + } + } +} + +fn default_completion

(ctx: &ShellContext) -> Suggest +where + P: ProgramCollect + Display + 'static, +{ + let cmd_nodes: Vec = this::

() + .get_nodes() + .into_iter() + .filter(|(s, _)| s != "__comp") + .map(|(s, _)| s) + .collect(); + debug!("cmd_nodes: {:?}", cmd_nodes); + + // If the current position is less than 1, do not perform completion + if ctx.word_index < 1 { + debug!("word_index < 1, returning file suggestions"); + return file_suggest(); + }; + + // Get the current input path + debug!( + "input_path before filter: {:?}", + &ctx.all_words.get(1..ctx.word_index).unwrap_or(&[]) + ); + + let input_path: Vec<&str> = ctx + .all_words + .get(1..ctx.word_index) + .unwrap_or(&[]) + .iter() + .filter(|s| !s.is_empty()) + .map(|s| s.as_str()) + .collect(); + debug!("input_path after filter: {:?}", input_path); + + debug!( + "default_completion: input_path = {:?}, word_index = {}, all_words = {:?}", + input_path, ctx.word_index, ctx.all_words + ); + + // Filter command nodes that match the input path + let mut suggestions = Vec::new(); + + // Special case: if input_path is empty, return all first-level commands + if input_path.is_empty() { + for node in cmd_nodes { + let node_parts: Vec<&str> = node.split(' ').collect(); + if !node_parts.is_empty() && !suggestions.contains(&node_parts[0].to_string()) { + suggestions.push(node_parts[0].to_string()); + } + } + } else { + // Get the current word + let current_word = input_path.last().unwrap(); + + // First, handle partial match completion for the current word + // Only perform current word completion when current_word is not empty + if input_path.len() == 1 && !ctx.current_word.is_empty() { + for node in &cmd_nodes { + let node_parts: Vec<&str> = node.split(' ').collect(); + if !node_parts.is_empty() + && node_parts[0].starts_with(current_word) + && !suggestions.contains(&node_parts[0].to_string()) + { + suggestions.push(node_parts[0].to_string()); + } + } + + // If suggestions for the current word are found, return directly + if !suggestions.is_empty() { + suggestions.sort(); + suggestions.dedup(); + debug!( + "default_completion: current word suggestions = {:?}", + suggestions + ); + return suggestions.into(); + } + } + + // Handle next-level command suggestions + for node in cmd_nodes { + let node_parts: Vec<&str> = node.split(' ').collect(); + + debug!("Checking node: '{}', parts: {:?}", node, node_parts); + + // If input path is longer than node parts, skip + if input_path.len() > node_parts.len() { + continue; + } + + // Check if input path matches the beginning of node parts + let mut matches = true; + for i in 0..input_path.len() { + if i >= node_parts.len() { + matches = false; + break; + } + + if i == input_path.len() - 1 { + if !node_parts[i].starts_with(input_path[i]) { + matches = false; + break; + } + } else if input_path[i] != node_parts[i] { + matches = false; + break; + } + } + + if matches && input_path.len() <= node_parts.len() { + if input_path.len() == node_parts.len() && !ctx.current_word.is_empty() { + suggestions.push(node_parts[input_path.len() - 1].to_string()); + } else if input_path.len() < node_parts.len() { + suggestions.push(node_parts[input_path.len()].to_string()); + } + } + } + } + + // Remove duplicates and sort + suggestions.sort(); + suggestions.dedup(); + + debug!("default_completion: suggestions = {:?}", suggestions); + + if suggestions.is_empty() { + file_suggest() + } else { + suggestions.into() + } +} + +fn file_suggest() -> Suggest { + trace!("file_suggest called"); + Suggest::FileCompletion +} + +fn print_suggest(suggestions: BTreeSet) { + trace!("print_suggest called with {} items", suggestions.len()); + let mut sorted_suggestions: Vec = suggestions.into_iter().collect(); + sorted_suggestions.sort(); + + for suggest in sorted_suggestions { + println!("{}", suggest.suggest()); + } + std::process::exit(0); +} + +fn print_suggest_with_description(suggestions: BTreeSet) { + trace!( + "print_suggest_with_description called with {} items", + suggestions.len() + ); + let mut sorted_suggestions: Vec = suggestions.into_iter().collect(); + sorted_suggestions.sort(); + + for suggest in sorted_suggestions { + match suggest.description() { + Some(desc) => println!("{}$({})", suggest.suggest(), desc), + None => println!("{}", suggest.suggest()), + } + } + std::process::exit(0); +} + +fn print_suggest_with_description_fish(suggestions: BTreeSet) { + trace!( + "print_suggest_with_description_fish called with {} items", + suggestions.len() + ); + let mut sorted_suggestions: Vec = suggestions.into_iter().collect(); + sorted_suggestions.sort(); + + for suggest in sorted_suggestions { + match suggest.description() { + Some(desc) => println!("{}\t{}", suggest.suggest(), desc), + None => println!("{}", suggest.suggest()), + } + } + std::process::exit(0); +} + +#[cfg(feature = "debug")] +fn trace_ctx(ctx: &ShellContext) { + trace!("=== SHELL CTX BEGIN ==="); + trace!("command_line={}", ctx.command_line); + trace!("cursor_position={}", ctx.cursor_position); + trace!("current_word={}", ctx.current_word); + trace!("previous_word={}", ctx.previous_word); + trace!("command_name={}", ctx.command_name); + trace!("word_index={}", ctx.word_index); + trace!("all_words={:?}", ctx.all_words); + trace!("shell_flag={:?}", ctx.shell_flag); + trace!("=== SHELL CTX END ==="); +} diff --git a/mingling_core/src/comp/flags.rs b/mingling_core/src/comp/flags.rs new file mode 100644 index 0000000..452126b --- /dev/null +++ b/mingling_core/src/comp/flags.rs @@ -0,0 +1,45 @@ +use just_fmt::snake_case; + +/// Represents the shell environment for which the output format is intended. +/// +/// This enum defines the supported shell types that can be used for +/// generating shell-specific command syntax, scripts, or completions. +#[derive(Default, Debug, Clone)] +#[cfg_attr(feature = "general_renderer", derive(serde::Serialize))] +pub enum ShellFlag { + /// Represents the Bash shell. + #[default] + Bash, + /// Represents the Zsh shell. + Zsh, + /// Represents the Fish shell. + Fish, + /// Represents PowerShell. + Powershell, + /// A custom or unsupported shell type, identified by the provided string. + Other(String), +} + +impl From for ShellFlag { + fn from(s: String) -> Self { + match s.trim().to_lowercase().as_str() { + "zsh" => ShellFlag::Zsh, + "bash" => ShellFlag::Bash, + "fish" => ShellFlag::Fish, + "pwsh" | "ps1" | "powershell" => ShellFlag::Powershell, + other => ShellFlag::Other(snake_case!(other)), + } + } +} + +impl From for String { + fn from(flag: ShellFlag) -> Self { + match flag { + ShellFlag::Zsh => "zsh".to_string(), + ShellFlag::Bash => "bash".to_string(), + ShellFlag::Fish => "fish".to_string(), + ShellFlag::Powershell => "powershell".to_string(), + ShellFlag::Other(s) => s, + } + } +} diff --git a/mingling_core/src/comp/shell_ctx.rs b/mingling_core/src/comp/shell_ctx.rs new file mode 100644 index 0000000..dd2aa86 --- /dev/null +++ b/mingling_core/src/comp/shell_ctx.rs @@ -0,0 +1,328 @@ +use std::collections::HashSet; + +use crate::{Flag, ShellFlag, Suggest}; + +/// Context passed from the shell to the completion system, +/// providing information about the current command line state +/// to guide how completions should be generated. +#[derive(Default, Debug)] +#[cfg_attr(feature = "general_renderer", derive(serde::Serialize))] +pub struct ShellContext { + /// The full command line (-f / --command-line) + pub command_line: String, + + /// Cursor position (-C / --cursor-position) + pub cursor_position: usize, + + /// Current word (-w / --current-word) + pub current_word: String, + + /// Previous word (-p / --previous-word) + pub previous_word: String, + + /// Command name (-c / --command-name) + pub command_name: String, + + /// Word index (-i / --word-index) + pub word_index: usize, + + /// All words (-a / --all-words) + pub all_words: Vec, + + /// Flag to indicate completion context (-F / --shell-flag) + pub shell_flag: ShellFlag, +} + +impl TryFrom> for ShellContext { + type Error = String; + + fn try_from(args: Vec) -> Result { + use std::collections::HashMap; + + // Parse arguments into a map for easy lookup + let mut arg_map = HashMap::new(); + let mut i = 0; + while i < args.len() { + if args[i].starts_with('-') { + let key = args[i].clone(); + if i + 1 < args.len() && !args[i + 1].starts_with('-') { + arg_map.insert(key, args[i + 1].clone()); + i += 2; + } else { + arg_map.insert(key, String::new()); + i += 1; + } + } else { + i += 1; + } + } + + // Extract values with defaults + let command_line = arg_map.get("-f").cloned().unwrap_or_default(); + let cursor_position = arg_map + .get("-C") + .and_then(|s| s.parse().ok()) + .unwrap_or_default(); + let current_word = arg_map.get("-w").cloned().unwrap_or_default(); + let previous_word = arg_map.get("-p").cloned().unwrap_or_default(); + let command_name = arg_map.get("-c").cloned().unwrap_or_default(); + let word_index = arg_map + .get("-i") + .and_then(|s| s.parse().ok()) + .unwrap_or_default(); + let shell_flag = arg_map + .get("-F") + .cloned() + .map(ShellFlag::from) + .unwrap_or(ShellFlag::Other("unknown".to_string())); + + let all_words = command_line + .split_whitespace() + .map(|s| s.replace('^', "-")) + .collect(); + + Ok(ShellContext { + command_line: command_line.replace('^', "-"), + cursor_position, + current_word: current_word.replace('^', "-"), + previous_word: previous_word.replace('^', "-"), + command_name: command_name.replace('^', "-"), + word_index, + all_words, + shell_flag, + }) + } +} + +impl ShellContext { + /// Checks if a flag appears exactly once in the command line arguments. + /// + /// This method is useful for determining whether a flag should be processed + /// when it should only be applied once, even if it appears multiple times + /// in the command line. It returns `true` if the flag is present and + /// appears exactly once among all words in the shell context. + /// + /// # Example + /// + /// ``` + /// # use mingling_core::ShellContext; + /// # use mingling_macros::suggest; + /// # use mingling::comp_tools::ShellContextHelper; + /// + /// let ctx = ShellContext::default(); + /// let helper = ShellContextHelper::from(ctx); + /// + /// // Check if either "--insert" or "-i" appears exactly once + /// if helper.filling_argument_first(["--insert", "-i"]) { + /// // Perform action that should only happen once, example: + /// // return suggest! { + /// // "A", "B", "C" + /// // } + /// } + /// ``` + pub fn filling_argument_first(&self, flag: impl Into) -> bool { + let flag = flag.into(); + if self.filling_argument(&flag) { + let mut flag_appears = 0; + for w in self.all_words.iter() { + for f in flag.iter() { + if *f == w { + flag_appears += 1; + } + } + } + if flag_appears < 2 { + return true; + } + } + return false; + } + + /// Checks if the previous word in the command line arguments matches any of the given flags. + /// + /// This method determines whether a flag is currently being processed + /// by checking the word immediately before the cursor position. It returns + /// `true` if the previous word matches any of the provided flag strings. + /// + /// # Example + /// + /// ``` + /// # use mingling_core::ShellContext; + /// # use mingling_macros::suggest; + /// # use mingling::comp_tools::ShellContextHelper; + /// + /// let ctx = ShellContext::default(); + /// let helper = ShellContextHelper::from(ctx); + /// + /// // Check if the previous word is either "--file" or "-f" + /// if helper.filling_argument(["--file", "-f"]) { + /// // The user is likely expecting a file argument next, example: + /// // return suggest! { + /// // "src/main.rs", "Cargo.toml", "README.md" + /// // } + /// } + /// ``` + pub fn filling_argument(&self, flag: impl Into) -> bool { + for f in flag.into().iter() { + if self.previous_word == **f { + return true; + } + } + return false; + } + + /// Checks if the user is currently typing a flag argument. + /// + /// This method determines whether the current word being typed starts with + /// a dash (`-`), indicating that the user is likely in the process of + /// entering a command-line flag. On Windows, an empty current word is also + /// considered as typing a flag to accommodate shell behavior differences. + /// It returns `true` if the current word begins with a dash character. + /// + /// # Platform-specific behavior + /// + /// - **Windows**: Returns `true` if `current_word` is empty or starts with `-` + /// - **Other platforms**: Returns `true` only if `current_word` starts with `-` + /// + /// # Example + /// + /// ``` + /// # use mingling_core::ShellContext; + /// # use mingling_macros::suggest; + /// # use mingling::comp_tools::ShellContextHelper; + /// + /// let ctx = ShellContext::default(); + /// let helper = ShellContextHelper::from(ctx); + /// + /// // Check if the user is typing a flag + /// if helper.typing_argument() { + /// // The user is likely entering a flag, example: + /// // return suggest! { + /// // "--help", "--version", "--verbose" + /// // } + /// } + /// ``` + pub fn typing_argument(&self) -> bool { + #[cfg(target_os = "windows")] + { + self.current_word.is_empty() + } + #[cfg(not(target_os = "windows"))] + { + self.current_word.starts_with("-") + } + } + + /// Filters out already typed flag arguments from suggestion results. + /// + /// This method removes any suggestions that match flag arguments already present + /// in the command line. It is useful for preventing duplicate flag suggestions + /// when the user has already typed certain flags. The method processes both + /// regular suggestion sets and file completion suggestions differently. + pub fn strip_typed_argument(&self, suggest: Suggest) -> Suggest { + let typed = Self::get_typed_arguments(&self); + match suggest { + Suggest::Suggest(mut set) => { + set.retain(|item| !typed.contains(item.suggest())); + Suggest::Suggest(set) + } + Suggest::FileCompletion => Suggest::FileCompletion, + } + } + + /// Retrieves all flag arguments from the command line. + /// + /// This method collects all words in the shell context that start with a dash (`-`), + /// which typically represent command-line flags or options. It returns a vector + /// containing these flag strings, converted to owned `String` values. + pub fn get_typed_arguments(&self) -> HashSet { + self.all_words + .iter() + .filter(|word| word.starts_with("-")) + .map(|word| word.to_string()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_try_from_full_args() { + let args = vec![ + "-f".to_string(), + "git commit ^m 'test'".to_string(), + "-C".to_string(), + "12".to_string(), + "-w".to_string(), + "commit".to_string(), + "-p".to_string(), + "git".to_string(), + "-c".to_string(), + "git".to_string(), + "-i".to_string(), + "1".to_string(), + "-F".to_string(), + "bash".to_string(), + ]; + + let context = ShellContext::try_from(args).unwrap(); + assert_eq!(context.command_line, "git commit -m 'test'"); + assert_eq!(context.cursor_position, 12); + assert_eq!(context.current_word, "commit"); + assert_eq!(context.previous_word, "git"); + assert_eq!(context.command_name, "git"); + assert_eq!(context.word_index, 1); + assert_eq!(context.all_words, vec!["git", "commit", "-m", "'test'"]); + assert!(matches!(context.shell_flag, ShellFlag::Bash)); + } + + #[test] + fn test_try_from_partial_args() { + let args = vec![ + "-f".to_string(), + "ls ^la".to_string(), + "-C".to_string(), + "5".to_string(), + ]; + + let context = ShellContext::try_from(args).unwrap(); + assert_eq!(context.command_line, "ls -la"); + assert_eq!(context.cursor_position, 5); + assert_eq!(context.current_word, ""); + assert_eq!(context.previous_word, ""); + assert_eq!(context.command_name, ""); + assert_eq!(context.word_index, 0); + assert_eq!(context.all_words, vec!["ls", "-la"]); + assert!(matches!(context.shell_flag, ShellFlag::Other(ref s) if s == "unknown")); + } + + #[test] + fn test_try_from_empty_args() { + let args = vec![]; + let context = ShellContext::try_from(args).unwrap(); + assert_eq!(context.command_line, ""); + assert_eq!(context.cursor_position, 0); + assert_eq!(context.current_word, ""); + assert_eq!(context.previous_word, ""); + assert_eq!(context.command_name, ""); + assert_eq!(context.word_index, 0); + assert!(context.all_words.is_empty()); + assert!(matches!(context.shell_flag, ShellFlag::Other(ref s) if s == "unknown")); + } + + #[test] + fn test_try_from_flag_without_value() { + let args = vec!["-F".to_string()]; + let context = ShellContext::try_from(args).unwrap(); + assert!(matches!(context.shell_flag, ShellFlag::Other(ref s) if s == "")); + } + + #[test] + fn test_all_words_splitting() { + let args = vec!["-f".to_string(), " cmd arg1 arg2 ".to_string()]; + let context = ShellContext::try_from(args).unwrap(); + assert_eq!(context.all_words, vec!["cmd", "arg1", "arg2"]); + } +} diff --git a/mingling_core/src/comp/suggest.rs b/mingling_core/src/comp/suggest.rs new file mode 100644 index 0000000..6d64341 --- /dev/null +++ b/mingling_core/src/comp/suggest.rs @@ -0,0 +1,178 @@ +use std::collections::BTreeSet; + +use crate::ShellContext; + +/// A completion suggestion that tells the shell how to perform completion. +/// This can be either a set of specific suggestion items or a request for file completion. +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "general_renderer", derive(serde::Serialize))] +pub enum Suggest { + /// A set of specific suggestion items for the shell to display. + Suggest(BTreeSet), + + /// A request for the shell to perform file‑path completion. + #[default] + FileCompletion, +} + +impl Suggest { + /// Creates a new Suggest variant containing a BTreeSet of suggestions. + pub fn new() -> Self { + Self::Suggest(BTreeSet::new()) + } + + /// Creates a FileCompletion variant. + pub fn file_comp() -> Self { + Self::FileCompletion + } + + /// Filters out already typed flag arguments from suggestion results. + pub fn strip_typed_argument(self, ctx: &ShellContext) -> Self { + ctx.strip_typed_argument(self) + } +} + +impl From for Suggest +where + T: IntoIterator, + T::Item: Into, +{ + fn from(items: T) -> Self { + let suggests = items + .into_iter() + .map(|item| SuggestItem::new(item.into())) + .collect(); + Suggest::Suggest(suggests) + } +} + +impl std::ops::Deref for Suggest { + type Target = BTreeSet; + + fn deref(&self) -> &Self::Target { + match self { + Self::Suggest(suggests) => suggests, + Self::FileCompletion => panic!("Cannot deref FileCompletion variant"), + } + } +} + +impl std::ops::DerefMut for Suggest { + fn deref_mut(&mut self) -> &mut Self::Target { + match self { + Self::Suggest(suggests) => suggests, + Self::FileCompletion => panic!("Cannot deref_mut FileCompletion variant"), + } + } +} + +/// Represents a single suggestion item for shell completion. +/// +/// This enum has two variants: +/// - `Simple(String)`: A suggestion without any description. +/// - `WithDescription(String, String)`: A suggestion with an associated description. +/// +/// The first `String` always holds the suggestion text, and the second `String` (if present) +/// holds an optional description providing additional context. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "general_renderer", derive(serde::Serialize))] +pub enum SuggestItem { + /// A simple suggestion with only the suggestion text. + Simple(String), + /// A suggestion with both text and a description. + WithDescription(String, String), +} + +impl Default for SuggestItem { + fn default() -> Self { + SuggestItem::Simple(String::new()) + } +} + +impl PartialOrd for SuggestItem { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for SuggestItem { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.suggest().cmp(other.suggest()) + } +} + +impl SuggestItem { + /// Creates a new simple suggestion without description. + pub fn new(suggest: String) -> Self { + Self::Simple(suggest) + } + + /// Creates a new suggestion with a description. + pub fn new_with_desc(suggest: String, description: String) -> Self { + Self::WithDescription(suggest, description) + } + + /// Adds a description to this suggestion, replacing any existing description. + pub fn with_desc(self, description: String) -> Self { + match self { + Self::Simple(suggest) => Self::WithDescription(suggest, description), + Self::WithDescription(suggest, _) => Self::WithDescription(suggest, description), + } + } + + /// Returns the suggestion text. + pub fn suggest(&self) -> &String { + match self { + Self::Simple(suggest) => suggest, + Self::WithDescription(suggest, _) => suggest, + } + } + + /// Updates the suggestion text. + pub fn set_suggest(&mut self, new_suggest: String) { + match self { + Self::Simple(suggest) => *suggest = new_suggest, + Self::WithDescription(suggest, _) => *suggest = new_suggest, + } + } + + /// Returns the description if present. + pub fn description(&self) -> Option<&String> { + match self { + Self::Simple(_) => None, + Self::WithDescription(_, description) => Some(description), + } + } + + /// Sets or replaces the description. + pub fn set_description(&mut self, description: String) { + match self { + Self::Simple(suggest) => *self = Self::WithDescription(suggest.clone(), description), + Self::WithDescription(_, desc) => *desc = description, + } + } + + /// Removes and returns the description if present. + pub fn remove_desc(&mut self) -> Option { + match self { + Self::Simple(_) => None, + Self::WithDescription(suggest, description) => { + let desc = std::mem::take(description); + *self = Self::Simple(std::mem::take(suggest)); + Some(desc) + } + } + } +} + +impl From for SuggestItem { + fn from(suggest: String) -> Self { + Self::new(suggest) + } +} + +impl From<(String, String)> for SuggestItem { + fn from((suggest, description): (String, String)) -> Self { + Self::new_with_desc(suggest, description) + } +} diff --git a/mingling_core/src/lib.rs b/mingling_core/src/lib.rs index c141a65..f4f7d18 100644 --- a/mingling_core/src/lib.rs +++ b/mingling_core/src/lib.rs @@ -27,8 +27,6 @@ pub use crate::any::group::*; pub use crate::any::*; pub use crate::asset::chain::*; -#[cfg(feature = "comp")] -pub use crate::asset::comp::*; pub use crate::asset::dispatcher::*; pub use crate::asset::enum_tag::*; pub use crate::asset::global_resource::*; @@ -64,3 +62,10 @@ pub mod build { /// Provided for framework developers pub mod debug; + +#[cfg(feature = "comp")] +#[doc(hidden)] +pub mod comp; + +#[cfg(feature = "comp")] +pub use crate::comp::*; -- cgit