diff --git a/Cargo.lock b/Cargo.lock
index 96b94f2..92fabbd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -544,6 +544,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
+ "shellexpand",
"similar",
"snafu",
"strum",
@@ -908,6 +909,15 @@ dependencies = [
"digest",
]
+[[package]]
+name = "shellexpand"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da03fa3b94cc19e3ebfc88c4229c49d8f08cdbd1228870a45f0ffdf84988e14b"
+dependencies = [
+ "dirs",
+]
+
[[package]]
name = "similar"
version = "2.5.0"
diff --git a/Cargo.toml b/Cargo.toml
index 6e3defa..b52c780 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -43,6 +43,7 @@ semver = "1.0.20"
serde = { version = "1.0.130", features = ["derive", "rc"] }
serde_json = "1.0.68"
sha2 = "0.10"
+shellexpand = "3.1.0"
similar = { version = "2.1.0", features = ["unicode"] }
snafu = "0.8.0"
strum = { version = "0.26.0", features = ["derive"] }
diff --git a/README.md b/README.md
index 8eb3452..cb643ce 100644
--- a/README.md
+++ b/README.md
@@ -1242,8 +1242,8 @@ escapes := "\t\n\r\"\\"
```
Indented versions of both single- and double-quoted strings, delimited by
-triple single- or triple double-quotes, are supported. Indented string lines
-are stripped of a leading line break, and leading whitespace common to all
+triple single- or double-quotes, are supported. Indented string lines are
+stripped of a leading line break, and leading whitespace common to all
non-blank lines:
```just
@@ -1267,6 +1267,24 @@ sequence processing takes place after unindentation. The unindentation
algorithm does not take escape-sequence produced whitespace or newlines into
account.
+Strings prefixed with `x` are shell expandedmaster:
+
+```justfile
+foobar := x'~/$FOO/${BAR}'
+```
+
+| Value | Replacement |
+|------|-------------|
+| `$VAR` | value of environment variable `VAR` |
+| `${VAR}` | value of environment variable `VAR` |
+| Leading `~` | path to current user's home directory |
+| Leading `~USER` | path to `USER`'s home directory |
+
+This expansion is performed at compile time, so variables from `.env` files and
+exported `just` variables cannot be used. However, this allows shell expanded
+strings to be used in places like settings and import paths, which cannot
+depend on `just` variables and `.env` files.
+
### Ignoring Errors
Normally, if a command returns a non-zero exit status, execution will stop. To
diff --git a/src/compile_error.rs b/src/compile_error.rs
index 8f75dd2..cc73115 100644
--- a/src/compile_error.rs
+++ b/src/compile_error.rs
@@ -206,6 +206,7 @@ impl Display for CompileError<'_> {
)
}
}
+ ShellExpansion { err } => write!(f, "Shell expansion failed: {err}"),
RequiredParameterFollowsDefaultParameter { parameter } => write!(
f,
"Non-default parameter `{parameter}` follows default parameter"
diff --git a/src/compile_error_kind.rs b/src/compile_error_kind.rs
index fbdbf2a..1e9c956 100644
--- a/src/compile_error_kind.rs
+++ b/src/compile_error_kind.rs
@@ -82,6 +82,9 @@ pub(crate) enum CompileErrorKind<'src> {
RequiredParameterFollowsDefaultParameter {
parameter: &'src str,
},
+ ShellExpansion {
+ err: shellexpand::LookupError,
+ },
UndefinedVariable {
variable: &'src str,
},
diff --git a/src/keyword.rs b/src/keyword.rs
index e0b6c63..a2451e1 100644
--- a/src/keyword.rs
+++ b/src/keyword.rs
@@ -26,6 +26,7 @@ pub(crate) enum Keyword {
True,
WindowsPowershell,
WindowsShell,
+ X,
}
impl Keyword {
@@ -43,3 +44,14 @@ impl<'a> PartialEq<&'a str> for Keyword {
self.lexeme() == *other
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn keyword_case() {
+ assert_eq!(Keyword::X.lexeme(), "x");
+ assert_eq!(Keyword::IgnoreComments.lexeme(), "ignore-comments");
+ }
+}
diff --git a/src/parser.rs b/src/parser.rs
index 2b09501..5760a41 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -550,7 +550,7 @@ impl<'run, 'src> Parser<'run, 'src> {
/// Parse a value, e.g. `(bar)`
fn parse_value(&mut self) -> CompileResult<'src, Expression<'src>> {
- if self.next_is(StringToken) {
+ if self.next_is(StringToken) || self.next_are(&[Identifier, StringToken]) {
Ok(Expression::StringLiteral {
string_literal: self.parse_string_literal()?,
})
@@ -604,6 +604,8 @@ impl<'run, 'src> Parser<'run, 'src> {
fn parse_string_literal_token(
&mut self,
) -> CompileResult<'src, (Token<'src>, StringLiteral<'src>)> {
+ let expand = self.accepted_keyword(Keyword::X)?;
+
let token = self.expect(StringToken)?;
let kind = StringKind::from_string_or_backtick(token)?;
@@ -648,7 +650,23 @@ impl<'run, 'src> Parser<'run, 'src> {
unindented
};
- Ok((token, StringLiteral { kind, raw, cooked }))
+ let cooked = if expand {
+ shellexpand::full(&cooked)
+ .map_err(|err| token.error(CompileErrorKind::ShellExpansion { err }))?
+ .into_owned()
+ } else {
+ cooked
+ };
+
+ Ok((
+ token,
+ StringLiteral {
+ cooked,
+ expand,
+ kind,
+ raw,
+ },
+ ))
}
/// Parse a string literal, e.g. `"FOO"`
diff --git a/src/settings.rs b/src/settings.rs
index dbabe4c..13085ff 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -201,11 +201,13 @@ mod tests {
kind: StringKind::from_token_start("\"").unwrap(),
raw: "asdf.exe",
cooked: "asdf.exe".to_string(),
+ expand: false,
},
arguments: vec![StringLiteral {
kind: StringKind::from_token_start("\"").unwrap(),
raw: "-nope",
cooked: "-nope".to_string(),
+ expand: false,
}],
}),
..Default::default()
diff --git a/src/string_literal.rs b/src/string_literal.rs
index 4a2b774..05b9c28 100644
--- a/src/string_literal.rs
+++ b/src/string_literal.rs
@@ -2,13 +2,18 @@ use super::*;
#[derive(PartialEq, Debug, Clone, Ord, Eq, PartialOrd)]
pub(crate) struct StringLiteral<'src> {
+ pub(crate) cooked: String,
+ pub(crate) expand: bool,
pub(crate) kind: StringKind,
pub(crate) raw: &'src str,
- pub(crate) cooked: String,
}
impl Display for StringLiteral<'_> {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ if self.expand {
+ write!(f, "x")?;
+ }
+
write!(
f,
"{}{}{}",
diff --git a/tests/lib.rs b/tests/lib.rs
index d282d2d..1440117 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -91,6 +91,7 @@ mod search_arguments;
mod shadowing_parameters;
mod shebang;
mod shell;
+mod shell_expansion;
mod show;
mod slash_operator;
mod string;
diff --git a/tests/shell_expansion.rs b/tests/shell_expansion.rs
new file mode 100644
index 0000000..b748c50
--- /dev/null
+++ b/tests/shell_expansion.rs
@@ -0,0 +1,67 @@
+use super::*;
+
+#[test]
+fn strings_are_shell_expanded() {
+ Test::new()
+ .justfile(
+ "
+ x := x'$JUST_TEST_VARIABLE'
+ ",
+ )
+ .env("JUST_TEST_VARIABLE", "FOO")
+ .args(["--evaluate", "x"])
+ .stdout("FOO")
+ .run();
+}
+
+#[test]
+fn shell_expanded_error_messages_highlight_string_token() {
+ Test::new()
+ .justfile(
+ "
+ x := x'$FOOOOOOOOOOOOOOOOOOOOOOOOOOOOO'
+ ",
+ )
+ .env("JUST_TEST_VARIABLE", "FOO")
+ .args(["--evaluate", "x"])
+ .status(1)
+ .stderr(
+ "
+ error: Shell expansion failed: error looking key 'FOOOOOOOOOOOOOOOOOOOOOOOOOOOOO' up: environment variable not found
+ ——▶ justfile:1:7
+ │
+ 1 │ x := x'$FOOOOOOOOOOOOOOOOOOOOOOOOOOOOO'
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ ")
+ .run();
+}
+
+#[test]
+fn shell_expanded_strings_are_dumped_correctly() {
+ Test::new()
+ .justfile(
+ "
+ x := x'$JUST_TEST_VARIABLE'
+ ",
+ )
+ .env("JUST_TEST_VARIABLE", "FOO")
+ .args(["--dump", "--unstable"])
+ .stdout("x := x'$JUST_TEST_VARIABLE'\n")
+ .run();
+}
+
+#[test]
+fn shell_expanded_strings_can_be_used_in_settings() {
+ Test::new()
+ .justfile(
+ "
+ set dotenv-filename := x'$JUST_TEST_VARIABLE'
+
+ @foo:
+ echo $DOTENV_KEY
+ ",
+ )
+ .env("JUST_TEST_VARIABLE", ".env")
+ .stdout("dotenv-value\n")
+ .run();
+}
diff --git a/tests/test.rs b/tests/test.rs
index 5883043..16b3b0e 100644
--- a/tests/test.rs
+++ b/tests/test.rs
@@ -199,7 +199,7 @@ impl Test {
let stdout = if self.unindent_stdout {
unindent(&self.stdout)
} else {
- self.stdout
+ self.stdout.clone()
};
let stderr = unindent(&self.stderr);
@@ -212,9 +212,9 @@ impl Test {
}
let mut child = command
- .args(self.args)
+ .args(&self.args)
.envs(&self.env)
- .current_dir(self.tempdir.path().join(self.current_dir))
+ .current_dir(self.tempdir.path().join(&self.current_dir))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -266,7 +266,7 @@ impl Test {
}
if self.test_round_trip && self.status == EXIT_SUCCESS {
- test_round_trip(self.tempdir.path());
+ self.round_trip();
}
Output {
@@ -275,42 +275,44 @@ impl Test {
tempdir: self.tempdir,
}
}
-}
-fn test_round_trip(tmpdir: &Path) {
- println!("Reparsing...");
+ fn round_trip(&self) {
+ println!("Reparsing...");
- let output = Command::new(executable_path("just"))
- .current_dir(tmpdir)
- .arg("--dump")
- .output()
- .expect("just invocation failed");
+ let output = Command::new(executable_path("just"))
+ .current_dir(self.tempdir.path())
+ .arg("--dump")
+ .envs(&self.env)
+ .output()
+ .expect("just invocation failed");
- if !output.status.success() {
- panic!("dump failed: {}", output.status);
+ if !output.status.success() {
+ panic!("dump failed: {} {:?}", output.status, output);
+ }
+
+ let dumped = String::from_utf8(output.stdout).unwrap();
+
+ let reparsed_path = self.tempdir.path().join("reparsed.just");
+
+ fs::write(&reparsed_path, &dumped).unwrap();
+
+ let output = Command::new(executable_path("just"))
+ .current_dir(self.tempdir.path())
+ .arg("--justfile")
+ .arg(&reparsed_path)
+ .arg("--dump")
+ .envs(&self.env)
+ .output()
+ .expect("just invocation failed");
+
+ if !output.status.success() {
+ panic!("reparse failed: {}", output.status);
+ }
+
+ let reparsed = String::from_utf8(output.stdout).unwrap();
+
+ assert_eq!(reparsed, dumped, "reparse mismatch");
}
-
- let dumped = String::from_utf8(output.stdout).unwrap();
-
- let reparsed_path = tmpdir.join("reparsed.just");
-
- fs::write(&reparsed_path, &dumped).unwrap();
-
- let output = Command::new(executable_path("just"))
- .current_dir(tmpdir)
- .arg("--justfile")
- .arg(&reparsed_path)
- .arg("--dump")
- .output()
- .expect("just invocation failed");
-
- if !output.status.success() {
- panic!("reparse failed: {}", output.status);
- }
-
- let reparsed = String::from_utf8(output.stdout).unwrap();
-
- assert_eq!(reparsed, dumped, "reparse mismatch");
}
pub fn assert_eval_eq(expression: &str, result: &str) {