pub_just/
function.rs

1use {
2  super::*,
3  heck::{
4    ToKebabCase, ToLowerCamelCase, ToShoutyKebabCase, ToShoutySnakeCase, ToSnakeCase, ToTitleCase,
5    ToUpperCamelCase,
6  },
7  rand::{seq::SliceRandom, thread_rng},
8  semver::{Version, VersionReq},
9  std::collections::HashSet,
10  Function::*,
11};
12
13pub enum Function {
14  Nullary(fn(Context) -> FunctionResult),
15  Unary(fn(Context, &str) -> FunctionResult),
16  UnaryOpt(fn(Context, &str, Option<&str>) -> FunctionResult),
17  UnaryPlus(fn(Context, &str, &[String]) -> FunctionResult),
18  Binary(fn(Context, &str, &str) -> FunctionResult),
19  BinaryPlus(fn(Context, &str, &str, &[String]) -> FunctionResult),
20  Ternary(fn(Context, &str, &str, &str) -> FunctionResult),
21}
22
23pub struct Context<'src: 'run, 'run> {
24  pub evaluator: &'run Evaluator<'src, 'run>,
25  pub name: Name<'src>,
26}
27
28impl<'src: 'run, 'run> Context<'src, 'run> {
29  pub fn new(evaluator: &'run Evaluator<'src, 'run>, name: Name<'src>) -> Self {
30    Self { evaluator, name }
31  }
32}
33
34pub fn get(name: &str) -> Option<Function> {
35  let name = if let Some(prefix) = name.strip_suffix("_dir") {
36    format!("{prefix}_directory")
37  } else if let Some(prefix) = name.strip_suffix("_dir_native") {
38    format!("{prefix}_directory_native")
39  } else {
40    name.into()
41  };
42
43  let function = match name.as_str() {
44    "absolute_path" => Unary(absolute_path),
45    "append" => Binary(append),
46    "arch" => Nullary(arch),
47    "blake3" => Unary(blake3),
48    "blake3_file" => Unary(blake3_file),
49    "cache_directory" => Nullary(|_| dir("cache", dirs::cache_dir)),
50    "canonicalize" => Unary(canonicalize),
51    "capitalize" => Unary(capitalize),
52    "choose" => Binary(choose),
53    "clean" => Unary(clean),
54    "config_directory" => Nullary(|_| dir("config", dirs::config_dir)),
55    "config_local_directory" => Nullary(|_| dir("local config", dirs::config_local_dir)),
56    "data_directory" => Nullary(|_| dir("data", dirs::data_dir)),
57    "data_local_directory" => Nullary(|_| dir("local data", dirs::data_local_dir)),
58    "datetime" => Unary(datetime),
59    "datetime_utc" => Unary(datetime_utc),
60    "encode_uri_component" => Unary(encode_uri_component),
61    "env" => UnaryOpt(env),
62    "env_var" => Unary(env_var),
63    "env_var_or_default" => Binary(env_var_or_default),
64    "error" => Unary(error),
65    "executable_directory" => Nullary(|_| dir("executable", dirs::executable_dir)),
66    "extension" => Unary(extension),
67    "file_name" => Unary(file_name),
68    "file_stem" => Unary(file_stem),
69    "home_directory" => Nullary(|_| dir("home", dirs::home_dir)),
70    "invocation_directory" => Nullary(invocation_directory),
71    "invocation_directory_native" => Nullary(invocation_directory_native),
72    "is_dependency" => Nullary(is_dependency),
73    "join" => BinaryPlus(join),
74    "just_executable" => Nullary(just_executable),
75    "just_pid" => Nullary(just_pid),
76    "justfile" => Nullary(justfile),
77    "justfile_directory" => Nullary(justfile_directory),
78    "kebabcase" => Unary(kebabcase),
79    "lowercamelcase" => Unary(lowercamelcase),
80    "lowercase" => Unary(lowercase),
81    "module_directory" => Nullary(module_directory),
82    "module_file" => Nullary(module_file),
83    "num_cpus" => Nullary(num_cpus),
84    "os" => Nullary(os),
85    "os_family" => Nullary(os_family),
86    "parent_directory" => Unary(parent_directory),
87    "path_exists" => Unary(path_exists),
88    "prepend" => Binary(prepend),
89    "quote" => Unary(quote),
90    "replace" => Ternary(replace),
91    "replace_regex" => Ternary(replace_regex),
92    "semver_matches" => Binary(semver_matches),
93    "sha256" => Unary(sha256),
94    "sha256_file" => Unary(sha256_file),
95    "shell" => UnaryPlus(shell),
96    "shoutykebabcase" => Unary(shoutykebabcase),
97    "shoutysnakecase" => Unary(shoutysnakecase),
98    "snakecase" => Unary(snakecase),
99    "source_directory" => Nullary(source_directory),
100    "source_file" => Nullary(source_file),
101    "style" => Unary(style),
102    "titlecase" => Unary(titlecase),
103    "trim" => Unary(trim),
104    "trim_end" => Unary(trim_end),
105    "trim_end_match" => Binary(trim_end_match),
106    "trim_end_matches" => Binary(trim_end_matches),
107    "trim_start" => Unary(trim_start),
108    "trim_start_match" => Binary(trim_start_match),
109    "trim_start_matches" => Binary(trim_start_matches),
110    "uppercamelcase" => Unary(uppercamelcase),
111    "uppercase" => Unary(uppercase),
112    "uuid" => Nullary(uuid),
113    "without_extension" => Unary(without_extension),
114    _ => return None,
115  };
116  Some(function)
117}
118
119impl Function {
120  pub fn argc(&self) -> RangeInclusive<usize> {
121    match *self {
122      Nullary(_) => 0..=0,
123      Unary(_) => 1..=1,
124      UnaryOpt(_) => 1..=2,
125      UnaryPlus(_) => 1..=usize::MAX,
126      Binary(_) => 2..=2,
127      BinaryPlus(_) => 2..=usize::MAX,
128      Ternary(_) => 3..=3,
129    }
130  }
131}
132
133fn absolute_path(context: Context, path: &str) -> FunctionResult {
134  let abs_path_unchecked = context
135    .evaluator
136    .context
137    .working_directory()
138    .join(path)
139    .lexiclean();
140  match abs_path_unchecked.to_str() {
141    Some(absolute_path) => Ok(absolute_path.to_owned()),
142    None => Err(format!(
143      "Working directory is not valid unicode: {}",
144      context.evaluator.context.search.working_directory.display()
145    )),
146  }
147}
148
149fn append(_context: Context, suffix: &str, s: &str) -> FunctionResult {
150  Ok(
151    s.split_whitespace()
152      .map(|s| format!("{s}{suffix}"))
153      .collect::<Vec<String>>()
154      .join(" "),
155  )
156}
157
158fn arch(_context: Context) -> FunctionResult {
159  Ok(target::arch().to_owned())
160}
161
162fn blake3(_context: Context, s: &str) -> FunctionResult {
163  Ok(blake3::hash(s.as_bytes()).to_string())
164}
165
166fn blake3_file(context: Context, path: &str) -> FunctionResult {
167  let path = context.evaluator.context.working_directory().join(path);
168  let mut hasher = blake3::Hasher::new();
169  hasher
170    .update_mmap_rayon(&path)
171    .map_err(|err| format!("Failed to hash `{}`: {err}", path.display()))?;
172  Ok(hasher.finalize().to_string())
173}
174
175fn canonicalize(context: Context, path: &str) -> FunctionResult {
176  let canonical = std::fs::canonicalize(context.evaluator.context.working_directory().join(path))
177    .map_err(|err| format!("I/O error canonicalizing path: {err}"))?;
178
179  canonical.to_str().map(str::to_string).ok_or_else(|| {
180    format!(
181      "Canonical path is not valid unicode: {}",
182      canonical.display(),
183    )
184  })
185}
186
187fn capitalize(_context: Context, s: &str) -> FunctionResult {
188  let mut capitalized = String::new();
189  for (i, c) in s.chars().enumerate() {
190    if i == 0 {
191      capitalized.extend(c.to_uppercase());
192    } else {
193      capitalized.extend(c.to_lowercase());
194    }
195  }
196  Ok(capitalized)
197}
198
199fn choose(_context: Context, n: &str, alphabet: &str) -> FunctionResult {
200  if alphabet.is_empty() {
201    return Err("empty alphabet".into());
202  }
203
204  let mut chars = HashSet::<char>::with_capacity(alphabet.len());
205
206  for c in alphabet.chars() {
207    if !chars.insert(c) {
208      return Err(format!("alphabet contains repeated character `{c}`"));
209    }
210  }
211
212  let alphabet = alphabet.chars().collect::<Vec<char>>();
213
214  let n = n
215    .parse::<usize>()
216    .map_err(|err| format!("failed to parse `{n}` as positive integer: {err}"))?;
217
218  let mut rng = thread_rng();
219
220  Ok((0..n).map(|_| alphabet.choose(&mut rng).unwrap()).collect())
221}
222
223fn clean(_context: Context, path: &str) -> FunctionResult {
224  Ok(Path::new(path).lexiclean().to_str().unwrap().to_owned())
225}
226
227fn dir(name: &'static str, f: fn() -> Option<PathBuf>) -> FunctionResult {
228  match f() {
229    Some(path) => path
230      .as_os_str()
231      .to_str()
232      .map(str::to_string)
233      .ok_or_else(|| {
234        format!(
235          "unable to convert {name} directory path to string: {}",
236          path.display(),
237        )
238      }),
239    None => Err(format!("{name} directory not found")),
240  }
241}
242
243fn datetime(_context: Context, format: &str) -> FunctionResult {
244  Ok(chrono::Local::now().format(format).to_string())
245}
246
247fn datetime_utc(_context: Context, format: &str) -> FunctionResult {
248  Ok(chrono::Utc::now().format(format).to_string())
249}
250
251fn encode_uri_component(_context: Context, s: &str) -> FunctionResult {
252  static PERCENT_ENCODE: percent_encoding::AsciiSet = percent_encoding::NON_ALPHANUMERIC
253    .remove(b'-')
254    .remove(b'_')
255    .remove(b'.')
256    .remove(b'!')
257    .remove(b'~')
258    .remove(b'*')
259    .remove(b'\'')
260    .remove(b'(')
261    .remove(b')');
262  Ok(percent_encoding::utf8_percent_encode(s, &PERCENT_ENCODE).to_string())
263}
264
265fn env_var(context: Context, key: &str) -> FunctionResult {
266  use std::env::VarError::*;
267
268  if let Some(value) = context.evaluator.context.dotenv.get(key) {
269    return Ok(value.clone());
270  }
271
272  match env::var(key) {
273    Err(NotPresent) => Err(format!("environment variable `{key}` not present")),
274    Err(NotUnicode(os_string)) => Err(format!(
275      "environment variable `{key}` not unicode: {os_string:?}"
276    )),
277    Ok(value) => Ok(value),
278  }
279}
280
281fn env_var_or_default(context: Context, key: &str, default: &str) -> FunctionResult {
282  use std::env::VarError::*;
283
284  if let Some(value) = context.evaluator.context.dotenv.get(key) {
285    return Ok(value.clone());
286  }
287
288  match env::var(key) {
289    Err(NotPresent) => Ok(default.to_owned()),
290    Err(NotUnicode(os_string)) => Err(format!(
291      "environment variable `{key}` not unicode: {os_string:?}"
292    )),
293    Ok(value) => Ok(value),
294  }
295}
296
297fn env(context: Context, key: &str, default: Option<&str>) -> FunctionResult {
298  match default {
299    Some(val) => env_var_or_default(context, key, val),
300    None => env_var(context, key),
301  }
302}
303
304fn error(_context: Context, message: &str) -> FunctionResult {
305  Err(message.to_owned())
306}
307
308fn extension(_context: Context, path: &str) -> FunctionResult {
309  Utf8Path::new(path)
310    .extension()
311    .map(str::to_owned)
312    .ok_or_else(|| format!("Could not extract extension from `{path}`"))
313}
314
315fn file_name(_context: Context, path: &str) -> FunctionResult {
316  Utf8Path::new(path)
317    .file_name()
318    .map(str::to_owned)
319    .ok_or_else(|| format!("Could not extract file name from `{path}`"))
320}
321
322fn file_stem(_context: Context, path: &str) -> FunctionResult {
323  Utf8Path::new(path)
324    .file_stem()
325    .map(str::to_owned)
326    .ok_or_else(|| format!("Could not extract file stem from `{path}`"))
327}
328
329fn invocation_directory(context: Context) -> FunctionResult {
330  Platform::convert_native_path(
331    &context.evaluator.context.search.working_directory,
332    &context.evaluator.context.config.invocation_directory,
333  )
334  .map_err(|e| format!("Error getting shell path: {e}"))
335}
336
337fn invocation_directory_native(context: Context) -> FunctionResult {
338  context
339    .evaluator
340    .context
341    .config
342    .invocation_directory
343    .to_str()
344    .map(str::to_owned)
345    .ok_or_else(|| {
346      format!(
347        "Invocation directory is not valid unicode: {}",
348        context
349          .evaluator
350          .context
351          .config
352          .invocation_directory
353          .display()
354      )
355    })
356}
357
358fn is_dependency(context: Context) -> FunctionResult {
359  Ok(context.evaluator.is_dependency.to_string())
360}
361
362fn prepend(_context: Context, prefix: &str, s: &str) -> FunctionResult {
363  Ok(
364    s.split_whitespace()
365      .map(|s| format!("{prefix}{s}"))
366      .collect::<Vec<String>>()
367      .join(" "),
368  )
369}
370
371fn join(_context: Context, base: &str, with: &str, and: &[String]) -> FunctionResult {
372  let mut result = Utf8Path::new(base).join(with);
373  for arg in and {
374    result.push(arg);
375  }
376  Ok(result.to_string())
377}
378
379fn just_executable(_context: Context) -> FunctionResult {
380  let exe_path =
381    env::current_exe().map_err(|e| format!("Error getting current executable: {e}"))?;
382
383  exe_path.to_str().map(str::to_owned).ok_or_else(|| {
384    format!(
385      "Executable path is not valid unicode: {}",
386      exe_path.display()
387    )
388  })
389}
390
391fn just_pid(_context: Context) -> FunctionResult {
392  Ok(std::process::id().to_string())
393}
394
395fn justfile(context: Context) -> FunctionResult {
396  context
397    .evaluator
398    .context
399    .search
400    .justfile
401    .to_str()
402    .map(str::to_owned)
403    .ok_or_else(|| {
404      format!(
405        "Justfile path is not valid unicode: {}",
406        context.evaluator.context.search.justfile.display()
407      )
408    })
409}
410
411fn justfile_directory(context: Context) -> FunctionResult {
412  let justfile_directory = context
413    .evaluator
414    .context
415    .search
416    .justfile
417    .parent()
418    .ok_or_else(|| {
419      format!(
420        "Could not resolve justfile directory. Justfile `{}` had no parent.",
421        context.evaluator.context.search.justfile.display()
422      )
423    })?;
424
425  justfile_directory
426    .to_str()
427    .map(str::to_owned)
428    .ok_or_else(|| {
429      format!(
430        "Justfile directory is not valid unicode: {}",
431        justfile_directory.display()
432      )
433    })
434}
435
436fn kebabcase(_context: Context, s: &str) -> FunctionResult {
437  Ok(s.to_kebab_case())
438}
439
440fn lowercamelcase(_context: Context, s: &str) -> FunctionResult {
441  Ok(s.to_lower_camel_case())
442}
443
444fn lowercase(_context: Context, s: &str) -> FunctionResult {
445  Ok(s.to_lowercase())
446}
447
448fn module_directory(context: Context) -> FunctionResult {
449  context
450    .evaluator
451    .context
452    .search
453    .justfile
454    .parent()
455    .unwrap()
456    .join(&context.evaluator.context.module.source)
457    .parent()
458    .unwrap()
459    .to_str()
460    .map(str::to_owned)
461    .ok_or_else(|| {
462      format!(
463        "Module directory is not valid unicode: {}",
464        context
465          .evaluator
466          .context
467          .module
468          .source
469          .parent()
470          .unwrap()
471          .display(),
472      )
473    })
474}
475
476fn module_file(context: Context) -> FunctionResult {
477  context
478    .evaluator
479    .context
480    .search
481    .justfile
482    .parent()
483    .unwrap()
484    .join(&context.evaluator.context.module.source)
485    .to_str()
486    .map(str::to_owned)
487    .ok_or_else(|| {
488      format!(
489        "Module file path is not valid unicode: {}",
490        context.evaluator.context.module.source.display(),
491      )
492    })
493}
494
495fn num_cpus(_context: Context) -> FunctionResult {
496  let num = num_cpus::get();
497  Ok(num.to_string())
498}
499
500fn os(_context: Context) -> FunctionResult {
501  Ok(target::os().to_owned())
502}
503
504fn os_family(_context: Context) -> FunctionResult {
505  Ok(target::family().to_owned())
506}
507
508fn parent_directory(_context: Context, path: &str) -> FunctionResult {
509  Utf8Path::new(path)
510    .parent()
511    .map(Utf8Path::to_string)
512    .ok_or_else(|| format!("Could not extract parent directory from `{path}`"))
513}
514
515fn path_exists(context: Context, path: &str) -> FunctionResult {
516  Ok(
517    context
518      .evaluator
519      .context
520      .working_directory()
521      .join(path)
522      .exists()
523      .to_string(),
524  )
525}
526
527fn quote(_context: Context, s: &str) -> FunctionResult {
528  Ok(format!("'{}'", s.replace('\'', "'\\''")))
529}
530
531fn replace(_context: Context, s: &str, from: &str, to: &str) -> FunctionResult {
532  Ok(s.replace(from, to))
533}
534
535fn replace_regex(_context: Context, s: &str, regex: &str, replacement: &str) -> FunctionResult {
536  Ok(
537    Regex::new(regex)
538      .map_err(|err| err.to_string())?
539      .replace_all(s, replacement)
540      .to_string(),
541  )
542}
543
544fn sha256(_context: Context, s: &str) -> FunctionResult {
545  use sha2::{Digest, Sha256};
546  let mut hasher = Sha256::new();
547  hasher.update(s);
548  let hash = hasher.finalize();
549  Ok(format!("{hash:x}"))
550}
551
552fn sha256_file(context: Context, path: &str) -> FunctionResult {
553  use sha2::{Digest, Sha256};
554  let path = context.evaluator.context.working_directory().join(path);
555  let mut hasher = Sha256::new();
556  let mut file =
557    fs::File::open(&path).map_err(|err| format!("Failed to open `{}`: {err}", path.display()))?;
558  std::io::copy(&mut file, &mut hasher)
559    .map_err(|err| format!("Failed to read `{}`: {err}", path.display()))?;
560  let hash = hasher.finalize();
561  Ok(format!("{hash:x}"))
562}
563
564fn shell(context: Context, command: &str, args: &[String]) -> FunctionResult {
565  let args = iter::once(command)
566    .chain(args.iter().map(String::as_str))
567    .collect::<Vec<&str>>();
568
569  context
570    .evaluator
571    .run_command(command, &args)
572    .map_err(|output_error| output_error.to_string())
573}
574
575fn shoutykebabcase(_context: Context, s: &str) -> FunctionResult {
576  Ok(s.to_shouty_kebab_case())
577}
578
579fn shoutysnakecase(_context: Context, s: &str) -> FunctionResult {
580  Ok(s.to_shouty_snake_case())
581}
582
583fn snakecase(_context: Context, s: &str) -> FunctionResult {
584  Ok(s.to_snake_case())
585}
586
587fn source_directory(context: Context) -> FunctionResult {
588  context
589    .evaluator
590    .context
591    .search
592    .justfile
593    .parent()
594    .unwrap()
595    .join(context.name.token.path)
596    .parent()
597    .unwrap()
598    .to_str()
599    .map(str::to_owned)
600    .ok_or_else(|| {
601      format!(
602        "Source file path not valid unicode: {}",
603        context.name.token.path.display(),
604      )
605    })
606}
607
608fn source_file(context: Context) -> FunctionResult {
609  context
610    .evaluator
611    .context
612    .search
613    .justfile
614    .parent()
615    .unwrap()
616    .join(context.name.token.path)
617    .to_str()
618    .map(str::to_owned)
619    .ok_or_else(|| {
620      format!(
621        "Source file path not valid unicode: {}",
622        context.name.token.path.display(),
623      )
624    })
625}
626
627fn style(context: Context, s: &str) -> FunctionResult {
628  match s {
629    "command" => Ok(
630      Color::always()
631        .command(context.evaluator.context.config.command_color)
632        .prefix()
633        .to_string(),
634    ),
635    "error" => Ok(Color::always().error().prefix().to_string()),
636    "warning" => Ok(Color::always().warning().prefix().to_string()),
637    _ => Err(format!("unknown style: `{s}`")),
638  }
639}
640
641fn titlecase(_context: Context, s: &str) -> FunctionResult {
642  Ok(s.to_title_case())
643}
644
645fn trim(_context: Context, s: &str) -> FunctionResult {
646  Ok(s.trim().to_owned())
647}
648
649fn trim_end(_context: Context, s: &str) -> FunctionResult {
650  Ok(s.trim_end().to_owned())
651}
652
653fn trim_end_match(_context: Context, s: &str, pat: &str) -> FunctionResult {
654  Ok(s.strip_suffix(pat).unwrap_or(s).to_owned())
655}
656
657fn trim_end_matches(_context: Context, s: &str, pat: &str) -> FunctionResult {
658  Ok(s.trim_end_matches(pat).to_owned())
659}
660
661fn trim_start(_context: Context, s: &str) -> FunctionResult {
662  Ok(s.trim_start().to_owned())
663}
664
665fn trim_start_match(_context: Context, s: &str, pat: &str) -> FunctionResult {
666  Ok(s.strip_prefix(pat).unwrap_or(s).to_owned())
667}
668
669fn trim_start_matches(_context: Context, s: &str, pat: &str) -> FunctionResult {
670  Ok(s.trim_start_matches(pat).to_owned())
671}
672
673fn uppercamelcase(_context: Context, s: &str) -> FunctionResult {
674  Ok(s.to_upper_camel_case())
675}
676
677fn uppercase(_context: Context, s: &str) -> FunctionResult {
678  Ok(s.to_uppercase())
679}
680
681fn uuid(_context: Context) -> FunctionResult {
682  Ok(uuid::Uuid::new_v4().to_string())
683}
684
685fn without_extension(_context: Context, path: &str) -> FunctionResult {
686  let parent = Utf8Path::new(path)
687    .parent()
688    .ok_or_else(|| format!("Could not extract parent from `{path}`"))?;
689
690  let file_stem = Utf8Path::new(path)
691    .file_stem()
692    .ok_or_else(|| format!("Could not extract file stem from `{path}`"))?;
693
694  Ok(parent.join(file_stem).to_string())
695}
696
697/// Check whether a string processes properly as semver (e.x. "0.1.0")
698/// and matches a given semver requirement (e.x. ">=0.1.0")
699fn semver_matches(_context: Context, version: &str, requirement: &str) -> FunctionResult {
700  Ok(
701    requirement
702      .parse::<VersionReq>()
703      .map_err(|err| format!("invalid semver requirement: {err}"))?
704      .matches(
705        &version
706          .parse::<Version>()
707          .map_err(|err| format!("invalid semver version: {err}"))?,
708      )
709      .to_string(),
710  )
711}
712
713#[cfg(test)]
714mod tests {
715  use super::*;
716
717  #[test]
718  fn dir_not_found() {
719    assert_eq!(dir("foo", || None).unwrap_err(), "foo directory not found");
720  }
721
722  #[cfg(unix)]
723  #[test]
724  fn dir_not_unicode() {
725    use std::os::unix::ffi::OsStrExt;
726    assert_eq!(
727      dir("foo", || Some(
728        std::ffi::OsStr::from_bytes(b"\xe0\x80\x80").into()
729      ))
730      .unwrap_err(),
731      "unable to convert foo directory path to string: ���",
732    );
733  }
734}