just/src/subcommand.rs

551 lines
15 KiB
Rust
Raw Normal View History

use super::*;
2022-02-23 11:47:43 -08:00
const INIT_JUSTFILE: &str = "default:\n echo 'Hello, world!'\n";
#[derive(PartialEq, Clone, Debug)]
pub(crate) enum Subcommand {
2021-07-31 13:53:27 -07:00
Changelog,
Choose {
overrides: BTreeMap<String, String>,
chooser: Option<String>,
},
2021-05-09 20:35:35 -07:00
Command {
arguments: Vec<OsString>,
binary: OsString,
2021-05-09 20:35:35 -07:00
overrides: BTreeMap<String, String>,
},
Completions {
shell: String,
},
2019-10-07 04:04:39 -07:00
Dump,
Edit,
Evaluate {
overrides: BTreeMap<String, String>,
variable: Option<String>,
},
2021-06-08 01:01:27 -07:00
Format,
Init,
List,
Run {
arguments: Vec<String>,
overrides: BTreeMap<String, String>,
},
Show {
name: String,
},
Summary,
Variables,
2019-10-07 04:04:39 -07:00
}
impl Subcommand {
pub(crate) fn execute<'src>(
&self,
config: &Config,
loader: &'src Loader,
) -> Result<(), Error<'src>> {
use Subcommand::*;
2021-07-31 13:53:27 -07:00
match self {
Changelog => {
Self::changelog();
return Ok(());
}
2022-01-30 12:16:10 -08:00
Completions { shell } => return Self::completions(shell),
2021-07-31 13:53:27 -07:00
Init => return Self::init(config),
Run {
arguments,
overrides,
} => return Self::run(config, loader, arguments, overrides),
_ => {}
}
let search = Search::find(&config.search_config, &config.invocation_directory)?;
if let Edit = self {
return Self::edit(&search);
}
let (src, ast, justfile) = Self::compile(config, loader, &search)?;
match self {
Choose { overrides, chooser } => {
Self::choose(config, justfile, &search, overrides, chooser.as_deref())?;
}
2021-09-16 07:51:45 -07:00
Command { overrides, .. } | Evaluate { overrides, .. } => {
2022-01-30 12:16:10 -08:00
justfile.run(config, &search, overrides, &[])?;
2021-09-16 07:51:45 -07:00
}
2021-11-17 00:07:48 -08:00
Dump => Self::dump(config, ast, justfile)?,
2022-01-30 12:16:10 -08:00
Format => Self::format(config, &search, src, ast)?,
List => Self::list(config, justfile),
2022-01-30 12:16:10 -08:00
Show { ref name } => Self::show(config, name, justfile)?,
Summary => Self::summary(config, justfile),
Variables => Self::variables(justfile),
Changelog | Completions { .. } | Edit | Init | Run { .. } => unreachable!(),
}
Ok(())
}
pub(crate) fn run<'src>(
config: &Config,
loader: &'src Loader,
arguments: &[String],
overrides: &BTreeMap<String, String>,
) -> Result<(), Error<'src>> {
if config.unstable && config.search_config == SearchConfig::FromInvocationDirectory {
let mut path = config.invocation_directory.clone();
let mut unknown_recipes_errors = None;
loop {
let search = match Search::find_next(&path) {
Err(SearchError::NotFound) => match unknown_recipes_errors {
Some(err) => return Err(err),
None => return Err(SearchError::NotFound.into()),
},
Err(err) => return Err(err.into()),
Ok(search) => {
if config.verbosity.loud() && path != config.invocation_directory {
eprintln!(
"Trying {}",
config
.invocation_directory
.strip_prefix(path)
.unwrap()
.components()
.map(|_| path::Component::ParentDir)
.collect::<PathBuf>()
.join(search.justfile.file_name().unwrap())
.display()
);
}
search
}
};
match Self::run_inner(config, loader, arguments, overrides, &search) {
Err(err @ Error::UnknownRecipes { .. }) => {
match search.justfile.parent().unwrap().parent() {
Some(parent) => {
unknown_recipes_errors.get_or_insert(err);
path = parent.into();
}
None => return Err(err),
}
}
result => return result,
}
}
} else {
Self::run_inner(
config,
loader,
arguments,
overrides,
&Search::find(&config.search_config, &config.invocation_directory)?,
)
}
}
fn run_inner<'src>(
config: &Config,
loader: &'src Loader,
arguments: &[String],
overrides: &BTreeMap<String, String>,
search: &Search,
) -> Result<(), Error<'src>> {
let (_src, _ast, justfile) = Self::compile(config, loader, search)?;
justfile.run(config, search, overrides, arguments)
}
fn compile<'src>(
config: &Config,
loader: &'src Loader,
search: &Search,
) -> Result<(&'src str, Ast<'src>, Justfile<'src>), Error<'src>> {
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 {
eprintln!("{}", warning.color_display(config.color.stderr()));
}
}
Ok((src, ast, justfile))
}
2021-07-31 13:53:27 -07:00
fn changelog() {
print!("{}", include_str!("../CHANGELOG.md"));
}
fn choose<'src>(
config: &Config,
justfile: Justfile<'src>,
search: &Search,
overrides: &BTreeMap<String, String>,
chooser: Option<&str>,
) -> Result<(), Error<'src>> {
let recipes = justfile
.public_recipes(config.unsorted)
.iter()
.filter(|recipe| recipe.min_arguments() == 0)
2022-01-30 12:16:10 -08:00
.copied()
.collect::<Vec<&Recipe<Dependency>>>();
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
2022-01-30 12:16:10 -08:00
.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) => {
2022-08-08 19:50:31 -07:00
let (shell_binary, shell_arguments) = justfile.settings.shell(config);
return Err(Error::ChooserInvoke {
2022-08-08 19:50:31 -07:00
shell_binary: shell_binary.to_owned(),
shell_arguments: shell_arguments.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
.split_whitespace()
.map(str::to_owned)
.collect::<Vec<String>>();
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, ()> {
if let Some(index) = haystack.find(needle) {
haystack.replace_range(index..index + needle.len(), replacement);
Ok(())
} else {
Err(Error::internal(format!(
"Failed to find text:\n{}\n…in completion script:\n{}",
needle, haystack
)))
}
}
let shell = shell
.parse::<Shell>()
.expect("Invalid value for clap::Shell");
let buffer = Vec::new();
let mut cursor = Cursor::new(buffer);
Config::app().gen_completions_to(env!("CARGO_PKG_NAME"), shell, &mut cursor);
let buffer = cursor.into_inner();
let mut script = String::from_utf8(buffer).expect("Clap completion not UTF-8");
match shell {
Shell::Bash => {
for (needle, replacement) in completions::BASH_COMPLETION_REPLACEMENTS {
replace(&mut script, needle, replacement)?;
}
}
Shell::Fish => {
script.insert_str(0, completions::FISH_RECIPE_COMPLETIONS);
}
Shell::PowerShell => {
for (needle, replacement) in completions::POWERSHELL_COMPLETION_REPLACEMENTS {
replace(&mut script, needle, replacement)?;
}
}
Shell::Zsh => {
for (needle, replacement) in completions::ZSH_COMPLETION_REPLACEMENTS {
replace(&mut script, needle, replacement)?;
}
}
Shell::Elvish => {}
}
println!("{}", script.trim());
Ok(())
}
2021-11-17 00:07:48 -08:00
fn dump(config: &Config, ast: Ast, justfile: Justfile) -> Result<(), Error<'static>> {
match config.dump_format {
DumpFormat::Json => {
config.require_unstable("The JSON dump format is currently unstable.")?;
serde_json::to_writer(io::stdout(), &justfile)
.map_err(|serde_json_error| Error::DumpJson { serde_json_error })?;
println!();
}
DumpFormat::Just => print!("{}", ast),
}
Ok(())
}
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, search: &Search, src: &str, ast: Ast) -> Result<(), Error<'static>> {
config.require_unstable("The `--fmt` command is currently unstable.")?;
let formatted = ast.to_string();
if config.check {
return if formatted != src {
use similar::{ChangeTag, TextDiff};
let diff = TextDiff::configure()
.algorithm(similar::Algorithm::Patience)
.diff_lines(src, &formatted);
for op in diff.ops() {
for change in diff.iter_changes(op) {
let (symbol, color) = match change.tag() {
ChangeTag::Delete => ("-", config.color.stderr().diff_deleted()),
ChangeTag::Equal => (" ", config.color.stderr()),
ChangeTag::Insert => ("+", config.color.stderr().diff_added()),
};
eprint!("{}{}{}{}", color.prefix(), symbol, change, color.suffix());
}
}
Err(Error::FormatCheckFoundDiff)
} else {
Ok(())
};
}
fs::write(&search.justfile, formatted).map_err(|io_error| Error::WriteJustfile {
justfile: search.justfile.clone(),
io_error,
})?;
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.color_display(Color::never())).as_str(),
);
}
if line_width <= 30 {
line_widths.insert(name, line_width);
}
}
}
2022-01-30 12:16:10 -08:00
let max_line_width = cmp::min(line_widths.values().copied().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 {
print!(" {}", parameter.color_display(config.color.stdout()));
}
// 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
2022-01-30 12:16:10 -08:00
.saturating_sub(line_widths.get(name).copied().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>(config: &Config, 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.color_display(config.color.stdout()));
Ok(())
} else if let Some(recipe) = justfile.get_recipe(name) {
println!("{}", recipe.color_display(config.color.stdout()));
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::<Vec<&str>>()
.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);
}
}