diff --git a/Cargo.toml b/Cargo.toml
index 5ac4ca8..cf03201 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,7 +12,7 @@ keywords = ["command-line", "task", "runner", "development", "utility"]
license = "CC0-1.0"
readme = "crates-io-readme.md"
repository = "https://github.com/casey/just"
-rust-version = "1.63"
+rust-version = "1.70"
[workspace]
members = [".", "crates/*"]
diff --git a/README.md b/README.md
index bf52875..06a51c5 100644
--- a/README.md
+++ b/README.md
@@ -1541,6 +1541,26 @@ and are implemented with the
- `executable_directory()` - The user-specific executable directory.
- `home_directory()` - The user's home directory.
+### Constants
+
+A number of constants are predefined:
+
+| Name | Value |
+|------|-------------|
+| `HEX`master | `"0123456789abcdef"` |
+| `HEXLOWER`master | `"0123456789abcdef"` |
+| `HEXUPPER`master | `"0123456789ABCDEF"` |
+
+```just
+@foo:
+ echo {{HEX}}
+```
+
+```sh
+$ just foo
+0123456789abcdef
+```
+
### Recipe Attributes
Recipes may be annotated with attributes that change their behavior.
diff --git a/src/assignment_resolver.rs b/src/assignment_resolver.rs
index 1835b4f..04a8936 100644
--- a/src/assignment_resolver.rs
+++ b/src/assignment_resolver.rs
@@ -128,7 +128,7 @@ impl<'src: 'run, 'run> AssignmentResolver<'src, 'run> {
Expression::StringLiteral { .. } | Expression::Backtick { .. } => Ok(()),
Expression::Variable { name } => {
let variable = name.lexeme();
- if self.evaluated.contains(variable) {
+ if self.evaluated.contains(variable) || constants().contains_key(variable) {
Ok(())
} else if self.stack.contains(&variable) {
self.stack.push(variable);
diff --git a/src/constants.rs b/src/constants.rs
new file mode 100644
index 0000000..e9007ea
--- /dev/null
+++ b/src/constants.rs
@@ -0,0 +1,15 @@
+use super::*;
+
+pub(crate) fn constants() -> &'static HashMap<&'static str, &'static str> {
+ static CONSTANTS: OnceLock> = OnceLock::new();
+
+ CONSTANTS.get_or_init(|| {
+ vec![
+ ("HEX", "0123456789abcdef"),
+ ("HEXLOWER", "0123456789abcdef"),
+ ("HEXUPPER", "0123456789ABCDEF"),
+ ]
+ .into_iter()
+ .collect()
+ })
+}
diff --git a/src/justfile.rs b/src/justfile.rs
index f8f2455..30985bf 100644
--- a/src/justfile.rs
+++ b/src/justfile.rs
@@ -135,7 +135,7 @@ impl<'src> Justfile<'src> {
BTreeMap::new()
};
- let root = Scope::new();
+ let root = Scope::root();
let scope = self.scope(config, &dotenv, search, overrides, &root)?;
diff --git a/src/lexer.rs b/src/lexer.rs
index 4c25223..adbad1b 100644
--- a/src/lexer.rs
+++ b/src/lexer.rs
@@ -231,11 +231,8 @@ impl<'src> Lexer<'src> {
// The width of the error site to highlight depends on the kind of error:
let length = match kind {
UnterminatedString | UnterminatedBacktick => {
- let kind = match StringKind::from_token_start(self.lexeme()) {
- Some(kind) => kind,
- None => {
- return self.internal_error("Lexer::error: expected string or backtick token start")
- }
+ let Some(kind) = StringKind::from_token_start(self.lexeme()) else {
+ return self.internal_error("Lexer::error: expected string or backtick token start");
};
kind.delimiter().len()
}
@@ -813,9 +810,7 @@ impl<'src> Lexer<'src> {
/// Cooked string: "[^"]*" # also processes escape sequences
/// Raw string: '[^']*'
fn lex_string(&mut self) -> CompileResult<'src> {
- let kind = if let Some(kind) = StringKind::from_token_start(self.rest()) {
- kind
- } else {
+ let Some(kind) = StringKind::from_token_start(self.rest()) else {
self.advance()?;
return Err(self.internal_error("Lexer::lex_string: invalid string start"));
};
diff --git a/src/lib.rs b/src/lib.rs
index f3f0d0f..8616e13 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -20,9 +20,9 @@ pub(crate) use {
color::Color, color_display::ColorDisplay, command_ext::CommandExt, compilation::Compilation,
compile_error::CompileError, compile_error_kind::CompileErrorKind, compiler::Compiler,
condition::Condition, conditional_operator::ConditionalOperator, config::Config,
- config_error::ConfigError, count::Count, delimiter::Delimiter, dependency::Dependency,
- dump_format::DumpFormat, enclosure::Enclosure, error::Error, evaluator::Evaluator,
- expression::Expression, fragment::Fragment, function::Function,
+ config_error::ConfigError, constants::constants, count::Count, delimiter::Delimiter,
+ dependency::Dependency, dump_format::DumpFormat, enclosure::Enclosure, error::Error,
+ evaluator::Evaluator, expression::Expression, fragment::Fragment, function::Function,
interrupt_guard::InterruptGuard, interrupt_handler::InterruptHandler, item::Item,
justfile::Justfile, keyed::Keyed, keyword::Keyword, lexer::Lexer, line::Line, list::List,
load_dotenv::load_dotenv, loader::Loader, name::Name, namepath::Namepath, ordinal::Ordinal,
@@ -53,7 +53,7 @@ pub(crate) use {
process::{self, Command, ExitStatus, Stdio},
rc::Rc,
str::{self, Chars},
- sync::{Mutex, MutexGuard},
+ sync::{Mutex, MutexGuard, OnceLock},
vec,
},
{
@@ -128,6 +128,7 @@ mod condition;
mod conditional_operator;
mod config;
mod config_error;
+mod constants;
mod count;
mod delimiter;
mod dependency;
diff --git a/src/ran.rs b/src/ran.rs
index a19c12d..7751e78 100644
--- a/src/ran.rs
+++ b/src/ran.rs
@@ -8,8 +8,7 @@ impl<'src> Ran<'src> {
self
.0
.get(recipe)
- .map(|ran| ran.contains(arguments))
- .unwrap_or_default()
+ .is_some_and(|ran| ran.contains(arguments))
}
pub(crate) fn ran(&mut self, recipe: &Namepath<'src>, arguments: Vec) {
diff --git a/src/recipe_resolver.rs b/src/recipe_resolver.rs
index 88e4978..5a962d8 100644
--- a/src/recipe_resolver.rs
+++ b/src/recipe_resolver.rs
@@ -58,8 +58,9 @@ impl<'src: 'run, 'run> RecipeResolver<'src, 'run> {
parameters: &[Parameter],
) -> CompileResult<'src> {
let name = variable.lexeme();
- let undefined =
- !self.assignments.contains_key(name) && !parameters.iter().any(|p| p.name.lexeme() == name);
+ let undefined = !self.assignments.contains_key(name)
+ && !parameters.iter().any(|p| p.name.lexeme() == name)
+ && !constants().contains_key(name);
if undefined {
return Err(variable.error(UndefinedVariable { variable: name }));
diff --git a/src/scope.rs b/src/scope.rs
index 18aa17c..dd7888c 100644
--- a/src/scope.rs
+++ b/src/scope.rs
@@ -14,11 +14,31 @@ impl<'src, 'run> Scope<'src, 'run> {
}
}
- pub(crate) fn new() -> Self {
- Self {
+ pub(crate) fn root() -> Self {
+ let mut root = Self {
parent: None,
bindings: Table::new(),
+ };
+
+ for (key, value) in constants() {
+ root.bind(
+ false,
+ Name {
+ token: Token {
+ column: 0,
+ kind: TokenKind::Identifier,
+ length: key.len(),
+ line: 0,
+ offset: 0,
+ path: Path::new("PRELUDE"),
+ src: key,
+ },
+ },
+ (*value).into(),
+ );
}
+
+ root
}
pub(crate) fn bind(&mut self, export: bool, name: Name<'src>, value: String) {
diff --git a/tests/constants.rs b/tests/constants.rs
new file mode 100644
index 0000000..59f8b9b
--- /dev/null
+++ b/tests/constants.rs
@@ -0,0 +1,45 @@
+use super::*;
+
+#[test]
+fn constants_are_defined() {
+ assert_eval_eq("HEX", "0123456789abcdef");
+}
+
+#[test]
+fn constants_are_defined_in_recipe_bodies() {
+ Test::new()
+ .justfile(
+ "
+ @foo:
+ echo {{HEX}}
+ ",
+ )
+ .stdout("0123456789abcdef\n")
+ .run();
+}
+
+#[test]
+fn constants_are_defined_in_recipe_parameters() {
+ Test::new()
+ .justfile(
+ "
+ @foo hex=HEX:
+ echo {{hex}}
+ ",
+ )
+ .stdout("0123456789abcdef\n")
+ .run();
+}
+
+#[test]
+fn constants_can_be_redefined() {
+ Test::new()
+ .justfile(
+ "
+ HEX := 'foo'
+ ",
+ )
+ .args(["--evaluate", "HEX"])
+ .stdout("foo")
+ .run();
+}
diff --git a/tests/functions.rs b/tests/functions.rs
index a9fb98b..95bddfd 100644
--- a/tests/functions.rs
+++ b/tests/functions.rs
@@ -436,15 +436,6 @@ fn semver_matches() {
.run();
}
-fn assert_eval_eq(expression: &str, result: &str) {
- Test::new()
- .justfile(format!("x := {expression}"))
- .args(["--evaluate", "x"])
- .stdout(result)
- .unindent_stdout(false)
- .run();
-}
-
#[test]
fn trim_end_matches() {
assert_eval_eq("trim_end_matches('foo', 'o')", "f");
diff --git a/tests/lib.rs b/tests/lib.rs
index 26c9d12..d282d2d 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -3,7 +3,7 @@ pub(crate) use {
assert_stdout::assert_stdout,
assert_success::assert_success,
tempdir::tempdir,
- test::{Output, Test},
+ test::{assert_eval_eq, Output, Test},
},
cradle::input::Input,
executable_path::executable_path,
@@ -46,6 +46,7 @@ mod command;
mod completions;
mod conditional;
mod confirm;
+mod constants;
mod delimiters;
mod directories;
mod dotenv;
diff --git a/tests/test.rs b/tests/test.rs
index 65ee30a..5883043 100644
--- a/tests/test.rs
+++ b/tests/test.rs
@@ -312,3 +312,12 @@ fn test_round_trip(tmpdir: &Path) {
assert_eq!(reparsed, dumped, "reparse mismatch");
}
+
+pub fn assert_eval_eq(expression: &str, result: &str) {
+ Test::new()
+ .justfile(format!("x := {expression}"))
+ .args(["--evaluate", "x"])
+ .stdout(result)
+ .unindent_stdout(false)
+ .run();
+}