Repository: casey/just
Pull request #1237 (2022-06-18 05:19:31)

Add `/` operator
Add a join operator, `/`, which joins strings using `/` characters:

```
x := "a" / "b" # Evaluates to "a/b"
```


Patch:
diff --git a/GRAMMAR.md b/GRAMMAR.md
index 298fc3e4..36360492 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 8c8184d5..9c6a2f4a 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 847c96d4..5c452f0d 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 3f07ba40..87e458ad 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 048828d0..7f45c91d 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 5b792682..0393159d 100644
--- a/src/expression.rs
+++ b/src/expression.rs
@@ -30,6 +30,11 @@ pub(crate) enum Expression<'src> {
   },
   /// `(contents)`
   Group { contents: Box<Expression<'src>> },
+  /// `lhs / rhs`
+  Join {
+    lhs: Box<Expression<'src>>,
+    rhs: Box<Expression<'src>>,
+  },
   /// `"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 09a89d92..536eea77 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 02f6a007..6afc0a43 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 fd424520..99297fe5 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 66bdad77..7d467ddb 100644
--- a/src/summary.rs
+++ b/src/summary.rs
@@ -196,6 +196,10 @@ pub enum Expression {
     otherwise: Box<Expression>,
     operator: ConditionalOperator,
   },
+  Join {
+    lhs: Box<Expression>,
+    rhs: Box<Expression>,
+  },
   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 c36edf50..b00517f3 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 1b634df6..f1bd506f 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);
         }


Test patch:
diff --git a/tests/conditional.rs b/tests/conditional.rs
index 3784a036..a0896c38 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 22fa746c..9c38b344 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 671450b7..1c34bdf9 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 00000000..5995a32e
--- /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();
+}