1 use crate::common::*; 2 3 const INIT_JUSTFILE: &str = "default:\n\techo 'Hello, world!'\n"; 4 5 #[derive(PartialEq, Clone, Debug)] 6 pub(crate) enum Subcommand { 7 Changelog, 8 Choose { 9 overrides: BTreeMap<String, String>, 10 chooser: Option<String>, 11 }, 12 Command { 13 arguments: Vec<OsString>, 14 binary: OsString, 15 overrides: BTreeMap<String, String>, 16 }, 17 Completions { 18 shell: String, 19 }, 20 Dump, 21 Edit, 22 Evaluate { 23 overrides: BTreeMap<String, String>, 24 variable: Option<String>, 25 }, 26 Format, 27 Init, 28 List, 29 Run { 30 overrides: BTreeMap<String, String>, 31 arguments: Vec<String>, 32 }, 33 Show { 34 name: String, 35 }, 36 Summary, 37 Variables, 38 } 39 40 impl Subcommand { run<'src>(&self, config: &Config, loader: &'src Loader) -> Result<(), Error<'src>>41 pub(crate) fn run<'src>(&self, config: &Config, loader: &'src Loader) -> Result<(), Error<'src>> { 42 use Subcommand::*; 43 44 match self { 45 Changelog => { 46 Self::changelog(); 47 return Ok(()); 48 } 49 Completions { shell } => return Self::completions(&shell), 50 Init => return Self::init(config), 51 _ => {} 52 } 53 54 let search = Search::find(&config.search_config, &config.invocation_directory)?; 55 56 if let Edit = self { 57 return Self::edit(&search); 58 } 59 60 let src = loader.load(&search.justfile)?; 61 62 let tokens = Lexer::lex(&src)?; 63 let ast = Parser::parse(&tokens)?; 64 let justfile = Analyzer::analyze(ast.clone())?; 65 66 if config.verbosity.loud() { 67 for warning in &justfile.warnings { 68 eprintln!("{}", warning.color_display(config.color.stderr())); 69 } 70 } 71 72 match self { 73 Choose { overrides, chooser } => { 74 Self::choose(config, justfile, &search, overrides, chooser.as_deref())?; 75 } 76 Command { overrides, .. } | Evaluate { overrides, .. } => { 77 justfile.run(config, &search, overrides, &[])? 78 } 79 Dump => Self::dump(config, ast, justfile)?, 80 Format => Self::format(config, &search, &src, ast)?, 81 List => Self::list(config, justfile), 82 Run { 83 arguments, 84 overrides, 85 } => justfile.run(config, &search, overrides, arguments)?, 86 Show { ref name } => Self::show(config, &name, justfile)?, 87 Summary => Self::summary(config, justfile), 88 Variables => Self::variables(justfile), 89 Changelog | Completions { .. } | Edit | Init => unreachable!(), 90 } 91 92 Ok(()) 93 } 94 changelog()95 fn changelog() { 96 print!("{}", include_str!("../CHANGELOG.md")); 97 } 98 choose<'src>( config: &Config, justfile: Justfile<'src>, search: &Search, overrides: &BTreeMap<String, String>, chooser: Option<&str>, ) -> Result<(), Error<'src>>99 fn choose<'src>( 100 config: &Config, 101 justfile: Justfile<'src>, 102 search: &Search, 103 overrides: &BTreeMap<String, String>, 104 chooser: Option<&str>, 105 ) -> Result<(), Error<'src>> { 106 let recipes = justfile 107 .public_recipes(config.unsorted) 108 .iter() 109 .filter(|recipe| recipe.min_arguments() == 0) 110 .cloned() 111 .collect::<Vec<&Recipe<Dependency>>>(); 112 113 if recipes.is_empty() { 114 return Err(Error::NoChoosableRecipes); 115 } 116 117 let chooser = chooser 118 .map(OsString::from) 119 .or_else(|| env::var_os(config::CHOOSER_ENVIRONMENT_KEY)) 120 .unwrap_or_else(|| OsString::from(config::CHOOSER_DEFAULT)); 121 122 let result = justfile 123 .settings 124 .shell_command(&config) 125 .arg(&chooser) 126 .current_dir(&search.working_directory) 127 .stdin(Stdio::piped()) 128 .stdout(Stdio::piped()) 129 .spawn(); 130 131 let mut child = match result { 132 Ok(child) => child, 133 Err(io_error) => { 134 return Err(Error::ChooserInvoke { 135 shell_binary: justfile.settings.shell_binary(&config).to_owned(), 136 shell_arguments: justfile.settings.shell_arguments(&config).join(" "), 137 chooser, 138 io_error, 139 }); 140 } 141 }; 142 143 for recipe in recipes { 144 if let Err(io_error) = child 145 .stdin 146 .as_mut() 147 .expect("Child was created with piped stdio") 148 .write_all(format!("{}\n", recipe.name).as_bytes()) 149 { 150 return Err(Error::ChooserWrite { io_error, chooser }); 151 } 152 } 153 154 let output = match child.wait_with_output() { 155 Ok(output) => output, 156 Err(io_error) => { 157 return Err(Error::ChooserRead { io_error, chooser }); 158 } 159 }; 160 161 if !output.status.success() { 162 return Err(Error::ChooserStatus { 163 status: output.status, 164 chooser, 165 }); 166 } 167 168 let stdout = String::from_utf8_lossy(&output.stdout); 169 170 let recipes = stdout 171 .trim() 172 .split_whitespace() 173 .map(str::to_owned) 174 .collect::<Vec<String>>(); 175 176 justfile.run(config, search, overrides, &recipes) 177 } 178 completions(shell: &str) -> RunResult<'static, ()>179 fn completions(shell: &str) -> RunResult<'static, ()> { 180 use clap::Shell; 181 182 fn replace(haystack: &mut String, needle: &str, replacement: &str) -> RunResult<'static, ()> { 183 if let Some(index) = haystack.find(needle) { 184 haystack.replace_range(index..index + needle.len(), replacement); 185 Ok(()) 186 } else { 187 Err(Error::internal(format!( 188 "Failed to find text:\n{}\n…in completion script:\n{}", 189 needle, haystack 190 ))) 191 } 192 } 193 194 let shell = shell 195 .parse::<Shell>() 196 .expect("Invalid value for clap::Shell"); 197 198 let buffer = Vec::new(); 199 let mut cursor = Cursor::new(buffer); 200 Config::app().gen_completions_to(env!("CARGO_PKG_NAME"), shell, &mut cursor); 201 let buffer = cursor.into_inner(); 202 let mut script = String::from_utf8(buffer).expect("Clap completion not UTF-8"); 203 204 match shell { 205 Shell::Bash => { 206 for (needle, replacement) in completions::BASH_COMPLETION_REPLACEMENTS { 207 replace(&mut script, needle, replacement)?; 208 } 209 } 210 Shell::Fish => { 211 script.insert_str(0, completions::FISH_RECIPE_COMPLETIONS); 212 } 213 Shell::PowerShell => { 214 for (needle, replacement) in completions::POWERSHELL_COMPLETION_REPLACEMENTS { 215 replace(&mut script, needle, replacement)?; 216 } 217 } 218 219 Shell::Zsh => { 220 for (needle, replacement) in completions::ZSH_COMPLETION_REPLACEMENTS { 221 replace(&mut script, needle, replacement)?; 222 } 223 } 224 Shell::Elvish => {} 225 } 226 227 println!("{}", script.trim()); 228 229 Ok(()) 230 } 231 dump(config: &Config, ast: Ast, justfile: Justfile) -> Result<(), Error<'static>>232 fn dump(config: &Config, ast: Ast, justfile: Justfile) -> Result<(), Error<'static>> { 233 match config.dump_format { 234 DumpFormat::Json => { 235 config.require_unstable("The JSON dump format is currently unstable.")?; 236 serde_json::to_writer(io::stdout(), &justfile) 237 .map_err(|serde_json_error| Error::DumpJson { serde_json_error })?; 238 println!(); 239 } 240 DumpFormat::Just => print!("{}", ast), 241 } 242 Ok(()) 243 } 244 edit(search: &Search) -> Result<(), Error<'static>>245 fn edit(search: &Search) -> Result<(), Error<'static>> { 246 let editor = env::var_os("VISUAL") 247 .or_else(|| env::var_os("EDITOR")) 248 .unwrap_or_else(|| "vim".into()); 249 250 let error = Command::new(&editor) 251 .current_dir(&search.working_directory) 252 .arg(&search.justfile) 253 .status(); 254 255 let status = match error { 256 Err(io_error) => return Err(Error::EditorInvoke { editor, io_error }), 257 Ok(status) => status, 258 }; 259 260 if !status.success() { 261 return Err(Error::EditorStatus { editor, status }); 262 } 263 264 Ok(()) 265 } 266 format(config: &Config, search: &Search, src: &str, ast: Ast) -> Result<(), Error<'static>>267 fn format(config: &Config, search: &Search, src: &str, ast: Ast) -> Result<(), Error<'static>> { 268 config.require_unstable("The `--fmt` command is currently unstable.")?; 269 270 let formatted = ast.to_string(); 271 272 if config.check { 273 return if formatted != src { 274 use similar::{ChangeTag, TextDiff}; 275 276 let diff = TextDiff::configure() 277 .algorithm(similar::Algorithm::Patience) 278 .diff_lines(src, &formatted); 279 280 for op in diff.ops() { 281 for change in diff.iter_changes(op) { 282 let (symbol, color) = match change.tag() { 283 ChangeTag::Delete => ("-", config.color.stderr().diff_deleted()), 284 ChangeTag::Equal => (" ", config.color.stderr()), 285 ChangeTag::Insert => ("+", config.color.stderr().diff_added()), 286 }; 287 288 eprint!("{}{}{}{}", color.prefix(), symbol, change, color.suffix()); 289 } 290 } 291 292 Err(Error::FormatCheckFoundDiff) 293 } else { 294 Ok(()) 295 }; 296 } 297 298 fs::write(&search.justfile, formatted).map_err(|io_error| Error::WriteJustfile { 299 justfile: search.justfile.clone(), 300 io_error, 301 })?; 302 303 if config.verbosity.loud() { 304 eprintln!("Wrote justfile to `{}`", search.justfile.display()); 305 } 306 307 Ok(()) 308 } 309 init(config: &Config) -> Result<(), Error<'static>>310 fn init(config: &Config) -> Result<(), Error<'static>> { 311 let search = Search::init(&config.search_config, &config.invocation_directory)?; 312 313 if search.justfile.is_file() { 314 Err(Error::InitExists { 315 justfile: search.justfile, 316 }) 317 } else if let Err(io_error) = fs::write(&search.justfile, INIT_JUSTFILE) { 318 Err(Error::WriteJustfile { 319 justfile: search.justfile, 320 io_error, 321 }) 322 } else { 323 if config.verbosity.loud() { 324 eprintln!("Wrote justfile to `{}`", search.justfile.display()); 325 } 326 Ok(()) 327 } 328 } 329 list(config: &Config, justfile: Justfile)330 fn list(config: &Config, justfile: Justfile) { 331 // Construct a target to alias map. 332 let mut recipe_aliases: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); 333 for alias in justfile.aliases.values() { 334 if alias.is_private() { 335 continue; 336 } 337 338 if !recipe_aliases.contains_key(alias.target.name.lexeme()) { 339 recipe_aliases.insert(alias.target.name.lexeme(), vec![alias.name.lexeme()]); 340 } else { 341 let aliases = recipe_aliases.get_mut(alias.target.name.lexeme()).unwrap(); 342 aliases.push(alias.name.lexeme()); 343 } 344 } 345 346 let mut line_widths: BTreeMap<&str, usize> = BTreeMap::new(); 347 348 for (name, recipe) in &justfile.recipes { 349 if recipe.private { 350 continue; 351 } 352 353 for name in iter::once(name).chain(recipe_aliases.get(name).unwrap_or(&Vec::new())) { 354 let mut line_width = UnicodeWidthStr::width(*name); 355 356 for parameter in &recipe.parameters { 357 line_width += UnicodeWidthStr::width( 358 format!(" {}", parameter.color_display(Color::never())).as_str(), 359 ); 360 } 361 362 if line_width <= 30 { 363 line_widths.insert(name, line_width); 364 } 365 } 366 } 367 368 let max_line_width = cmp::min(line_widths.values().cloned().max().unwrap_or(0), 30); 369 370 let doc_color = config.color.stdout().doc(); 371 print!("{}", config.list_heading); 372 373 for recipe in justfile.public_recipes(config.unsorted) { 374 let name = recipe.name(); 375 376 for (i, name) in iter::once(&name) 377 .chain(recipe_aliases.get(name).unwrap_or(&Vec::new())) 378 .enumerate() 379 { 380 print!("{}{}", config.list_prefix, name); 381 for parameter in &recipe.parameters { 382 print!(" {}", parameter.color_display(config.color.stdout())); 383 } 384 385 // Declaring this outside of the nested loops will probably be more efficient, 386 // but it creates all sorts of lifetime issues with variables inside the loops. 387 // If this is inlined like the docs say, it shouldn't make any difference. 388 let print_doc = |doc| { 389 print!( 390 " {:padding$}{} {}", 391 "", 392 doc_color.paint("#"), 393 doc_color.paint(doc), 394 padding = max_line_width 395 .saturating_sub(line_widths.get(name).cloned().unwrap_or(max_line_width)) 396 ); 397 }; 398 399 match (i, recipe.doc) { 400 (0, Some(doc)) => print_doc(doc), 401 (0, None) => (), 402 _ => { 403 let alias_doc = format!("alias for `{}`", recipe.name); 404 print_doc(&alias_doc); 405 } 406 } 407 println!(); 408 } 409 } 410 } 411 show<'src>(config: &Config, name: &str, justfile: Justfile<'src>) -> Result<(), Error<'src>>412 fn show<'src>(config: &Config, name: &str, justfile: Justfile<'src>) -> Result<(), Error<'src>> { 413 if let Some(alias) = justfile.get_alias(name) { 414 let recipe = justfile.get_recipe(alias.target.name.lexeme()).unwrap(); 415 println!("{}", alias); 416 println!("{}", recipe.color_display(config.color.stdout())); 417 Ok(()) 418 } else if let Some(recipe) = justfile.get_recipe(name) { 419 println!("{}", recipe.color_display(config.color.stdout())); 420 Ok(()) 421 } else { 422 Err(Error::UnknownRecipes { 423 recipes: vec![name.to_owned()], 424 suggestion: justfile.suggest_recipe(name), 425 }) 426 } 427 } 428 summary(config: &Config, justfile: Justfile)429 fn summary(config: &Config, justfile: Justfile) { 430 if justfile.count() == 0 { 431 if config.verbosity.loud() { 432 eprintln!("Justfile contains no recipes."); 433 } 434 } else { 435 let summary = justfile 436 .public_recipes(config.unsorted) 437 .iter() 438 .map(|recipe| recipe.name()) 439 .collect::<Vec<&str>>() 440 .join(" "); 441 println!("{}", summary); 442 } 443 } 444 variables(justfile: Justfile)445 fn variables(justfile: Justfile) { 446 for (i, (_, assignment)) in justfile.assignments.iter().enumerate() { 447 if i > 0 { 448 print!(" "); 449 } 450 print!("{}", assignment.name); 451 } 452 println!(); 453 } 454 } 455 456 #[cfg(test)] 457 mod tests { 458 use super::*; 459 460 #[test] init_justfile()461 fn init_justfile() { 462 testing::compile(INIT_JUSTFILE); 463 } 464 } 465