aboutsummaryrefslogtreecommitdiff
path: root/mingling_core/src/comp.rs
diff options
context:
space:
mode:
Diffstat (limited to 'mingling_core/src/comp.rs')
-rw-r--r--mingling_core/src/comp.rs191
1 files changed, 151 insertions, 40 deletions
diff --git a/mingling_core/src/comp.rs b/mingling_core/src/comp.rs
index d8dcfbd..a49ea0c 100644
--- a/mingling_core/src/comp.rs
+++ b/mingling_core/src/comp.rs
@@ -23,9 +23,11 @@ pub use shell_ctx::*;
#[doc(hidden)]
pub use suggest::*;
-use crate::{ProgramCollect, debug, only_debug, this, trace};
+use crate::{ProgramCollect, debug, metadata::Description, only_debug, this, trace};
#[cfg(not(feature = "dispatch_tree"))]
+use crate::ChainProcess;
+#[cfg(not(feature = "dispatch_tree"))]
use crate::exec::match_user_input;
/// Trait for implementing completion logic.
@@ -107,7 +109,17 @@ impl CompletionHelper {
trace_ctx(ctx);
};
- let args = ctx.all_words.iter().skip(1).cloned().collect::<Vec<_>>();
+ // Everything before the first argument that matches a command node
+ // is treated as global parameters (flags and their values), which do
+ // not participate in command tree matching. The dispatch path starts
+ // at that first match, enabling `prog [PARAM]... <subcommand>`
+ // style invocations.
+ let all_args = ctx.all_words.iter().skip(1).cloned().collect::<Vec<_>>();
+ let first_cmd_match = first_command_arg_index::<P>(&all_args);
+ let args = match first_cmd_match {
+ Some(start) => all_args[start..].to_vec(),
+ None => Vec::new(),
+ };
trace!("arguments=\"{}\"", args.join(", "));
#[cfg(not(feature = "dispatch_tree"))]
@@ -138,11 +150,10 @@ impl CompletionHelper {
debug!("dispatch_args_trie OK, member_id = {:?}", any.member_id);
trace!("entry type: {}", any.member_id);
- let dispatcher_not_found =
- <P::ErrorDispatcherNotFound as crate::Grouped<P>>::member_id();
+ let entry_fallback = <P::EntryFallback as crate::Grouped<P>>::member_id();
- if dispatcher_not_found == any.member_id {
- debug!("dispatcher_not_found matched");
+ if entry_fallback == any.member_id {
+ debug!("entry_fallback matched");
trace!("begin not Ok");
None
} else {
@@ -159,12 +170,40 @@ impl CompletionHelper {
match suggest {
Some(suggest) => {
+ // A concrete entry was dispatched. Merge the entry's own
+ // completion with the default subcommand suggestions so that,
+ // e.g. `thanks <tab>`, suggests both the leaf nodes (`bob`,
+ // `alice`) and the `thanks` entry's own completion.
trace!("using custom completion: {:?}", suggest);
- suggest
+ let default = default_completion::<P>(ctx);
+ if suggest == Suggest::FileCompletion {
+ trace!(
+ "custom completion is FileCompletion, using default: {:?}",
+ default
+ );
+ default
+ } else {
+ trace!("combining custom completion with default");
+ suggest.combine(default)
+ }
}
None => {
- trace!("using default completion");
- default_completion::<P>(ctx)
+ if first_cmd_match.is_some() {
+ // A command node has been matched: the global
+ // EntryFallback must not run afterwards, only the
+ // command path is completed.
+ trace!("command node matched, skipping EntryFallback");
+ default_completion::<P>(ctx)
+ } else {
+ trace!("using default completion");
+ let fallback = P::do_comp(&P::build_entry_fallback(vec![]), ctx);
+ let default = default_completion::<P>(ctx);
+ if fallback == Suggest::FileCompletion {
+ default
+ } else {
+ fallback.combine(default)
+ }
+ }
}
}
}
@@ -212,6 +251,60 @@ impl CompletionHelper {
}
}
+/// Finds the index of the first argument that matches the head of a
+/// registered command node.
+///
+/// Everything before this index is treated as global parameters (flags and
+/// their values), which do not participate in command tree matching. This
+/// allows `prog [PARAM]... <subcommand>` style invocations to resolve the
+/// subcommand, while a "broken" path such as `prog -v hello -a someone`
+/// still fails to match the `hello someone` node.
+fn first_command_arg_index<P>(args: &[String]) -> Option<usize>
+where
+ P: ProgramCollect<Enum = P> + Display + 'static,
+{
+ let cmd_heads: Vec<String> = this::<P>()
+ .get_nodes()
+ .into_iter()
+ .filter(|(s, _)| !s.starts_with('_'))
+ .map(|(s, _)| s.split(' ').next().unwrap_or("").to_string())
+ .collect();
+
+ args.iter().position(|arg| {
+ !arg.is_empty() && cmd_heads.iter().any(|head| head.starts_with(arg.as_str()))
+ })
+}
+
+/// Resolves the `Description` metadata registered for the entry that owns the
+/// given (space-separated) command node path, if any.
+///
+/// The node path is dispatched to obtain the owning entry's member id, and its
+/// `Description` is then retrieved via [`ProgramCollect::get_metadata`]. Returns
+/// `None` when the path does not resolve to a concrete entry (e.g. an
+/// intermediate trie segment that has no entry of its own) or when the entry
+/// has no `Description` registered.
+fn entry_description<P>(node: &str) -> Option<String>
+where
+ P: ProgramCollect<Enum = P> + Display + 'static,
+{
+ let words: Vec<String> = node.split(' ').map(str::to_string).collect();
+
+ #[cfg(feature = "dispatch_tree")]
+ let lazy_member = P::dispatch_args_trie(&words).ok().map(|any| any.member_id);
+
+ #[cfg(not(feature = "dispatch_tree"))]
+ let lazy_member = match match_user_input(this::<P>(), &words) {
+ Ok((dispatcher, args)) => match dispatcher.begin(args) {
+ ChainProcess::Ok((any, _)) => Some(any.member_id),
+ _ => None,
+ },
+ Err(_) => None,
+ };
+
+ let member_id = lazy_member?;
+ P::get_metadata::<Description>(member_id).map(String::from)
+}
+
fn default_completion<P>(ctx: &ShellContext) -> Suggest
where
P: ProgramCollect<Enum = P> + Display + 'static,
@@ -238,14 +331,17 @@ where
&ctx.all_words.get(1..input_end).unwrap_or(&[])
);
- let input_path: Vec<&str> = ctx
- .all_words
- .get(1..input_end)
- .unwrap_or(&[])
- .iter()
- .filter(|s| !s.is_empty())
- .map(std::string::String::as_str)
- .collect();
+ // Skip global parameters (arguments before the first command node match)
+ // when resolving the command path, so `prog [PARAM]... <subcommand>`
+ // style invocations suggest the subcommand.
+ let input_slice = ctx.all_words.get(1..input_end).unwrap_or(&[]);
+ let input_path: Vec<&str> = match first_command_arg_index::<P>(input_slice) {
+ Some(start) => input_slice[start..]
+ .iter()
+ .map(std::string::String::as_str)
+ .collect(),
+ None => Vec::new(),
+ };
debug!(
"input_path={:?}, current_word='{}'",
input_path, ctx.current_word
@@ -257,16 +353,27 @@ where
input_path, ctx.word_index, ctx.all_words
);
- // Filter command nodes that match the input path
- let mut suggestions = Vec::new();
+ // Build a suggestion item for `token`, attaching the owning entry's
+ // `Description` metadata (resolved via `node_path`) when one is available.
+ let make_item = |token: &str, node_path: &str| -> SuggestItem {
+ match entry_description::<P>(node_path) {
+ Some(desc) => SuggestItem::new_with_desc(token.to_string(), desc),
+ None => SuggestItem::new(token.to_string()),
+ }
+ };
+
+ // Track both the suggestion text and the node path used to look up its
+ // description, then deduplicate by suggestion text.
+ let mut suggestions: std::collections::BTreeSet<SuggestItem> =
+ std::collections::BTreeSet::new();
// Special case: if input_path is empty, return all first-level commands
if input_path.is_empty() {
debug!("input_path empty, returning first-level commands");
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());
+ if let Some(first) = node_parts.first() {
+ suggestions.insert(make_item(first, first));
}
}
} else {
@@ -279,23 +386,21 @@ where
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());
+ let Some(first) = node_parts.first() else {
+ continue;
+ };
+ if first.starts_with(current_word) {
+ suggestions.insert(make_item(first, first));
}
}
// 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();
+ return Suggest::Suggest(suggestions);
}
}
@@ -333,31 +438,37 @@ where
let last_idx = input_path.len() - 1;
let is_partial = input_path[last_idx] != node_parts[last_idx];
+ // The suggested token and the fully qualified node path that
+ // owns it, used to look up its `Description` metadata.
+ let (token, owner_path) = if input_path.len() == node_parts.len() {
+ // Completing the final token of this node.
+ (node_parts[last_idx], node_parts.join(" "))
+ } else if is_partial {
+ // Completing the current (partial) token in place.
+ (node_parts[last_idx], node_parts[..=last_idx].join(" "))
+ } else {
+ // Advancing to the next level under the matched prefix.
+ let idx = input_path.len();
+ (node_parts[idx], node_parts[..=idx].join(" "))
+ };
+
if input_path.len() == node_parts.len() {
if !ctx.current_word.is_empty() {
- suggestions.push(node_parts[last_idx].to_string());
+ suggestions.insert(make_item(token, &owner_path));
}
} else if input_path.len() < node_parts.len() {
- if is_partial {
- suggestions.push(node_parts[last_idx].to_string());
- } else {
- suggestions.push(node_parts[input_path.len()].to_string());
- }
+ suggestions.insert(make_item(token, &owner_path));
}
}
}
}
- // Remove duplicates and sort
- suggestions.sort();
- suggestions.dedup();
-
debug!("default_completion: suggestions = {:?}", suggestions);
if suggestions.is_empty() {
file_suggest()
} else {
- suggestions.into()
+ Suggest::Suggest(suggestions)
}
}