diff --git a/src/alias.rs b/src/alias.rs index 941b64b80b..6ee8db2007 100644 --- a/src/alias.rs +++ b/src/alias.rs @@ -24,7 +24,7 @@ impl<'src> Alias<'src> { } } -impl RecipeAlias<'_> { +impl Alias<'_, T> { pub(crate) fn is_public(&self) -> bool { !self.name.lexeme().starts_with('_') && !self.attributes.private() } diff --git a/src/arguments.rs b/src/arguments.rs index ff88261a1c..6ceb70f9b6 100644 --- a/src/arguments.rs +++ b/src/arguments.rs @@ -367,6 +367,7 @@ pub(crate) struct Subcommand { )] pub(crate) choose: bool, #[arg( + add = ArgValueCompleter::new(Completer::complete_recipe_or_module), conflicts_with = "arguments", help = "Clear recipe cache, optionally restricted to recipes whose path begins with ", help_heading = Self::HEADING, @@ -443,6 +444,7 @@ pub(crate) struct Subcommand { )] pub(crate) json: bool, #[arg( + add = ArgValueCompleter::new(Completer::complete_module), conflicts_with = "arguments", help = "List available recipes in or root if omitted", help_heading = Self::HEADING, diff --git a/src/completer.rs b/src/completer.rs index 46ed22167b..b53fc78974 100644 --- a/src/completer.rs +++ b/src/completer.rs @@ -7,6 +7,37 @@ pub(crate) struct Completer<'run, 'src> { } impl<'run, 'src> Completer<'run, 'src> { + fn candidate_modules(&self) -> Vec { + let mut candidates = Vec::new(); + + for module in self.justfile.public_modules_recursive(&self.config) { + let path = module.module_path.to_string(); + + if path.starts_with(self.current) { + candidates.push(CompletionCandidate::new(path).help(module.doc.as_ref().map(Into::into))); + } + } + + if self.config.complete_aliases { + for (alias, modulepath) in self.justfile.public_module_aliases_recursive(&self.config) { + let name = modulepath.join(alias.name.lexeme()).to_string(); + if name.starts_with(self.current) { + candidates.push( + CompletionCandidate::new(name).help( + self + .justfile + .submodule(&alias.target) + .and_then(|module| module.doc.as_ref()) + .map(Into::into), + ), + ); + } + } + } + + candidates + } + fn candidate_recipes(&self) -> Vec { let mut candidates = Vec::new(); @@ -67,6 +98,16 @@ impl<'run, 'src> Completer<'run, 'src> { .collect() } + pub(crate) fn complete_module(current: &OsStr) -> Vec { + let loader = Loader::new(); + + let Some(completer) = Completer::new(current, &loader) else { + return Vec::new(); + }; + + completer.candidate_modules() + } + pub(crate) fn complete_recipe(current: &OsStr) -> Vec { let loader = Loader::new(); @@ -77,6 +118,20 @@ impl<'run, 'src> Completer<'run, 'src> { completer.candidate_recipes() } + pub(crate) fn complete_recipe_or_module(current: &OsStr) -> Vec { + let loader = Loader::new(); + + let Some(completer) = Completer::new(current, &loader) else { + return Vec::new(); + }; + + let mut candidates = completer.candidate_modules(); + + candidates.extend(completer.candidate_recipes()); + + candidates + } + pub(crate) fn complete_variable(current: &OsStr) -> Vec { let loader = Loader::new(); @@ -98,6 +153,15 @@ impl<'run, 'src> Completer<'run, 'src> { args.drain(1..3); + let index = env::var("_CLAP_COMPLETE_INDEX") + .ok() + .and_then(|index| index.parse::().ok()) + .unwrap_or(args.len() - 1); + + if (1..args.len()).contains(&index) { + args.remove(index); + } + let matches = Arguments::command() .ignore_errors(true) .try_get_matches_from(args) diff --git a/src/justfile.rs b/src/justfile.rs index 25fb26c121..850f8639ba 100644 --- a/src/justfile.rs +++ b/src/justfile.rs @@ -752,6 +752,44 @@ impl<'src> Justfile<'src> { aliases } + pub(crate) fn public_modules_recursive(&self, config: &Config) -> Vec<&Justfile> { + let mut modules = Vec::new(); + + let mut stack = self.public_modules(config); + stack.reverse(); + while let Some(current) = stack.pop() { + modules.push(current); + + for module in current.public_modules(config).into_iter().rev() { + stack.push(module); + } + } + + modules + } + + pub(crate) fn public_module_aliases_recursive( + &self, + config: &Config, + ) -> Vec<(&ModuleAlias<'_>, &Modulepath)> { + let mut aliases = Vec::new(); + + let mut stack = vec![self]; + while let Some(current) = stack.pop() { + for alias in current.module_aliases.values() { + if alias.is_public() { + aliases.push((alias, ¤t.module_path)); + } + } + + for module in current.public_modules(config).into_iter().rev() { + stack.push(module); + } + } + + aliases + } + pub(crate) fn groups(&self) -> Vec<&str> { self .groups diff --git a/tests/completions.rs b/tests/completions.rs index 3278a43ffe..fcf203a0e1 100644 --- a/tests/completions.rs +++ b/tests/completions.rs @@ -453,3 +453,68 @@ fn recipes_with_invalid_config() { .stdout_regex("bar\nfoo\n.\njustfile\n--.*") .success(); } + +#[test] +fn list_modules() { + Test::new() + .justfile( + " + # doc + mod foo + ", + ) + .write("foo.just", "mod bar") + .write("bar.just", "baz:") + .shell(false) + .env("JUST_COMPLETE", "fish") + .args(complete_args(&["--list", ""])) + .stdout_regex("foo\tdoc\nfoo::bar\nfoo::bar::baz\n.\nbar.just\nfoo.just\njustfile\n--.*") + .success(); +} + +#[test] +fn list_module_aliases_not_completed_by_default() { + Test::new() + .justfile( + " + mod foo + alias f := foo + ", + ) + .write("foo.just", "") + .shell(false) + .env("JUST_COMPLETE", "fish") + .args(complete_args(&["--list", ""])) + .stdout_regex("foo\n.\nfoo.just\njustfile\n--.*") + .success(); +} + +#[test] +fn list_module_aliases_completed_with_flag() { + Test::new() + .justfile( + " + # doc + mod foo + alias f := foo + ", + ) + .write("foo.just", "") + .shell(false) + .env("JUST_COMPLETE", "fish") + .args(complete_args(&["--complete-aliases", "--list", ""])) + .stdout_regex("foo\tdoc\nf\tdoc\n.\nfoo.just\njustfile\n--.*") + .success(); +} + +#[test] +fn clean_recipes_and_modules() { + Test::new() + .justfile("mod foo") + .write("foo.just", "bar:") + .shell(false) + .env("JUST_COMPLETE", "fish") + .args(complete_args(&["--clean", ""])) + .stdout_regex("foo\nfoo::bar\nfoo::bar\n.\nfoo.just\njustfile\n--.*") + .success(); +}