diff options
Diffstat (limited to 'mingling_core/src/comp.rs')
| -rw-r--r-- | mingling_core/src/comp.rs | 191 |
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) } } |
