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