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/comp/shell_ctx.rs | 328 ++++++++++++++++++++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 mingling_core/src/comp/shell_ctx.rs (limited to 'mingling_core/src/comp/shell_ctx.rs') 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"]); + } +} -- cgit