From ce0376cfdf796c8589735d4a25f751daedffcac2 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Mon, 26 Jul 2021 17:19:52 -0700 Subject: [PATCH] Move subcommand functions into Subcommand (#918) --- src/common.rs | 2 +- src/completions.rs | 174 ++++++++++++++++ src/config.rs | 343 +----------------------------- src/justfile.rs | 4 + src/lib.rs | 1 + src/run.rs | 2 +- src/subcommand.rs | 510 +++++++++++++++++++++++++++++---------------- 7 files changed, 518 insertions(+), 518 deletions(-) create mode 100644 src/completions.rs diff --git a/src/common.rs b/src/common.rs index a37d91f..5759423 100644 --- a/src/common.rs +++ b/src/common.rs @@ -31,7 +31,7 @@ pub(crate) use typed_arena::Arena; pub(crate) use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; // modules -pub(crate) use crate::{config_error, setting}; +pub(crate) use crate::{completions, config, config_error, setting}; // functions pub(crate) use crate::{load_dotenv::load_dotenv, output::output, unindent::unindent}; diff --git a/src/completions.rs b/src/completions.rs new file mode 100644 index 0000000..a97e405 --- /dev/null +++ b/src/completions.rs @@ -0,0 +1,174 @@ +pub(crate) const FISH_RECIPE_COMPLETIONS: &str = r#"function __fish_just_complete_recipes + just --summary 2> /dev/null | tr " " "\n" || echo "" +end + +# don't suggest files right off +complete -c just -n "__fish_is_first_arg" --no-files + +# complete recipes +complete -c just -a '(__fish_just_complete_recipes)' + +# autogenerated completions +"#; + +pub(crate) const ZSH_COMPLETION_REPLACEMENTS: &[(&str, &str)] = &[ + ( + r#" _arguments "${_arguments_options[@]}" \"#, + r#" local common=("#, + ), + ( + r#"'*--set=[Override with ]' \"#, + r#"'*--set[Override with ]: :_just_variables' \"#, + ), + ( + r#"'-s+[Show information about ]' \ +'--show=[Show information about ]' \"#, + r#"'-s+[Show information about ]: :_just_commands' \ +'--show=[Show information about ]: :_just_commands' \"#, + ), + ( + "'::ARGUMENTS -- Overrides and recipe(s) to run, defaulting to the first recipe in the \ + justfile:_files' \\ +&& ret=0 +\x20\x20\x20\x20 +", + r#") + + _arguments "${_arguments_options[@]}" $common \ + '1: :_just_commands' \ + '*: :->args' \ + && ret=0 + + case $state in + args) + curcontext="${curcontext%:*}-${words[2]}:" + + local lastarg=${words[${#words}]} + local recipe + + local cmds; cmds=( + ${(s: :)$(_call_program commands just --summary)} + ) + + # Find first recipe name + for ((i = 2; i < $#words; i++ )) do + if [[ ${cmds[(I)${words[i]}]} -gt 0 ]]; then + recipe=${words[i]} + break + fi + done + + if [[ $lastarg = */* ]]; then + # Arguments contain slash would be recognised as a file + _arguments -s -S $common '*:: :_files' + elif [[ $lastarg = *=* ]]; then + # Arguments contain equal would be recognised as a variable + _message "value" + elif [[ $recipe ]]; then + # Show usage message + _message "`just --show $recipe`" + # Or complete with other commands + #_arguments -s -S $common '*:: :_just_commands' + else + _arguments -s -S $common '*:: :_just_commands' + fi + ;; + esac + + return ret +"#, + ), + ( + " local commands; commands=( +\x20\x20\x20\x20\x20\x20\x20\x20 + )", + r#" [[ $PREFIX = -* ]] && return 1 + integer ret=1 + local variables; variables=( + ${(s: :)$(_call_program commands just --variables)} + ) + local commands; commands=( + ${${${(M)"${(f)$(_call_program commands just --list)}":# *}/ ##/}/ ##/:Args: } + ) +"#, + ), + ( + r#" _describe -t commands 'just commands' commands "$@""#, + r#" if compset -P '*='; then + case "${${words[-1]%=*}#*=}" in + *) _message 'value' && ret=0 ;; + esac + else + _describe -t variables 'variables' variables -qS "=" && ret=0 + _describe -t commands 'just commands' commands "$@" + fi +"#, + ), + ( + r#"_just "$@""#, + r#"(( $+functions[_just_variables] )) || +_just_variables() { + [[ $PREFIX = -* ]] && return 1 + integer ret=1 + local variables; variables=( + ${(s: :)$(_call_program commands just --variables)} + ) + + if compset -P '*='; then + case "${${words[-1]%=*}#*=}" in + *) _message 'value' && ret=0 ;; + esac + else + _describe -t variables 'variables' variables && ret=0 + fi + + return ret +} + +_just "$@""#, + ), +]; + +pub(crate) const POWERSHELL_COMPLETION_REPLACEMENTS: &[(&str, &str)] = &[( + r#"$completions.Where{ $_.CompletionText -like "$wordToComplete*" } | + Sort-Object -Property ListItemText"#, + r#"function Get-JustFileRecipes([string[]]$CommandElements) { + $justFileIndex = $commandElements.IndexOf("--justfile"); + + if ($justFileIndex -ne -1 && $justFileIndex + 1 -le $commandElements.Length) { + $justFileLocation = $commandElements[$justFileIndex + 1] + } + + $justArgs = @("--summary") + + if (Test-Path $justFileLocation) { + $justArgs += @("--justfile", $justFileLocation) + } + + $recipes = $(just @justArgs) -split ' ' + return $recipes | ForEach-Object { [CompletionResult]::new($_) } + } + + $elementValues = $commandElements | Select-Object -ExpandProperty Value + $recipes = Get-JustFileRecipes -CommandElements $elementValues + $completions += $recipes + $completions.Where{ $_.CompletionText -like "$wordToComplete*" } | + Sort-Object -Property ListItemText"#, +)]; + +pub(crate) const BASH_COMPLETION_REPLACEMENTS: &[(&str, &str)] = &[( + r#" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi"#, + r#" if [[ ${cur} == -* ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + elif [[ ${COMP_CWORD} -eq 1 ]]; then + local recipes=$(just --summary --color never 2> /dev/null) + if [[ $? -eq 0 ]]; then + COMPREPLY=( $(compgen -W "${recipes}" -- "${cur}") ) + return 0 + fi + fi"#, +)]; diff --git a/src/config.rs b/src/config.rs index 3e1d028..20bac56 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,7 +11,6 @@ pub(crate) const CHOOSE_HELP: &str = "Select one or more recipes to run using a pub(crate) const DEFAULT_SHELL: &str = "sh"; pub(crate) const DEFAULT_SHELL_ARG: &str = "-cu"; -pub(crate) const INIT_JUSTFILE: &str = "default:\n\techo 'Hello, world!'\n"; #[derive(Debug, PartialEq)] pub(crate) struct Config { @@ -532,163 +531,7 @@ impl Config { }) } - pub(crate) fn run_subcommand<'src>(self, loader: &'src Loader) -> Result<(), Error<'src>> { - use Subcommand::*; - - if self.subcommand == Init { - return self.init(); - } - - if let Completions { shell } = self.subcommand { - return Subcommand::completions(&shell); - } - - let search = Search::find(&self.search_config, &self.invocation_directory)?; - - if self.subcommand == Edit { - return Self::edit(&search); - } - - let src = loader.load(&search.justfile)?; - - let tokens = Lexer::lex(&src)?; - let ast = Parser::parse(&tokens)?; - let justfile = Analyzer::analyze(ast.clone())?; - - if self.verbosity.loud() { - for warning in &justfile.warnings { - warning.write(&mut io::stderr(), self.color.stderr()).ok(); - } - } - - match &self.subcommand { - Choose { overrides, chooser } => - self.choose(justfile, &search, overrides, chooser.as_deref())?, - Command { overrides, .. } => self.run(justfile, &search, overrides, &[])?, - Dump => Self::dump(ast), - Evaluate { overrides, .. } => self.run(justfile, &search, overrides, &[])?, - Format => self.format(ast, &search)?, - List => self.list(justfile), - Run { - arguments, - overrides, - } => self.run(justfile, &search, overrides, arguments)?, - Show { ref name } => Self::show(&name, justfile)?, - Summary => self.summary(justfile), - Variables => Self::variables(justfile), - Completions { .. } | Edit | Init => unreachable!(), - } - - Ok(()) - } - - fn choose<'src>( - &self, - justfile: Justfile<'src>, - search: &Search, - overrides: &BTreeMap, - chooser: Option<&str>, - ) -> Result<(), Error<'src>> { - let recipes = justfile - .public_recipes(self.unsorted) - .iter() - .filter(|recipe| recipe.min_arguments() == 0) - .cloned() - .collect::>>(); - - if recipes.is_empty() { - return Err(Error::NoChoosableRecipes); - } - - let chooser = chooser - .map(OsString::from) - .or_else(|| env::var_os(CHOOSER_ENVIRONMENT_KEY)) - .unwrap_or_else(|| OsString::from(CHOOSER_DEFAULT)); - - let result = justfile - .settings - .shell_command(self) - .arg(&chooser) - .current_dir(&search.working_directory) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn(); - - let mut child = match result { - Ok(child) => child, - Err(io_error) => { - return Err(Error::ChooserInvoke { - shell_binary: justfile.settings.shell_binary(self).to_owned(), - shell_arguments: justfile.settings.shell_arguments(self).join(" "), - chooser, - io_error, - }); - }, - }; - - for recipe in recipes { - if let Err(io_error) = child - .stdin - .as_mut() - .expect("Child was created with piped stdio") - .write_all(format!("{}\n", recipe.name).as_bytes()) - { - return Err(Error::ChooserWrite { io_error, chooser }); - } - } - - let output = match child.wait_with_output() { - Ok(output) => output, - Err(io_error) => { - return Err(Error::ChooserRead { io_error, chooser }); - }, - }; - - if !output.status.success() { - return Err(Error::ChooserStatus { - status: output.status, - chooser, - }); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - - let recipes = stdout - .trim() - .split_whitespace() - .map(str::to_owned) - .collect::>(); - - self.run(justfile, search, overrides, &recipes) - } - - fn dump(ast: Ast) { - print!("{}", ast); - } - - pub(crate) fn edit(search: &Search) -> Result<(), Error<'static>> { - let editor = env::var_os("VISUAL") - .or_else(|| env::var_os("EDITOR")) - .unwrap_or_else(|| "vim".into()); - - let error = Command::new(&editor) - .current_dir(&search.working_directory) - .arg(&search.justfile) - .status(); - - let status = match error { - Err(io_error) => return Err(Error::EditorInvoke { editor, io_error }), - Ok(status) => status, - }; - - if !status.success() { - return Err(Error::EditorStatus { editor, status }); - } - - Ok(()) - } - - fn require_unstable(&self, message: &str) -> Result<(), Error<'static>> { + pub(crate) fn require_unstable(&self, message: &str) -> Result<(), Error<'static>> { if self.unstable { Ok(()) } else { @@ -698,183 +541,8 @@ impl Config { } } - fn format(&self, ast: Ast, search: &Search) -> Result<(), Error<'static>> { - self.require_unstable("The `--fmt` command is currently unstable.")?; - - if let Err(io_error) = - File::create(&search.justfile).and_then(|mut file| write!(file, "{}", ast)) - { - Err(Error::WriteJustfile { - justfile: search.justfile.clone(), - io_error, - }) - } else { - if self.verbosity.loud() { - eprintln!("Wrote justfile to `{}`", search.justfile.display()); - } - Ok(()) - } - } - - pub(crate) fn init(&self) -> Result<(), Error<'static>> { - let search = Search::init(&self.search_config, &self.invocation_directory)?; - - if search.justfile.is_file() { - Err(Error::InitExists { - justfile: search.justfile, - }) - } else if let Err(io_error) = fs::write(&search.justfile, INIT_JUSTFILE) { - Err(Error::WriteJustfile { - justfile: search.justfile, - io_error, - }) - } else { - if self.verbosity.loud() { - eprintln!("Wrote justfile to `{}`", search.justfile.display()); - } - Ok(()) - } - } - - fn list(&self, justfile: Justfile) { - // Construct a target to alias map. - let mut recipe_aliases: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); - for alias in justfile.aliases.values() { - if alias.is_private() { - continue; - } - - if !recipe_aliases.contains_key(alias.target.name.lexeme()) { - recipe_aliases.insert(alias.target.name.lexeme(), vec![alias.name.lexeme()]); - } else { - let aliases = recipe_aliases.get_mut(alias.target.name.lexeme()).unwrap(); - aliases.push(alias.name.lexeme()); - } - } - - let mut line_widths: BTreeMap<&str, usize> = BTreeMap::new(); - - for (name, recipe) in &justfile.recipes { - if recipe.private { - continue; - } - - for name in iter::once(name).chain(recipe_aliases.get(name).unwrap_or(&Vec::new())) { - let mut line_width = UnicodeWidthStr::width(*name); - - for parameter in &recipe.parameters { - line_width += UnicodeWidthStr::width(format!(" {}", parameter).as_str()); - } - - if line_width <= 30 { - line_widths.insert(name, line_width); - } - } - } - - let max_line_width = cmp::min(line_widths.values().cloned().max().unwrap_or(0), 30); - - let doc_color = self.color.stdout().doc(); - print!("{}", self.list_heading); - - for recipe in justfile.public_recipes(self.unsorted) { - let name = recipe.name(); - - for (i, name) in iter::once(&name) - .chain(recipe_aliases.get(name).unwrap_or(&Vec::new())) - .enumerate() - { - print!("{}{}", self.list_prefix, name); - for parameter in &recipe.parameters { - if self.color.stdout().active() { - print!(" {:#}", parameter); - } else { - print!(" {}", parameter); - } - } - - // Declaring this outside of the nested loops will probably be more efficient, - // but it creates all sorts of lifetime issues with variables inside the loops. - // If this is inlined like the docs say, it shouldn't make any difference. - let print_doc = |doc| { - print!( - " {:padding$}{} {}", - "", - doc_color.paint("#"), - doc_color.paint(doc), - padding = max_line_width - .saturating_sub(line_widths.get(name).cloned().unwrap_or(max_line_width)) - ); - }; - - match (i, recipe.doc) { - (0, Some(doc)) => print_doc(doc), - (0, None) => (), - _ => { - let alias_doc = format!("alias for `{}`", recipe.name); - print_doc(&alias_doc); - }, - } - println!(); - } - } - } - - fn run<'src>( - &self, - justfile: Justfile<'src>, - search: &Search, - overrides: &BTreeMap, - arguments: &[String], - ) -> Result<(), Error<'src>> { - if let Err(error) = InterruptHandler::install(self.verbosity) { - warn!("Failed to set CTRL-C handler: {}", error); - } - - justfile.run(&self, search, overrides, arguments) - } - - fn show<'src>(name: &str, justfile: Justfile<'src>) -> Result<(), Error<'src>> { - if let Some(alias) = justfile.get_alias(name) { - let recipe = justfile.get_recipe(alias.target.name.lexeme()).unwrap(); - println!("{}", alias); - println!("{}", recipe); - Ok(()) - } else if let Some(recipe) = justfile.get_recipe(name) { - println!("{}", recipe); - Ok(()) - } else { - Err(Error::UnknownRecipes { - recipes: vec![name.to_owned()], - suggestion: justfile.suggest_recipe(name), - }) - } - } - - fn summary(&self, justfile: Justfile) { - if justfile.count() == 0 { - if self.verbosity.loud() { - eprintln!("Justfile contains no recipes."); - } - } else { - let summary = justfile - .public_recipes(self.unsorted) - .iter() - .map(|recipe| recipe.name()) - .collect::>() - .join(" "); - println!("{}", summary); - } - } - - fn variables(justfile: Justfile) { - for (i, (_, assignment)) in justfile.assignments.iter().enumerate() { - if i > 0 { - print!(" "); - } - print!("{}", assignment.name); - } - println!(); + pub(crate) fn run<'src>(self, loader: &'src Loader) -> Result<(), Error<'src>> { + self.subcommand.run(&self, loader) } } @@ -1716,9 +1384,4 @@ ARGS: assert_eq!(overrides, map!{"bar": "baz"}); }, } - - #[test] - fn init_justfile() { - testing::compile(INIT_JUSTFILE); - } } diff --git a/src/justfile.rs b/src/justfile.rs index ee6cd7b..7d5c762 100644 --- a/src/justfile.rs +++ b/src/justfile.rs @@ -81,6 +81,10 @@ impl<'src> Justfile<'src> { overrides: &BTreeMap, arguments: &[String], ) -> RunResult<'src, ()> { + if let Err(error) = InterruptHandler::install(config.verbosity) { + warn!("Failed to set CTRL-C handler: {}", error); + } + let unknown_overrides = overrides .keys() .filter(|name| !self.assignments.contains_key(name.as_str())) diff --git a/src/lib.rs b/src/lib.rs index 8af492b..c7f125d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -70,6 +70,7 @@ mod common; mod compile_error; mod compile_error_kind; mod compiler; +mod completions; mod config; mod config_error; mod count; diff --git a/src/run.rs b/src/run.rs index f9162b0..e7f3568 100644 --- a/src/run.rs +++ b/src/run.rs @@ -26,7 +26,7 @@ pub fn run() -> Result<(), i32> { .and_then(|config| { color = config.color; verbosity = config.verbosity; - config.run_subcommand(&loader) + config.run(&loader) }) .map_err(|error| { if !verbosity.quiet() { diff --git a/src/subcommand.rs b/src/subcommand.rs index f03556e..bbc8462 100644 --- a/src/subcommand.rs +++ b/src/subcommand.rs @@ -1,5 +1,7 @@ use crate::common::*; +const INIT_JUSTFILE: &str = "default:\n\techo 'Hello, world!'\n"; + #[derive(PartialEq, Clone, Debug)] pub(crate) enum Subcommand { Choose { @@ -34,183 +36,138 @@ pub(crate) enum Subcommand { Variables, } -const FISH_RECIPE_COMPLETIONS: &str = r#"function __fish_just_complete_recipes - just --summary 2> /dev/null | tr " " "\n" || echo "" -end +impl Subcommand { + pub(crate) fn run<'src>(&self, config: &Config, loader: &'src Loader) -> Result<(), Error<'src>> { + use Subcommand::*; -# don't suggest files right off -complete -c just -n "__fish_is_first_arg" --no-files - -# complete recipes -complete -c just -a '(__fish_just_complete_recipes)' - -# autogenerated completions -"#; - -const ZSH_COMPLETION_REPLACEMENTS: &[(&str, &str)] = &[ - ( - r#" _arguments "${_arguments_options[@]}" \"#, - r#" local common=("#, - ), - ( - r#"'*--set=[Override with ]' \"#, - r#"'*--set[Override with ]: :_just_variables' \"#, - ), - ( - r#"'-s+[Show information about ]' \ -'--show=[Show information about ]' \"#, - r#"'-s+[Show information about ]: :_just_commands' \ -'--show=[Show information about ]: :_just_commands' \"#, - ), - ( - "'::ARGUMENTS -- Overrides and recipe(s) to run, defaulting to the first recipe in the \ - justfile:_files' \\ -&& ret=0 -\x20\x20\x20\x20 -", - r#") - - _arguments "${_arguments_options[@]}" $common \ - '1: :_just_commands' \ - '*: :->args' \ - && ret=0 - - case $state in - args) - curcontext="${curcontext%:*}-${words[2]}:" - - local lastarg=${words[${#words}]} - local recipe - - local cmds; cmds=( - ${(s: :)$(_call_program commands just --summary)} - ) - - # Find first recipe name - for ((i = 2; i < $#words; i++ )) do - if [[ ${cmds[(I)${words[i]}]} -gt 0 ]]; then - recipe=${words[i]} - break - fi - done - - if [[ $lastarg = */* ]]; then - # Arguments contain slash would be recognised as a file - _arguments -s -S $common '*:: :_files' - elif [[ $lastarg = *=* ]]; then - # Arguments contain equal would be recognised as a variable - _message "value" - elif [[ $recipe ]]; then - # Show usage message - _message "`just --show $recipe`" - # Or complete with other commands - #_arguments -s -S $common '*:: :_just_commands' - else - _arguments -s -S $common '*:: :_just_commands' - fi - ;; - esac - - return ret -"#, - ), - ( - " local commands; commands=( -\x20\x20\x20\x20\x20\x20\x20\x20 - )", - r#" [[ $PREFIX = -* ]] && return 1 - integer ret=1 - local variables; variables=( - ${(s: :)$(_call_program commands just --variables)} - ) - local commands; commands=( - ${${${(M)"${(f)$(_call_program commands just --list)}":# *}/ ##/}/ ##/:Args: } - ) -"#, - ), - ( - r#" _describe -t commands 'just commands' commands "$@""#, - r#" if compset -P '*='; then - case "${${words[-1]%=*}#*=}" in - *) _message 'value' && ret=0 ;; - esac - else - _describe -t variables 'variables' variables -qS "=" && ret=0 - _describe -t commands 'just commands' commands "$@" - fi -"#, - ), - ( - r#"_just "$@""#, - r#"(( $+functions[_just_variables] )) || -_just_variables() { - [[ $PREFIX = -* ]] && return 1 - integer ret=1 - local variables; variables=( - ${(s: :)$(_call_program commands just --variables)} - ) - - if compset -P '*='; then - case "${${words[-1]%=*}#*=}" in - *) _message 'value' && ret=0 ;; - esac - else - _describe -t variables 'variables' variables && ret=0 - fi - - return ret -} - -_just "$@""#, - ), -]; - -const POWERSHELL_COMPLETION_REPLACEMENTS: &[(&str, &str)] = &[( - r#"$completions.Where{ $_.CompletionText -like "$wordToComplete*" } | - Sort-Object -Property ListItemText"#, - r#"function Get-JustFileRecipes([string[]]$CommandElements) { - $justFileIndex = $commandElements.IndexOf("--justfile"); - - if ($justFileIndex -ne -1 && $justFileIndex + 1 -le $commandElements.Length) { - $justFileLocation = $commandElements[$justFileIndex + 1] - } - - $justArgs = @("--summary") - - if (Test-Path $justFileLocation) { - $justArgs += @("--justfile", $justFileLocation) - } - - $recipes = $(just @justArgs) -split ' ' - return $recipes | ForEach-Object { [CompletionResult]::new($_) } + if let Init = self { + return Self::init(config); } - $elementValues = $commandElements | Select-Object -ExpandProperty Value - $recipes = Get-JustFileRecipes -CommandElements $elementValues - $completions += $recipes - $completions.Where{ $_.CompletionText -like "$wordToComplete*" } | - Sort-Object -Property ListItemText"#, -)]; + if let Completions { shell } = self { + return Self::completions(&shell); + } -const BASH_COMPLETION_REPLACEMENTS: &[(&str, &str)] = &[( - r#" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then - COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) - return 0 - fi"#, - r#" if [[ ${cur} == -* ]] ; then - COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) - return 0 - elif [[ ${COMP_CWORD} -eq 1 ]]; then - local recipes=$(just --summary --color never 2> /dev/null) - if [[ $? -eq 0 ]]; then - COMPREPLY=( $(compgen -W "${recipes}" -- "${cur}") ) - return 0 - fi - fi"#, -)]; + let search = Search::find(&config.search_config, &config.invocation_directory)?; -impl Subcommand { - pub(crate) fn completions(shell: &str) -> RunResult<'static, ()> { + if let Edit = self { + return Self::edit(&search); + } + + let src = loader.load(&search.justfile)?; + + let tokens = Lexer::lex(&src)?; + let ast = Parser::parse(&tokens)?; + let justfile = Analyzer::analyze(ast.clone())?; + + if config.verbosity.loud() { + for warning in &justfile.warnings { + warning.write(&mut io::stderr(), config.color.stderr()).ok(); + } + } + + match self { + Choose { overrides, chooser } => + Self::choose(&config, justfile, &search, overrides, chooser.as_deref())?, + Command { overrides, .. } => justfile.run(&config, &search, overrides, &[])?, + Dump => Self::dump(ast), + Evaluate { overrides, .. } => justfile.run(&config, &search, overrides, &[])?, + Format => Self::format(&config, ast, &search)?, + List => Self::list(&config, justfile), + Run { + arguments, + overrides, + } => justfile.run(&config, &search, overrides, arguments)?, + Show { ref name } => Self::show(&name, justfile)?, + Summary => Self::summary(&config, justfile), + Variables => Self::variables(justfile), + Completions { .. } | Edit | Init => unreachable!(), + } + + Ok(()) + } + + fn choose<'src>( + config: &Config, + justfile: Justfile<'src>, + search: &Search, + overrides: &BTreeMap, + chooser: Option<&str>, + ) -> Result<(), Error<'src>> { + let recipes = justfile + .public_recipes(config.unsorted) + .iter() + .filter(|recipe| recipe.min_arguments() == 0) + .cloned() + .collect::>>(); + + if recipes.is_empty() { + return Err(Error::NoChoosableRecipes); + } + + let chooser = chooser + .map(OsString::from) + .or_else(|| env::var_os(config::CHOOSER_ENVIRONMENT_KEY)) + .unwrap_or_else(|| OsString::from(config::CHOOSER_DEFAULT)); + + let result = justfile + .settings + .shell_command(&config) + .arg(&chooser) + .current_dir(&search.working_directory) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn(); + + let mut child = match result { + Ok(child) => child, + Err(io_error) => { + return Err(Error::ChooserInvoke { + shell_binary: justfile.settings.shell_binary(&config).to_owned(), + shell_arguments: justfile.settings.shell_arguments(&config).join(" "), + chooser, + io_error, + }); + }, + }; + + for recipe in recipes { + if let Err(io_error) = child + .stdin + .as_mut() + .expect("Child was created with piped stdio") + .write_all(format!("{}\n", recipe.name).as_bytes()) + { + return Err(Error::ChooserWrite { io_error, chooser }); + } + } + + let output = match child.wait_with_output() { + Ok(output) => output, + Err(io_error) => { + return Err(Error::ChooserRead { io_error, chooser }); + }, + }; + + if !output.status.success() { + return Err(Error::ChooserStatus { + status: output.status, + chooser, + }); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + let recipes = stdout + .trim() + .split_whitespace() + .map(str::to_owned) + .collect::>(); + + justfile.run(config, search, overrides, &recipes) + } + + fn completions(shell: &str) -> RunResult<'static, ()> { use clap::Shell; fn replace(haystack: &mut String, needle: &str, replacement: &str) -> RunResult<'static, ()> { @@ -237,19 +194,19 @@ impl Subcommand { match shell { Shell::Bash => - for (needle, replacement) in BASH_COMPLETION_REPLACEMENTS { + for (needle, replacement) in completions::BASH_COMPLETION_REPLACEMENTS { replace(&mut script, needle, replacement)?; }, Shell::Fish => { - script.insert_str(0, FISH_RECIPE_COMPLETIONS); + script.insert_str(0, completions::FISH_RECIPE_COMPLETIONS); }, Shell::PowerShell => - for (needle, replacement) in POWERSHELL_COMPLETION_REPLACEMENTS { + for (needle, replacement) in completions::POWERSHELL_COMPLETION_REPLACEMENTS { replace(&mut script, needle, replacement)?; }, Shell::Zsh => - for (needle, replacement) in ZSH_COMPLETION_REPLACEMENTS { + for (needle, replacement) in completions::ZSH_COMPLETION_REPLACEMENTS { replace(&mut script, needle, replacement)?; }, Shell::Elvish => {}, @@ -259,4 +216,205 @@ impl Subcommand { Ok(()) } + + fn dump(ast: Ast) { + print!("{}", ast); + } + + fn edit(search: &Search) -> Result<(), Error<'static>> { + let editor = env::var_os("VISUAL") + .or_else(|| env::var_os("EDITOR")) + .unwrap_or_else(|| "vim".into()); + + let error = Command::new(&editor) + .current_dir(&search.working_directory) + .arg(&search.justfile) + .status(); + + let status = match error { + Err(io_error) => return Err(Error::EditorInvoke { editor, io_error }), + Ok(status) => status, + }; + + if !status.success() { + return Err(Error::EditorStatus { editor, status }); + } + + Ok(()) + } + + fn format(config: &Config, ast: Ast, search: &Search) -> Result<(), Error<'static>> { + config.require_unstable("The `--fmt` command is currently unstable.")?; + + if let Err(io_error) = + File::create(&search.justfile).and_then(|mut file| write!(file, "{}", ast)) + { + Err(Error::WriteJustfile { + justfile: search.justfile.clone(), + io_error, + }) + } else { + if config.verbosity.loud() { + eprintln!("Wrote justfile to `{}`", search.justfile.display()); + } + Ok(()) + } + } + + fn init(config: &Config) -> Result<(), Error<'static>> { + let search = Search::init(&config.search_config, &config.invocation_directory)?; + + if search.justfile.is_file() { + Err(Error::InitExists { + justfile: search.justfile, + }) + } else if let Err(io_error) = fs::write(&search.justfile, INIT_JUSTFILE) { + Err(Error::WriteJustfile { + justfile: search.justfile, + io_error, + }) + } else { + if config.verbosity.loud() { + eprintln!("Wrote justfile to `{}`", search.justfile.display()); + } + Ok(()) + } + } + + fn list(config: &Config, justfile: Justfile) { + // Construct a target to alias map. + let mut recipe_aliases: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + for alias in justfile.aliases.values() { + if alias.is_private() { + continue; + } + + if !recipe_aliases.contains_key(alias.target.name.lexeme()) { + recipe_aliases.insert(alias.target.name.lexeme(), vec![alias.name.lexeme()]); + } else { + let aliases = recipe_aliases.get_mut(alias.target.name.lexeme()).unwrap(); + aliases.push(alias.name.lexeme()); + } + } + + let mut line_widths: BTreeMap<&str, usize> = BTreeMap::new(); + + for (name, recipe) in &justfile.recipes { + if recipe.private { + continue; + } + + for name in iter::once(name).chain(recipe_aliases.get(name).unwrap_or(&Vec::new())) { + let mut line_width = UnicodeWidthStr::width(*name); + + for parameter in &recipe.parameters { + line_width += UnicodeWidthStr::width(format!(" {}", parameter).as_str()); + } + + if line_width <= 30 { + line_widths.insert(name, line_width); + } + } + } + + let max_line_width = cmp::min(line_widths.values().cloned().max().unwrap_or(0), 30); + + let doc_color = config.color.stdout().doc(); + print!("{}", config.list_heading); + + for recipe in justfile.public_recipes(config.unsorted) { + let name = recipe.name(); + + for (i, name) in iter::once(&name) + .chain(recipe_aliases.get(name).unwrap_or(&Vec::new())) + .enumerate() + { + print!("{}{}", config.list_prefix, name); + for parameter in &recipe.parameters { + if config.color.stdout().active() { + print!(" {:#}", parameter); + } else { + print!(" {}", parameter); + } + } + + // Declaring this outside of the nested loops will probably be more efficient, + // but it creates all sorts of lifetime issues with variables inside the loops. + // If this is inlined like the docs say, it shouldn't make any difference. + let print_doc = |doc| { + print!( + " {:padding$}{} {}", + "", + doc_color.paint("#"), + doc_color.paint(doc), + padding = max_line_width + .saturating_sub(line_widths.get(name).cloned().unwrap_or(max_line_width)) + ); + }; + + match (i, recipe.doc) { + (0, Some(doc)) => print_doc(doc), + (0, None) => (), + _ => { + let alias_doc = format!("alias for `{}`", recipe.name); + print_doc(&alias_doc); + }, + } + println!(); + } + } + } + + fn show<'src>(name: &str, justfile: Justfile<'src>) -> Result<(), Error<'src>> { + if let Some(alias) = justfile.get_alias(name) { + let recipe = justfile.get_recipe(alias.target.name.lexeme()).unwrap(); + println!("{}", alias); + println!("{}", recipe); + Ok(()) + } else if let Some(recipe) = justfile.get_recipe(name) { + println!("{}", recipe); + Ok(()) + } else { + Err(Error::UnknownRecipes { + recipes: vec![name.to_owned()], + suggestion: justfile.suggest_recipe(name), + }) + } + } + + fn summary(config: &Config, justfile: Justfile) { + if justfile.count() == 0 { + if config.verbosity.loud() { + eprintln!("Justfile contains no recipes."); + } + } else { + let summary = justfile + .public_recipes(config.unsorted) + .iter() + .map(|recipe| recipe.name()) + .collect::>() + .join(" "); + println!("{}", summary); + } + } + + fn variables(justfile: Justfile) { + for (i, (_, assignment)) in justfile.assignments.iter().enumerate() { + if i > 0 { + print!(" "); + } + print!("{}", assignment.name); + } + println!(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn init_justfile() { + testing::compile(INIT_JUSTFILE); + } }