pub_just/
subcommand.rs

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}