1use {super::*, clap_mangen::Man};
2
3const INIT_JUSTFILE: &str = "default:\n echo 'Hello, world!'\n";
4
5fn backtick_re() -> &'static Regex {
6 static BACKTICK_RE: OnceLock<Regex> = OnceLock::new();
7 BACKTICK_RE.get_or_init(|| Regex::new("(`.*?`)|(`[^`]*$)").unwrap())
8}
9
10#[derive(PartialEq, Clone, Debug)]
11pub enum Subcommand {
12 Changelog,
13 Choose {
14 overrides: BTreeMap<String, String>,
15 chooser: Option<String>,
16 },
17 Command {
18 arguments: Vec<OsString>,
19 binary: OsString,
20 overrides: BTreeMap<String, String>,
21 },
22 Completions {
23 shell: completions::Shell,
24 },
25 Dump,
26 Edit,
27 Evaluate {
28 overrides: BTreeMap<String, String>,
29 variable: Option<String>,
30 },
31 Format,
32 Groups,
33 Init,
34 List {
35 path: ModulePath,
36 },
37 Man,
38 Run {
39 arguments: Vec<String>,
40 overrides: BTreeMap<String, String>,
41 },
42 Show {
43 path: ModulePath,
44 },
45 Summary,
46 Variables,
47}
48
49impl Subcommand {
50 pub fn execute<'src>(&self, config: &Config, loader: &'src Loader) -> RunResult<'src> {
51 use Subcommand::*;
52
53 match self {
54 Changelog => {
55 Self::changelog();
56 return Ok(());
57 }
58 Completions { shell } => return Self::completions(*shell),
59 Init => return Self::init(config),
60 Man => return Self::man(),
61 _ => {}
62 }
63
64 let search = Search::find(&config.search_config, &config.invocation_directory)?;
65
66 if let Edit = self {
67 return Self::edit(&search);
68 }
69
70 let compilation = Self::compile(config, loader, &search)?;
71 let justfile = &compilation.justfile;
72
73 match self {
74 Run {
75 arguments,
76 overrides,
77 } => Self::run(config, loader, search, compilation, arguments, overrides)?,
78 Choose { overrides, chooser } => {
79 Self::choose(config, justfile, &search, overrides, chooser.as_deref())?;
80 }
81 Command { overrides, .. } | Evaluate { overrides, .. } => {
82 justfile.run(config, &search, overrides, &[])?;
83 }
84 Dump => Self::dump(config, compilation)?,
85 Format => Self::format(config, &search, compilation)?,
86 Groups => Self::groups(config, justfile),
87 List { path } => Self::list(config, justfile, path)?,
88 Show { path } => Self::show(config, justfile, path)?,
89 Summary => Self::summary(config, justfile),
90 Variables => Self::variables(justfile),
91 Changelog | Completions { .. } | Edit | Init | Man => unreachable!(),
92 }
93
94 Ok(())
95 }
96
97 fn groups(config: &Config, justfile: &Justfile) {
98 println!("Recipe groups:");
99 for group in justfile.public_groups(config) {
100 println!("{}{group}", config.list_prefix);
101 }
102 }
103
104 fn run<'src>(
105 config: &Config,
106 loader: &'src Loader,
107 mut search: Search,
108 mut compilation: Compilation<'src>,
109 arguments: &[String],
110 overrides: &BTreeMap<String, String>,
111 ) -> RunResult<'src> {
112 let starting_parent = search.justfile.parent().as_ref().unwrap().lexiclean();
113
114 loop {
115 let justfile = &compilation.justfile;
116 let fallback = justfile.settings.fallback
117 && matches!(
118 config.search_config,
119 SearchConfig::FromInvocationDirectory | SearchConfig::FromSearchDirectory { .. }
120 );
121
122 let result = justfile.run(config, &search, overrides, arguments);
123
124 if fallback {
125 if let Err(err @ (Error::UnknownRecipe { .. } | Error::UnknownSubmodule { .. })) = result {
126 search = search.search_parent_directory().map_err(|_| err)?;
127
128 if config.verbosity.loquacious() {
129 eprintln!(
130 "Trying {}",
131 starting_parent
132 .strip_prefix(search.justfile.parent().unwrap())
133 .unwrap()
134 .components()
135 .map(|_| path::Component::ParentDir)
136 .collect::<PathBuf>()
137 .join(search.justfile.file_name().unwrap())
138 .display()
139 );
140 }
141
142 compilation = Self::compile(config, loader, &search)?;
143
144 continue;
145 }
146 }
147
148 return result;
149 }
150 }
151
152 fn compile<'src>(
153 config: &Config,
154 loader: &'src Loader,
155 search: &Search,
156 ) -> RunResult<'src, Compilation<'src>> {
157 let compilation = Compiler::compile(loader, &search.justfile)?;
158
159 compilation.justfile.check_unstable(config)?;
160
161 if config.verbosity.loud() {
162 for warning in &compilation.justfile.warnings {
163 eprintln!("{}", warning.color_display(config.color.stderr()));
164 }
165 }
166
167 Ok(compilation)
168 }
169
170 fn changelog() {
171 print!("{}", include_str!("../CHANGELOG.md"));
172 }
173
174 fn choose<'src>(
175 config: &Config,
176 justfile: &Justfile<'src>,
177 search: &Search,
178 overrides: &BTreeMap<String, String>,
179 chooser: Option<&str>,
180 ) -> RunResult<'src> {
181 let mut recipes = Vec::<&Recipe>::new();
182 let mut stack = vec![justfile];
183 while let Some(module) = stack.pop() {
184 recipes.extend(
185 module
186 .public_recipes(config)
187 .iter()
188 .filter(|recipe| recipe.min_arguments() == 0),
189 );
190 stack.extend(module.modules.values());
191 }
192
193 if recipes.is_empty() {
194 return Err(Error::NoChoosableRecipes);
195 }
196
197 let chooser = if let Some(chooser) = chooser {
198 OsString::from(chooser)
199 } else {
200 let mut chooser = OsString::new();
201 chooser.push("fzf --multi --preview 'just --unstable --color always --justfile \"");
202 chooser.push(&search.justfile);
203 chooser.push("\" --show {}'");
204 chooser
205 };
206
207 let result = justfile
208 .settings
209 .shell_command(config)
210 .arg(&chooser)
211 .current_dir(&search.working_directory)
212 .stdin(Stdio::piped())
213 .stdout(Stdio::piped())
214 .spawn();
215
216 let mut child = match result {
217 Ok(child) => child,
218 Err(io_error) => {
219 let (shell_binary, shell_arguments) = justfile.settings.shell(config);
220 return Err(Error::ChooserInvoke {
221 shell_binary: shell_binary.to_owned(),
222 shell_arguments: shell_arguments.join(" "),
223 chooser,
224 io_error,
225 });
226 }
227 };
228
229 for recipe in recipes {
230 writeln!(
231 child.stdin.as_mut().unwrap(),
232 "{}",
233 recipe.namepath.spaced()
234 )
235 .map_err(|io_error| Error::ChooserWrite {
236 io_error,
237 chooser: chooser.clone(),
238 })?;
239 }
240
241 let output = match child.wait_with_output() {
242 Ok(output) => output,
243 Err(io_error) => {
244 return Err(Error::ChooserRead { io_error, chooser });
245 }
246 };
247
248 if !output.status.success() {
249 return Err(Error::ChooserStatus {
250 status: output.status,
251 chooser,
252 });
253 }
254
255 let stdout = String::from_utf8_lossy(&output.stdout);
256
257 let recipes = stdout
258 .split_whitespace()
259 .map(str::to_owned)
260 .collect::<Vec<String>>();
261
262 justfile.run(config, search, overrides, &recipes)
263 }
264
265 fn completions(shell: completions::Shell) -> RunResult<'static, ()> {
266 println!("{}", shell.script()?);
267 Ok(())
268 }
269
270 fn dump(config: &Config, compilation: Compilation) -> RunResult<'static> {
271 match config.dump_format {
272 DumpFormat::Json => {
273 serde_json::to_writer(io::stdout(), &compilation.justfile)
274 .map_err(|serde_json_error| Error::DumpJson { serde_json_error })?;
275 println!();
276 }
277 DumpFormat::Just => print!("{}", compilation.root_ast()),
278 }
279 Ok(())
280 }
281
282 fn edit(search: &Search) -> RunResult<'static> {
283 let editor = env::var_os("VISUAL")
284 .or_else(|| env::var_os("EDITOR"))
285 .unwrap_or_else(|| "vim".into());
286
287 let error = Command::new(&editor)
288 .current_dir(&search.working_directory)
289 .arg(&search.justfile)
290 .status();
291
292 let status = match error {
293 Err(io_error) => return Err(Error::EditorInvoke { editor, io_error }),
294 Ok(status) => status,
295 };
296
297 if !status.success() {
298 return Err(Error::EditorStatus { editor, status });
299 }
300
301 Ok(())
302 }
303
304 fn format(config: &Config, search: &Search, compilation: Compilation) -> RunResult<'static> {
305 let justfile = &compilation.justfile;
306 let src = compilation.root_src();
307 let ast = compilation.root_ast();
308
309 config.require_unstable(justfile, UnstableFeature::FormatSubcommand)?;
310
311 let formatted = ast.to_string();
312
313 if config.check {
314 return if formatted == src {
315 Ok(())
316 } else {
317 if !config.verbosity.quiet() {
318 use similar::{ChangeTag, TextDiff};
319
320 let diff = TextDiff::configure()
321 .algorithm(similar::Algorithm::Patience)
322 .diff_lines(src, &formatted);
323
324 for op in diff.ops() {
325 for change in diff.iter_changes(op) {
326 let (symbol, color) = match change.tag() {
327 ChangeTag::Delete => ("-", config.color.stdout().diff_deleted()),
328 ChangeTag::Equal => (" ", config.color.stdout()),
329 ChangeTag::Insert => ("+", config.color.stdout().diff_added()),
330 };
331
332 print!("{}{symbol}{change}{}", color.prefix(), color.suffix());
333 }
334 }
335 }
336
337 Err(Error::FormatCheckFoundDiff)
338 };
339 }
340
341 fs::write(&search.justfile, formatted).map_err(|io_error| Error::WriteJustfile {
342 justfile: search.justfile.clone(),
343 io_error,
344 })?;
345
346 if config.verbosity.loud() {
347 eprintln!("Wrote justfile to `{}`", search.justfile.display());
348 }
349
350 Ok(())
351 }
352
353 fn init(config: &Config) -> RunResult<'static> {
354 let search = Search::init(&config.search_config, &config.invocation_directory)?;
355
356 if search.justfile.is_file() {
357 return Err(Error::InitExists {
358 justfile: search.justfile,
359 });
360 }
361
362 if let Err(io_error) = fs::write(&search.justfile, INIT_JUSTFILE) {
363 return Err(Error::WriteJustfile {
364 justfile: search.justfile,
365 io_error,
366 });
367 }
368
369 if config.verbosity.loud() {
370 eprintln!("Wrote justfile to `{}`", search.justfile.display());
371 }
372
373 Ok(())
374 }
375
376 fn man() -> RunResult<'static> {
377 let mut buffer = Vec::<u8>::new();
378
379 Man::new(Config::app())
380 .render(&mut buffer)
381 .expect("writing to buffer cannot fail");
382
383 let mut stdout = io::stdout().lock();
384
385 stdout
386 .write_all(&buffer)
387 .map_err(|io_error| Error::StdoutIo { io_error })?;
388
389 stdout
390 .flush()
391 .map_err(|io_error| Error::StdoutIo { io_error })?;
392
393 Ok(())
394 }
395
396 fn list(config: &Config, mut module: &Justfile, path: &ModulePath) -> RunResult<'static> {
397 for name in &path.path {
398 module = module
399 .modules
400 .get(name)
401 .ok_or_else(|| Error::UnknownSubmodule {
402 path: path.to_string(),
403 })?;
404 }
405
406 Self::list_module(config, module, 0);
407
408 Ok(())
409 }
410
411 fn list_module(config: &Config, module: &Justfile, depth: usize) {
412 fn format_doc(
413 config: &Config,
414 name: &str,
415 doc: Option<&str>,
416 max_signature_width: usize,
417 signature_widths: &BTreeMap<&str, usize>,
418 ) {
419 if let Some(doc) = doc {
420 if !doc.is_empty() && doc.lines().count() <= 1 {
421 let color = config.color.stdout();
422 print!(
423 "{:padding$}{} ",
424 "",
425 color.doc().paint("#"),
426 padding = max_signature_width.saturating_sub(signature_widths[name]) + 1,
427 );
428
429 let mut end = 0;
430 for backtick in backtick_re().find_iter(doc) {
431 let prefix = &doc[end..backtick.start()];
432 if !prefix.is_empty() {
433 print!("{}", color.doc().paint(prefix));
434 }
435 print!("{}", color.doc_backtick().paint(backtick.as_str()));
436 end = backtick.end();
437 }
438
439 let suffix = &doc[end..];
440 if !suffix.is_empty() {
441 print!("{}", color.doc().paint(suffix));
442 }
443 }
444 }
445
446 println!();
447 }
448
449 let aliases = if config.no_aliases {
450 BTreeMap::new()
451 } else {
452 let mut aliases = BTreeMap::<&str, Vec<&str>>::new();
453 for alias in module.aliases.values().filter(|alias| !alias.is_private()) {
454 aliases
455 .entry(alias.target.name.lexeme())
456 .or_default()
457 .push(alias.name.lexeme());
458 }
459 aliases
460 };
461
462 let signature_widths = {
463 let mut signature_widths: BTreeMap<&str, usize> = BTreeMap::new();
464
465 for (name, recipe) in &module.recipes {
466 if !recipe.is_public() {
467 continue;
468 }
469
470 for name in iter::once(name).chain(aliases.get(name).unwrap_or(&Vec::new())) {
471 signature_widths.insert(
472 name,
473 UnicodeWidthStr::width(
474 RecipeSignature { name, recipe }
475 .color_display(Color::never())
476 .to_string()
477 .as_str(),
478 ),
479 );
480 }
481 }
482 if !config.list_submodules {
483 for (name, _) in &module.modules {
484 signature_widths.insert(name, UnicodeWidthStr::width(format!("{name} ...").as_str()));
485 }
486 }
487
488 signature_widths
489 };
490
491 let max_signature_width = signature_widths
492 .values()
493 .copied()
494 .filter(|width| *width <= 50)
495 .max()
496 .unwrap_or(0);
497
498 let list_prefix = config.list_prefix.repeat(depth + 1);
499
500 if depth == 0 {
501 print!("{}", config.list_heading);
502 }
503
504 let recipe_groups = {
505 let mut groups = BTreeMap::<Option<String>, Vec<&Recipe>>::new();
506 for recipe in module.public_recipes(config) {
507 let recipe_groups = recipe.groups();
508 if recipe_groups.is_empty() {
509 groups.entry(None).or_default().push(recipe);
510 } else {
511 for group in recipe_groups {
512 groups.entry(Some(group)).or_default().push(recipe);
513 }
514 }
515 }
516 groups
517 };
518
519 let submodule_groups = {
520 let mut groups = BTreeMap::<Option<String>, Vec<&Justfile>>::new();
521 for submodule in module.modules(config) {
522 let submodule_groups = submodule.groups();
523 if submodule_groups.is_empty() {
524 groups.entry(None).or_default().push(submodule);
525 } else {
526 for group in submodule_groups {
527 groups
528 .entry(Some(group.to_string()))
529 .or_default()
530 .push(submodule);
531 }
532 }
533 }
534 groups
535 };
536
537 let mut ordered_groups = module
538 .public_groups(config)
539 .into_iter()
540 .map(Some)
541 .collect::<Vec<Option<String>>>();
542
543 if recipe_groups.contains_key(&None) || submodule_groups.contains_key(&None) {
544 ordered_groups.insert(0, None);
545 }
546
547 let no_groups = ordered_groups.len() == 1 && ordered_groups.first() == Some(&None);
548 let mut groups_count = 0;
549 if !no_groups {
550 groups_count = ordered_groups.len();
551 }
552
553 for (i, group) in ordered_groups.into_iter().enumerate() {
554 if i > 0 {
555 println!();
556 }
557
558 if !no_groups {
559 if let Some(group) = &group {
560 println!(
561 "{list_prefix}{}",
562 config.color.stdout().group().paint(&format!("[{group}]"))
563 );
564 }
565 }
566
567 if let Some(recipes) = recipe_groups.get(&group) {
568 for recipe in recipes {
569 for (i, name) in iter::once(&recipe.name())
570 .chain(aliases.get(recipe.name()).unwrap_or(&Vec::new()))
571 .enumerate()
572 {
573 let doc = if i == 0 {
574 recipe.doc().map(Cow::Borrowed)
575 } else {
576 Some(Cow::Owned(format!("alias for `{}`", recipe.name)))
577 };
578
579 if let Some(doc) = &doc {
580 if doc.lines().count() > 1 {
581 for line in doc.lines() {
582 println!(
583 "{list_prefix}{} {}",
584 config.color.stdout().doc().paint("#"),
585 config.color.stdout().doc().paint(line),
586 );
587 }
588 }
589 }
590
591 print!(
592 "{list_prefix}{}",
593 RecipeSignature { name, recipe }.color_display(config.color.stdout())
594 );
595
596 format_doc(
597 config,
598 name,
599 doc.as_deref(),
600 max_signature_width,
601 &signature_widths,
602 );
603 }
604 }
605 }
606
607 if let Some(submodules) = submodule_groups.get(&group) {
608 for (i, submodule) in submodules.iter().enumerate() {
609 if config.list_submodules {
610 if no_groups && (i + groups_count > 0) {
611 println!();
612 }
613 println!("{list_prefix}{}:", submodule.name());
614
615 Self::list_module(config, submodule, depth + 1);
616 } else {
617 print!("{list_prefix}{} ...", submodule.name());
618 format_doc(
619 config,
620 submodule.name(),
621 submodule.doc.as_deref(),
622 max_signature_width,
623 &signature_widths,
624 );
625 }
626 }
627 }
628 }
629 }
630
631 fn show<'src>(
632 config: &Config,
633 mut module: &Justfile<'src>,
634 path: &ModulePath,
635 ) -> RunResult<'src> {
636 for name in &path.path[0..path.path.len() - 1] {
637 module = module
638 .modules
639 .get(name)
640 .ok_or_else(|| Error::UnknownSubmodule {
641 path: path.to_string(),
642 })?;
643 }
644
645 let name = path.path.last().unwrap();
646
647 if let Some(alias) = module.get_alias(name) {
648 let recipe = module.get_recipe(alias.target.name.lexeme()).unwrap();
649 println!("{alias}");
650 println!("{}", recipe.color_display(config.color.stdout()));
651 Ok(())
652 } else if let Some(recipe) = module.get_recipe(name) {
653 println!("{}", recipe.color_display(config.color.stdout()));
654 Ok(())
655 } else {
656 Err(Error::UnknownRecipe {
657 recipe: name.to_owned(),
658 suggestion: module.suggest_recipe(name),
659 })
660 }
661 }
662
663 fn summary(config: &Config, justfile: &Justfile) {
664 let mut printed = 0;
665 Self::summary_recursive(config, &mut Vec::new(), &mut printed, justfile);
666 println!();
667
668 if printed == 0 && config.verbosity.loud() {
669 eprintln!("Justfile contains no recipes.");
670 }
671 }
672
673 fn summary_recursive<'a>(
674 config: &Config,
675 components: &mut Vec<&'a str>,
676 printed: &mut usize,
677 justfile: &'a Justfile,
678 ) {
679 let path = components.join("::");
680
681 for recipe in justfile.public_recipes(config) {
682 if *printed > 0 {
683 print!(" ");
684 }
685 if path.is_empty() {
686 print!("{}", recipe.name());
687 } else {
688 print!("{}::{}", path, recipe.name());
689 }
690 *printed += 1;
691 }
692
693 for (name, module) in &justfile.modules {
694 components.push(name);
695 Self::summary_recursive(config, components, printed, module);
696 components.pop();
697 }
698 }
699
700 fn variables(justfile: &Justfile) {
701 for (i, (_, assignment)) in justfile
702 .assignments
703 .iter()
704 .filter(|(_, binding)| !binding.private)
705 .enumerate()
706 {
707 if i > 0 {
708 print!(" ");
709 }
710 print!("{}", assignment.name);
711 }
712 println!();
713 }
714}
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719
720 #[test]
721 fn init_justfile() {
722 testing::compile(INIT_JUSTFILE);
723 }
724}