pub_just/
recipe.rs

1use super::*;
2
3/// Return a `Error::Signal` if the process was terminated by a signal,
4/// otherwise return an `Error::UnknownFailure`
5fn error_from_signal(recipe: &str, line_number: Option<usize>, exit_status: ExitStatus) -> Error {
6  match Platform::signal_from_exit_status(exit_status) {
7    Some(signal) => Error::Signal {
8      recipe,
9      line_number,
10      signal,
11    },
12    None => Error::Unknown {
13      recipe,
14      line_number,
15    },
16  }
17}
18
19/// A recipe, e.g. `foo: bar baz`
20#[derive(PartialEq, Debug, Clone, Serialize)]
21pub struct Recipe<'src, D = Dependency<'src>> {
22  pub attributes: BTreeSet<Attribute<'src>>,
23  pub body: Vec<Line<'src>>,
24  pub dependencies: Vec<D>,
25  pub doc: Option<String>,
26  #[serde(skip)]
27  pub file_depth: u32,
28  #[serde(skip)]
29  pub import_offsets: Vec<usize>,
30  pub name: Name<'src>,
31  pub namepath: Namepath<'src>,
32  pub parameters: Vec<Parameter<'src>>,
33  pub priors: usize,
34  pub private: bool,
35  pub quiet: bool,
36  pub shebang: bool,
37}
38
39impl<'src, D> Recipe<'src, D> {
40  pub fn argument_range(&self) -> RangeInclusive<usize> {
41    self.min_arguments()..=self.max_arguments()
42  }
43
44  pub fn min_arguments(&self) -> usize {
45    self
46      .parameters
47      .iter()
48      .filter(|p| p.default.is_none() && p.kind != ParameterKind::Star)
49      .count()
50  }
51
52  pub fn max_arguments(&self) -> usize {
53    if self.parameters.iter().any(|p| p.kind.is_variadic()) {
54      usize::MAX - 1
55    } else {
56      self.parameters.len()
57    }
58  }
59
60  pub fn name(&self) -> &'src str {
61    self.name.lexeme()
62  }
63
64  pub fn line_number(&self) -> usize {
65    self.name.line
66  }
67
68  pub fn confirm(&self) -> RunResult<'src, bool> {
69    for attribute in &self.attributes {
70      if let Attribute::Confirm(prompt) = attribute {
71        if let Some(prompt) = prompt {
72          eprint!("{} ", prompt.cooked);
73        } else {
74          eprint!("Run recipe `{}`? ", self.name);
75        }
76        let mut line = String::new();
77        std::io::stdin()
78          .read_line(&mut line)
79          .map_err(|io_error| Error::GetConfirmation { io_error })?;
80        let line = line.trim().to_lowercase();
81        return Ok(line == "y" || line == "yes");
82      }
83    }
84    Ok(true)
85  }
86
87  pub fn check_can_be_default_recipe(&self) -> RunResult<'src, ()> {
88    let min_arguments = self.min_arguments();
89    if min_arguments > 0 {
90      return Err(Error::DefaultRecipeRequiresArguments {
91        recipe: self.name.lexeme(),
92        min_arguments,
93      });
94    }
95
96    Ok(())
97  }
98
99  pub fn is_public(&self) -> bool {
100    !self.private && !self.attributes.contains(&Attribute::Private)
101  }
102
103  pub fn is_script(&self) -> bool {
104    self.shebang
105  }
106
107  pub fn takes_positional_arguments(&self, settings: &Settings) -> bool {
108    settings.positional_arguments || self.attributes.contains(&Attribute::PositionalArguments)
109  }
110
111  pub fn change_directory(&self) -> bool {
112    !self.attributes.contains(&Attribute::NoCd)
113  }
114
115  pub fn enabled(&self) -> bool {
116    let windows = self.attributes.contains(&Attribute::Windows);
117    let linux = self.attributes.contains(&Attribute::Linux);
118    let macos = self.attributes.contains(&Attribute::Macos);
119    let unix = self.attributes.contains(&Attribute::Unix);
120
121    (!windows && !linux && !macos && !unix)
122      || (cfg!(target_os = "windows") && windows)
123      || (cfg!(target_os = "linux") && (linux || unix))
124      || (cfg!(target_os = "macos") && (macos || unix))
125      || (cfg!(windows) && windows)
126      || (cfg!(unix) && unix)
127  }
128
129  fn print_exit_message(&self) -> bool {
130    !self.attributes.contains(&Attribute::NoExitMessage)
131  }
132
133  fn working_directory<'a>(&'a self, context: &'a ExecutionContext) -> Option<PathBuf> {
134    if self.change_directory() {
135      Some(context.working_directory())
136    } else {
137      None
138    }
139  }
140
141  fn no_quiet(&self) -> bool {
142    self.attributes.contains(&Attribute::NoQuiet)
143  }
144
145  pub fn run<'run>(
146    &self,
147    context: &ExecutionContext<'src, 'run>,
148    scope: &Scope<'src, 'run>,
149    positional: &[String],
150    is_dependency: bool,
151  ) -> RunResult<'src, ()> {
152    let config = &context.config;
153
154    let color = config.color.stderr().banner();
155    let prefix = color.prefix();
156    let suffix = color.suffix();
157
158    if config.verbosity.loquacious() {
159      eprintln!("{prefix}===> Running recipe `{}`...{suffix}", self.name);
160    }
161
162    if config.explain {
163      if let Some(doc) = self.doc() {
164        eprintln!("{prefix}#### {doc}{suffix}");
165      }
166    }
167
168    let evaluator = Evaluator::new(context, is_dependency, scope);
169
170    if self.is_script() {
171      self.run_script(context, scope, positional, config, evaluator)
172    } else {
173      self.run_linewise(context, scope, positional, config, evaluator)
174    }
175  }
176
177  fn run_linewise<'run>(
178    &self,
179    context: &ExecutionContext<'src, 'run>,
180    scope: &Scope<'src, 'run>,
181    positional: &[String],
182    config: &Config,
183    mut evaluator: Evaluator<'src, 'run>,
184  ) -> RunResult<'src, ()> {
185    let mut lines = self.body.iter().peekable();
186    let mut line_number = self.line_number() + 1;
187    loop {
188      if lines.peek().is_none() {
189        return Ok(());
190      }
191      let mut evaluated = String::new();
192      let mut continued = false;
193      let quiet_line = lines.peek().map_or(false, |line| line.is_quiet());
194      let infallible_line = lines.peek().map_or(false, |line| line.is_infallible());
195
196      let comment_line = context.module.settings.ignore_comments
197        && lines.peek().map_or(false, |line| line.is_comment());
198
199      loop {
200        if lines.peek().is_none() {
201          break;
202        }
203        let line = lines.next().unwrap();
204        line_number += 1;
205        if !comment_line {
206          evaluated += &evaluator.evaluate_line(line, continued)?;
207        }
208        if line.is_continuation() && !comment_line {
209          continued = true;
210          evaluated.pop();
211        } else {
212          break;
213        }
214      }
215
216      if comment_line {
217        continue;
218      }
219
220      let mut command = evaluated.as_str();
221
222      let sigils = usize::from(infallible_line) + usize::from(quiet_line);
223
224      command = &command[sigils..];
225
226      if command.is_empty() {
227        continue;
228      }
229
230      if config.dry_run
231        || config.verbosity.loquacious()
232        || !((quiet_line ^ self.quiet)
233          || (context.module.settings.quiet && !self.no_quiet())
234          || config.verbosity.quiet())
235      {
236        let color = config
237          .highlight
238          .then(|| config.color.command(config.command_color))
239          .unwrap_or(config.color)
240          .stderr();
241
242        if config.timestamp {
243          eprint!(
244            "[{}] ",
245            color.paint(
246              &chrono::Local::now()
247                .format(&config.timestamp_format)
248                .to_string()
249            ),
250          );
251        }
252
253        eprintln!("{}", color.paint(command));
254      }
255
256      if config.dry_run {
257        continue;
258      }
259
260      let mut cmd = context.module.settings.shell_command(config);
261
262      if let Some(working_directory) = self.working_directory(context) {
263        cmd.current_dir(working_directory);
264      }
265
266      cmd.arg(command);
267
268      if self.takes_positional_arguments(&context.module.settings) {
269        cmd.arg(self.name.lexeme());
270        cmd.args(positional);
271      }
272
273      if config.verbosity.quiet() {
274        cmd.stderr(Stdio::null());
275        cmd.stdout(Stdio::null());
276      }
277
278      cmd.export(
279        &context.module.settings,
280        context.dotenv,
281        scope,
282        &context.module.unexports,
283      );
284
285      match InterruptHandler::guard(|| cmd.status()) {
286        Ok(exit_status) => {
287          if let Some(code) = exit_status.code() {
288            if code != 0 && !infallible_line {
289              return Err(Error::Code {
290                recipe: self.name(),
291                line_number: Some(line_number),
292                code,
293                print_message: self.print_exit_message(),
294              });
295            }
296          } else {
297            return Err(error_from_signal(
298              self.name(),
299              Some(line_number),
300              exit_status,
301            ));
302          }
303        }
304        Err(io_error) => {
305          return Err(Error::Io {
306            recipe: self.name(),
307            io_error,
308          });
309        }
310      };
311    }
312  }
313
314  pub fn run_script<'run>(
315    &self,
316    context: &ExecutionContext<'src, 'run>,
317    scope: &Scope<'src, 'run>,
318    positional: &[String],
319    config: &Config,
320    mut evaluator: Evaluator<'src, 'run>,
321  ) -> RunResult<'src, ()> {
322    let mut evaluated_lines = Vec::new();
323    for line in &self.body {
324      evaluated_lines.push(evaluator.evaluate_line(line, false)?);
325    }
326
327    if config.verbosity.loud() && (config.dry_run || self.quiet) {
328      for line in &evaluated_lines {
329        eprintln!(
330          "{}",
331          config
332            .color
333            .command(config.command_color)
334            .stderr()
335            .paint(line)
336        );
337      }
338    }
339
340    if config.dry_run {
341      return Ok(());
342    }
343
344    let executor = if let Some(Attribute::Script(interpreter)) = self
345      .attributes
346      .iter()
347      .find(|attribute| matches!(attribute, Attribute::Script(_)))
348    {
349      Executor::Command(
350        interpreter
351          .as_ref()
352          .or(context.module.settings.script_interpreter.as_ref())
353          .unwrap_or_else(|| Interpreter::default_script_interpreter()),
354      )
355    } else {
356      let line = evaluated_lines
357        .first()
358        .ok_or_else(|| Error::internal("evaluated_lines was empty"))?;
359
360      let shebang =
361        Shebang::new(line).ok_or_else(|| Error::internal(format!("bad shebang line: {line}")))?;
362
363      Executor::Shebang(shebang)
364    };
365
366    let mut tempdir_builder = tempfile::Builder::new();
367    tempdir_builder.prefix("just-");
368    let tempdir = match &context.module.settings.tempdir {
369      Some(tempdir) => tempdir_builder.tempdir_in(context.search.working_directory.join(tempdir)),
370      None => {
371        if let Some(runtime_dir) = dirs::runtime_dir() {
372          let path = runtime_dir.join("just");
373          fs::create_dir_all(&path).map_err(|io_error| Error::RuntimeDirIo {
374            io_error,
375            path: path.clone(),
376          })?;
377          tempdir_builder.tempdir_in(path)
378        } else {
379          tempdir_builder.tempdir()
380        }
381      }
382    }
383    .map_err(|error| Error::TempdirIo {
384      recipe: self.name(),
385      io_error: error,
386    })?;
387    let mut path = tempdir.path().to_path_buf();
388
389    let extension = self.attributes.iter().find_map(|attribute| {
390      if let Attribute::Extension(extension) = attribute {
391        Some(extension.cooked.as_str())
392      } else {
393        None
394      }
395    });
396
397    path.push(executor.script_filename(self.name(), extension));
398
399    let script = executor.script(self, &evaluated_lines);
400
401    if config.verbosity.grandiloquent() {
402      eprintln!("{}", config.color.doc().stderr().paint(&script));
403    }
404
405    fs::write(&path, script).map_err(|error| Error::TempdirIo {
406      recipe: self.name(),
407      io_error: error,
408    })?;
409
410    let mut command = executor.command(
411      &path,
412      self.name(),
413      self.working_directory(context).as_deref(),
414    )?;
415
416    if self.takes_positional_arguments(&context.module.settings) {
417      command.args(positional);
418    }
419
420    command.export(
421      &context.module.settings,
422      context.dotenv,
423      scope,
424      &context.module.unexports,
425    );
426
427    // run it!
428    match InterruptHandler::guard(|| command.status()) {
429      Ok(exit_status) => exit_status.code().map_or_else(
430        || Err(error_from_signal(self.name(), None, exit_status)),
431        |code| {
432          if code == 0 {
433            Ok(())
434          } else {
435            Err(Error::Code {
436              recipe: self.name(),
437              line_number: None,
438              code,
439              print_message: self.print_exit_message(),
440            })
441          }
442        },
443      ),
444      Err(io_error) => Err(executor.error(io_error, self.name())),
445    }
446  }
447
448  pub fn groups(&self) -> BTreeSet<String> {
449    self
450      .attributes
451      .iter()
452      .filter_map(|attribute| {
453        if let Attribute::Group(group) = attribute {
454          Some(group.cooked.clone())
455        } else {
456          None
457        }
458      })
459      .collect()
460  }
461
462  pub fn doc(&self) -> Option<&str> {
463    for attribute in &self.attributes {
464      if let Attribute::Doc(doc) = attribute {
465        return doc.as_ref().map(|s| s.cooked.as_ref());
466      }
467    }
468
469    self.doc.as_deref()
470  }
471
472  pub fn subsequents(&self) -> impl Iterator<Item = &D> {
473    self.dependencies.iter().skip(self.priors)
474  }
475}
476
477impl<'src, D: Display> ColorDisplay for Recipe<'src, D> {
478  fn fmt(&self, f: &mut Formatter, color: Color) -> fmt::Result {
479    if !self
480      .attributes
481      .iter()
482      .any(|attribute| matches!(attribute, Attribute::Doc(_)))
483    {
484      if let Some(doc) = &self.doc {
485        writeln!(f, "# {doc}")?;
486      }
487    }
488
489    for attribute in &self.attributes {
490      writeln!(f, "[{attribute}]")?;
491    }
492
493    if self.quiet {
494      write!(f, "@{}", self.name)?;
495    } else {
496      write!(f, "{}", self.name)?;
497    }
498
499    for parameter in &self.parameters {
500      write!(f, " {}", parameter.color_display(color))?;
501    }
502    write!(f, ":")?;
503
504    for (i, dependency) in self.dependencies.iter().enumerate() {
505      if i == self.priors {
506        write!(f, " &&")?;
507      }
508
509      write!(f, " {dependency}")?;
510    }
511
512    for (i, line) in self.body.iter().enumerate() {
513      if i == 0 {
514        writeln!(f)?;
515      }
516      for (j, fragment) in line.fragments.iter().enumerate() {
517        if j == 0 {
518          write!(f, "    ")?;
519        }
520        match fragment {
521          Fragment::Text { token } => write!(f, "{}", token.lexeme())?,
522          Fragment::Interpolation { expression, .. } => write!(f, "{{{{ {expression} }}}}")?,
523        }
524      }
525      if i + 1 < self.body.len() {
526        writeln!(f)?;
527      }
528    }
529    Ok(())
530  }
531}
532
533impl<'src, D> Keyed<'src> for Recipe<'src, D> {
534  fn key(&self) -> &'src str {
535    self.name.lexeme()
536  }
537}