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
697fn 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}