aboutsummaryrefslogtreecommitdiff
path: root/mingling_core
diff options
context:
space:
mode:
author魏曹先生 <1992414357@qq.com>2026-04-09 15:12:11 +0800
committer魏曹先生 <1992414357@qq.com>2026-04-09 15:12:11 +0800
commit4764c3c818e3da16a3cba3b9877d9beb635e4237 (patch)
tree3adc438ca9b56f0fcd95354af4bd8329640ecce4 /mingling_core
parent240361b240d638363346013160b0943b47769c37 (diff)
Add basic completion module with shell integration
Diffstat (limited to 'mingling_core')
-rw-r--r--mingling_core/Cargo.toml3
-rw-r--r--mingling_core/src/asset.rs7
-rw-r--r--mingling_core/src/asset/comp.rs20
-rw-r--r--mingling_core/src/asset/comp/flags.rs35
-rw-r--r--mingling_core/src/asset/comp/shell_ctx.rs180
-rw-r--r--mingling_core/src/asset/comp/suggest.rs159
-rw-r--r--mingling_core/src/lib.rs2
7 files changed, 405 insertions, 1 deletions
diff --git a/mingling_core/Cargo.toml b/mingling_core/Cargo.toml
index e978401..ef0ea5d 100644
--- a/mingling_core/Cargo.toml
+++ b/mingling_core/Cargo.toml
@@ -8,7 +8,8 @@ repository = "https://github.com/catilgrass/mingling"
[features]
default = []
-full = ["general_renderer"]
+full = ["comp", "general_renderer"]
+comp = []
general_renderer = [
"dep:serde",
"dep:ron",
diff --git a/mingling_core/src/asset.rs b/mingling_core/src/asset.rs
index 81aa3a6..1270a50 100644
--- a/mingling_core/src/asset.rs
+++ b/mingling_core/src/asset.rs
@@ -1,8 +1,15 @@
#[doc(hidden)]
pub mod chain;
+
+#[cfg(feature = "comp")]
+#[doc(hidden)]
+pub mod comp;
+
#[doc(hidden)]
pub mod dispatcher;
+
#[doc(hidden)]
pub mod node;
+
#[doc(hidden)]
pub mod renderer;
diff --git a/mingling_core/src/asset/comp.rs b/mingling_core/src/asset/comp.rs
new file mode 100644
index 0000000..4815b5a
--- /dev/null
+++ b/mingling_core/src/asset/comp.rs
@@ -0,0 +1,20 @@
+mod flags;
+mod shell_ctx;
+mod suggest;
+
+#[doc(hidden)]
+pub use flags::*;
+#[doc(hidden)]
+pub use shell_ctx::*;
+#[doc(hidden)]
+pub use suggest::*;
+
+/// 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;
+}
diff --git a/mingling_core/src/asset/comp/flags.rs b/mingling_core/src/asset/comp/flags.rs
new file mode 100644
index 0000000..b432b08
--- /dev/null
+++ b/mingling_core/src/asset/comp/flags.rs
@@ -0,0 +1,35 @@
+use just_fmt::snake_case;
+
+#[derive(Default, Debug, Clone)]
+pub enum ShellFlag {
+ #[default]
+ Bash,
+ Zsh,
+ Fish,
+ Powershell,
+ Other(String),
+}
+
+impl From<String> for ShellFlag {
+ fn from(s: String) -> Self {
+ match s.trim().to_lowercase().as_str() {
+ "zsh" => ShellFlag::Zsh,
+ "bash" => ShellFlag::Bash,
+ "fish" => ShellFlag::Fish,
+ "pwsl" | "ps1" | "powershell" => ShellFlag::Powershell,
+ other => ShellFlag::Other(snake_case!(other)),
+ }
+ }
+}
+
+impl From<ShellFlag> 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
new file mode 100644
index 0000000..081337f
--- /dev/null
+++ b/mingling_core/src/asset/comp/shell_ctx.rs
@@ -0,0 +1,180 @@
+use crate::ShellFlag;
+
+/// 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)]
+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<String>,
+
+ /// Flag to indicate completion context (-F / --shell-flag)
+ pub shell_flag: ShellFlag,
+}
+
+impl TryFrom<Vec<String>> for ShellContext {
+ type Error = String;
+
+ fn try_from(args: Vec<String>) -> Result<Self, Self::Error> {
+ 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()));
+
+ // Build all_words from command_line using basic whitespace splitting
+ // Note: External input replaces '-' with '^' in arguments, so we need to restore them
+ let all_words = command_line
+ .split_whitespace()
+ .map(|s| s.replace('^', "-"))
+ .collect();
+
+ // Also restore the original command_line with proper hyphens
+ let command_line = command_line.replace('^', "-");
+
+ Ok(ShellContext {
+ command_line,
+ cursor_position,
+ current_word,
+ previous_word,
+ command_name,
+ word_index,
+ all_words,
+ shell_flag,
+ })
+ }
+}
+
+#[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
new file mode 100644
index 0000000..4e7ce82
--- /dev/null
+++ b/mingling_core/src/asset/comp/suggest.rs
@@ -0,0 +1,159 @@
+use std::collections::BTreeSet;
+
+/// 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)]
+pub enum Suggest {
+ /// A set of specific suggestion items for the shell to display.
+ Suggest(BTreeSet<SuggestItem>),
+
+ /// 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
+ }
+}
+
+impl<T> From<T> for Suggest
+where
+ T: IntoIterator,
+ T::Item: Into<String>,
+{
+ 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<SuggestItem>;
+
+ 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"),
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum SuggestItem {
+ Simple(String),
+ 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<std::cmp::Ordering> {
+ 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<String> {
+ 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<String> 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 999c141..072f50e 100644
--- a/mingling_core/src/lib.rs
+++ b/mingling_core/src/lib.rs
@@ -21,6 +21,8 @@ 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::node::*;
pub use crate::asset::renderer::*;