diff --git a/GRAMMAR.md b/GRAMMAR.md index 298fc3e..3636049 100644 --- a/GRAMMAR.md +++ b/GRAMMAR.md @@ -66,6 +66,7 @@ setting : 'set' 'dotenv-load' boolean? boolean : ':=' ('true' | 'false') expression : 'if' condition '{' expression '}' 'else' '{' expression '}' + | value '/' expression | value '+' expression | value diff --git a/README.md b/README.md index 8c8184d..9c6a2f4 100644 --- a/README.md +++ b/README.md @@ -803,11 +803,12 @@ Starting server with database localhost:6379 on port 1337… ### Variables and Substitution -Variables, strings, concatenation, and substitution using `{{…}}` are supported: +Variables, strings, concatenation, path joining, and substitution using `{{…}}` are supported: ```make +tmpdir := `mktemp` version := "0.2.7" -tardir := "awesomesauce-" + version +tardir := tmpdir / "awesomesauce-" + version tarball := tardir + ".tar.gz" publish: @@ -819,6 +820,33 @@ publish: rm -rf {{tarball}} {{tardir}} ``` +#### Joining Paths + +The `/` operator can be used to join two strings with a slash: + +```make +foo := "a" / "b" +``` + +``` +$ just --evaluate foo +a/b +``` + +Note that a `/` is added even if one is already present: + +```make +foo := "a/" +bar := foo / "b" +``` + +``` +$ just --evaluate bar +a//b +``` + +The `/` operator uses the `/` character, even on Windows. Thus, using the `/` operator should be avoided with paths that use universal naming convention (UNC), i.e., those that start with `\?`, since forward slashes are not supported with UNC paths. + #### Escaping `{{` To write a recipe containing `{{`, use `{{{{`: @@ -1064,7 +1092,7 @@ These functions can fail, for example if a path does not have an extension, whic ##### Infallible -- `join(a, b…)` - Join path `a` with path `b`. `join("foo/bar", "baz")` is `foo/bar/baz`. Accepts two or more arguments. +- `join(a, b…)` - *This function uses `/` on Unix and `\` on Windows, which can be lead to unwanted behavior. The `/` operator, e.g., `a / b`, which always uses `/`, should be considered as a replacement unless `\`s are specifically desired on Windows.* Join path `a` with path `b`. `join("foo/bar", "baz")` is `foo/bar/baz`. Accepts two or more arguments. - `clean(path)` - Simplify `path` by removing extra path separators, intermediate `.` components, and `..` where possible. `clean("foo//bar")` is `foo/bar`, `clean("foo/..")` is `.`, `clean("foo/./bar")` is `foo/bar`. @@ -1360,12 +1388,12 @@ Testing server:unit… ./test --tests unit server ``` -Default values may be arbitrary expressions, but concatenations must be parenthesized: +Default values may be arbitrary expressions, but concatenations or path joins must be parenthesized: ```make arch := "wasm" -test triple=(arch + "-unknown-unknown"): +test triple=(arch + "-unknown-unknown") input=(arch / "input.dat"): ./test {{triple}} ``` diff --git a/justfile b/justfile index 847c96d..5c452f0 100755 --- a/justfile +++ b/justfile @@ -58,9 +58,6 @@ check: fmt clippy test forbid git diff --no-ext-diff --quiet --exit-code VERSION=`sed -En 's/version[[:space:]]*=[[:space:]]*"([^"]+)"/\1/p' Cargo.toml | head -1` grep "^\[$VERSION\]" CHANGELOG.md - cargo +nightly generate-lockfile -Z minimal-versions - cargo test - git checkout Cargo.lock # publish current GitHub master branch publish: diff --git a/src/assignment_resolver.rs b/src/assignment_resolver.rs index 3f07ba4..87e458a 100644 --- a/src/assignment_resolver.rs +++ b/src/assignment_resolver.rs @@ -101,7 +101,7 @@ impl<'src: 'run, 'run> AssignmentResolver<'src, 'run> { self.resolve_expression(c) } }, - Expression::Concatenation { lhs, rhs } => { + Expression::Concatenation { lhs, rhs } | Expression::Join { lhs, rhs } => { self.resolve_expression(lhs)?; self.resolve_expression(rhs) } diff --git a/src/evaluator.rs b/src/evaluator.rs index 048828d..7f45c91 100644 --- a/src/evaluator.rs +++ b/src/evaluator.rs @@ -176,6 +176,9 @@ impl<'src, 'run> Evaluator<'src, 'run> { } } Expression::Group { contents } => self.evaluate_expression(contents), + Expression::Join { lhs, rhs } => { + Ok(self.evaluate_expression(lhs)? + "/" + &self.evaluate_expression(rhs)?) + } } } diff --git a/src/expression.rs b/src/expression.rs index 5b79268..0393159 100644 --- a/src/expression.rs +++ b/src/expression.rs @@ -30,6 +30,11 @@ pub(crate) enum Expression<'src> { }, /// `(contents)` Group { contents: Box> }, + /// `lhs / rhs` + Join { + lhs: Box>, + rhs: Box>, + }, /// `"string_literal"` or `'string_literal'` StringLiteral { string_literal: StringLiteral<'src> }, /// `variable` @@ -46,6 +51,7 @@ impl<'src> Display for Expression<'src> { fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { match self { Expression::Backtick { token, .. } => write!(f, "{}", token.lexeme()), + Expression::Join { lhs, rhs } => write!(f, "{} / {}", lhs, rhs), Expression::Concatenation { lhs, rhs } => write!(f, "{} + {}", lhs, rhs), Expression::Conditional { lhs, @@ -86,6 +92,13 @@ impl<'src> Serialize for Expression<'src> { seq.serialize_element(rhs)?; seq.end() } + Self::Join { lhs, rhs } => { + let mut seq = serializer.serialize_seq(None)?; + seq.serialize_element("join")?; + seq.serialize_element(lhs)?; + seq.serialize_element(rhs)?; + seq.end() + } Self::Conditional { lhs, rhs, diff --git a/src/lexer.rs b/src/lexer.rs index 09a89d9..536eea7 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -485,6 +485,7 @@ impl<'src> Lexer<'src> { '*' => self.lex_single(Asterisk), '+' => self.lex_single(Plus), ',' => self.lex_single(Comma), + '/' => self.lex_single(Slash), ':' => self.lex_colon(), '=' => self.lex_choices('=', &[('=', EqualsEquals), ('~', EqualsTilde)], Equals), '@' => self.lex_single(At), @@ -942,6 +943,7 @@ mod tests { ParenL => "(", ParenR => ")", Plus => "+", + Slash => "/", Whitespace => " ", // Empty lexemes diff --git a/src/node.rs b/src/node.rs index 02f6a00..6afc0a4 100644 --- a/src/node.rs +++ b/src/node.rs @@ -118,6 +118,7 @@ impl<'src> Node<'src> for Expression<'src> { } => Tree::string(cooked), Expression::Backtick { contents, .. } => Tree::atom("backtick").push(Tree::string(contents)), Expression::Group { contents } => Tree::List(vec![contents.tree()]), + Expression::Join { lhs, rhs } => Tree::atom("/").push(lhs.tree()).push(rhs.tree()), } } } diff --git a/src/parser.rs b/src/parser.rs index fd42452..99297fe 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -408,7 +408,11 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { } else { let value = self.parse_value()?; - if self.accepted(Plus)? { + if self.accepted(Slash)? { + let lhs = Box::new(value); + let rhs = Box::new(self.parse_expression()?); + Expression::Join { lhs, rhs } + } else if self.accepted(Plus)? { let lhs = Box::new(value); let rhs = Box::new(self.parse_expression()?); Expression::Concatenation { lhs, rhs } diff --git a/src/summary.rs b/src/summary.rs index 66bdad7..7d467dd 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -196,6 +196,10 @@ pub enum Expression { otherwise: Box, operator: ConditionalOperator, }, + Join { + lhs: Box, + rhs: Box, + }, String { text: String, }, @@ -253,6 +257,10 @@ impl Expression { lhs: Box::new(Expression::new(lhs)), rhs: Box::new(Expression::new(rhs)), }, + Join { lhs, rhs } => Expression::Join { + lhs: Box::new(Expression::new(lhs)), + rhs: Box::new(Expression::new(rhs)), + }, Conditional { lhs, operator, diff --git a/src/token_kind.rs b/src/token_kind.rs index c36edf5..b00517f 100644 --- a/src/token_kind.rs +++ b/src/token_kind.rs @@ -30,6 +30,7 @@ pub(crate) enum TokenKind { ParenL, ParenR, Plus, + Slash, StringToken, Text, Unspecified, @@ -71,6 +72,7 @@ impl Display for TokenKind { ParenL => "'('", ParenR => "')'", Plus => "'+'", + Slash => "'/'", StringToken => "string", Text => "command text", Unspecified => "unspecified", diff --git a/src/variables.rs b/src/variables.rs index 1b634df..f1bd506 100644 --- a/src/variables.rs +++ b/src/variables.rs @@ -53,7 +53,7 @@ impl<'expression, 'src> Iterator for Variables<'expression, 'src> { self.stack.push(lhs); } Expression::Variable { name, .. } => return Some(name.token()), - Expression::Concatenation { lhs, rhs } => { + Expression::Concatenation { lhs, rhs } | Expression::Join { lhs, rhs } => { self.stack.push(rhs); self.stack.push(lhs); } diff --git a/tests/conditional.rs b/tests/conditional.rs index 3784a03..a0896c3 100644 --- a/tests/conditional.rs +++ b/tests/conditional.rs @@ -132,7 +132,7 @@ test! { ", stdout: "", stderr: " - error: Expected '!=', '==', '=~', or '+', but found identifier + error: Expected '!=', '==', '=~', '+', or '/', but found identifier | 1 | a := if '' a '' { '' } else { b } | ^ diff --git a/tests/evaluate.rs b/tests/evaluate.rs index 22fa746..9c38b34 100644 --- a/tests/evaluate.rs +++ b/tests/evaluate.rs @@ -43,7 +43,7 @@ test! { } test! { - name: evaluate_single, + name: evaluate_single_free, justfile: " a := 'x' b := 'y' diff --git a/tests/lib.rs b/tests/lib.rs index 671450b..1c34bdf 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -69,6 +69,7 @@ mod search; mod shebang; mod shell; mod show; +mod slash_operator; mod string; mod sublime_syntax; mod subsequents; diff --git a/tests/slash_operator.rs b/tests/slash_operator.rs new file mode 100644 index 0000000..5995a32 --- /dev/null +++ b/tests/slash_operator.rs @@ -0,0 +1,54 @@ +use super::*; + +#[test] +fn once() { + Test::new() + .justfile("x := 'a' / 'b'") + .args(&["--evaluate", "x"]) + .stdout("a/b") + .run(); +} + +#[test] +fn twice() { + Test::new() + .justfile("x := 'a' / 'b' / 'c'") + .args(&["--evaluate", "x"]) + .stdout("a/b/c") + .run(); +} + +#[test] +fn default_un_parenthesized() { + Test::new() + .justfile( + " + foo x='a' / 'b': + echo {{x}} + ", + ) + .stderr( + " + error: Expected '*', ':', '$', identifier, or '+', but found '/' + | + 1 | foo x='a' / 'b': + | ^ + ", + ) + .status(EXIT_FAILURE) + .run(); +} + +#[test] +fn default_parenthesized() { + Test::new() + .justfile( + " + foo x=('a' / 'b'): + echo {{x}} + ", + ) + .stderr("echo a/b\n") + .stdout("a/b\n") + .run(); +}