Repository: casey/just
Pull request #2490 (2024-11-30 18:20:21)

Add "not matching regex" conditional
Closes https://github.com/casey/just/issues/2416

casey: The `Unspecified` token kind isn't really intended to be surfaced to users, it's for constructing an compile error, which normally requires a token, when there is none.

See the test I added in the last commit, which fails with something like:

```
error: Expected '&&', '!=', '!~', '||', '==', '=~', '+', or '/', but found unspecified
 ——▶ justfile:1:12
  │
1 │ x := if '' !! '' { '' } else { '' }
  │            ^
```

I think the signature of lex_choices should be changed to:

```
  fn lex_choices(
    &mut self,
    first: char,
    choices: &[(char, TokenKind)],
    otherwise: Option<TokenKind>,
  ) -> CompileResult<'src> {
```

And if `otherwise` is `None`, it generates an `UnexpectedCharacter` error.


Patch:
diff --git a/src/compile_error.rs b/src/compile_error.rs
index 7fa2e0a3..02e29af2 100644
--- a/src/compile_error.rs
+++ b/src/compile_error.rs
@@ -246,7 +246,9 @@ impl Display for CompileError<'_> {
         "Non-default parameter `{parameter}` follows default parameter"
       ),
       UndefinedVariable { variable } => write!(f, "Variable `{variable}` not defined"),
-      UnexpectedCharacter { expected } => write!(f, "Expected character `{expected}`"),
+      UnexpectedCharacter { expected } => {
+        write!(f, "Expected character {}", List::or_ticked(expected.iter()))
+      }
       UnexpectedClosingDelimiter { close } => {
         write!(f, "Unexpected closing delimiter `{}`", close.close())
       }
diff --git a/src/compile_error_kind.rs b/src/compile_error_kind.rs
index bc013b90..91c5cd07 100644
--- a/src/compile_error_kind.rs
+++ b/src/compile_error_kind.rs
@@ -107,7 +107,7 @@ pub(crate) enum CompileErrorKind<'src> {
     variable: &'src str,
   },
   UnexpectedCharacter {
-    expected: char,
+    expected: Vec<char>,
   },
   UnexpectedClosingDelimiter {
     close: Delimiter,
diff --git a/src/conditional_operator.rs b/src/conditional_operator.rs
index bb297c22..87832a03 100644
--- a/src/conditional_operator.rs
+++ b/src/conditional_operator.rs
@@ -9,6 +9,8 @@ pub(crate) enum ConditionalOperator {
   Inequality,
   /// `=~`
   RegexMatch,
+  /// `!~`
+  RegexMismatch,
 }
 
 impl Display for ConditionalOperator {
@@ -17,6 +19,7 @@ impl Display for ConditionalOperator {
       Self::Equality => write!(f, "=="),
       Self::Inequality => write!(f, "!="),
       Self::RegexMatch => write!(f, "=~"),
+      Self::RegexMismatch => write!(f, "!~"),
     }
   }
 }
diff --git a/src/evaluator.rs b/src/evaluator.rs
index b83c8cf7..46955980 100644
--- a/src/evaluator.rs
+++ b/src/evaluator.rs
@@ -245,6 +245,9 @@ impl<'src, 'run> Evaluator<'src, 'run> {
       ConditionalOperator::RegexMatch => Regex::new(&rhs_value)
         .map_err(|source| Error::RegexCompile { source })?
         .is_match(&lhs_value),
+      ConditionalOperator::RegexMismatch => !Regex::new(&rhs_value)
+        .map_err(|source| Error::RegexCompile { source })?
+        .is_match(&lhs_value),
     };
     Ok(condition)
   }
diff --git a/src/lexer.rs b/src/lexer.rs
index 2c56db9d..0e7f76c6 100644
--- a/src/lexer.rs
+++ b/src/lexer.rs
@@ -475,7 +475,7 @@ impl<'src> Lexer<'src> {
     match start {
       ' ' | '\t' => self.lex_whitespace(),
       '!' if self.rest().starts_with("!include") => Err(self.error(Include)),
-      '!' => self.lex_digraph('!', '=', BangEquals),
+      '!' => self.lex_choices('!', &[('=', BangEquals), ('~', BangTilde)], None),
       '#' => self.lex_comment(),
       '$' => self.lex_single(Dollar),
       '&' => self.lex_digraph('&', '&', AmpersandAmpersand),
@@ -486,7 +486,11 @@ impl<'src> Lexer<'src> {
       ',' => self.lex_single(Comma),
       '/' => self.lex_single(Slash),
       ':' => self.lex_colon(),
-      '=' => self.lex_choices('=', &[('=', EqualsEquals), ('~', EqualsTilde)], Equals),
+      '=' => self.lex_choices(
+        '=',
+        &[('=', EqualsEquals), ('~', EqualsTilde)],
+        Some(Equals),
+      ),
       '?' => self.lex_single(QuestionMark),
       '@' => self.lex_single(At),
       '[' => self.lex_delimiter(BracketL),
@@ -618,7 +622,7 @@ impl<'src> Lexer<'src> {
     &mut self,
     first: char,
     choices: &[(char, TokenKind)],
-    otherwise: TokenKind,
+    otherwise: Option<TokenKind>,
   ) -> CompileResult<'src> {
     self.presume(first)?;
 
@@ -629,7 +633,20 @@ impl<'src> Lexer<'src> {
       }
     }
 
-    self.token(otherwise);
+    if let Some(token) = otherwise {
+      self.token(token);
+    } else {
+      // Emit an unspecified token to consume the current character,
+      self.token(Unspecified);
+
+      // …and advance past another character,
+      self.advance()?;
+
+      // …so that the error we produce highlights the unexpected character.
+      return Err(self.error(UnexpectedCharacter {
+        expected: choices.iter().map(|choice| choice.0).collect(),
+      }));
+    }
 
     Ok(())
   }
@@ -700,7 +717,9 @@ impl<'src> Lexer<'src> {
       self.advance()?;
 
       // …so that the error we produce highlights the unexpected character.
-      Err(self.error(UnexpectedCharacter { expected: right }))
+      Err(self.error(UnexpectedCharacter {
+        expected: vec![right],
+      }))
     }
   }
 
@@ -949,6 +968,7 @@ mod tests {
       Asterisk => "*",
       At => "@",
       BangEquals => "!=",
+      BangTilde => "!~",
       BarBar => "||",
       BraceL => "{",
       BraceR => "}",
@@ -2272,7 +2292,7 @@ mod tests {
     column: 1,
     width:  1,
     kind:   UnexpectedCharacter {
-      expected: '&',
+      expected: vec!['&'],
     },
   }
 
diff --git a/src/parser.rs b/src/parser.rs
index 5903e5b4..5d7821a3 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -606,6 +606,8 @@ impl<'run, 'src> Parser<'run, 'src> {
       ConditionalOperator::Inequality
     } else if self.accepted(EqualsTilde)? {
       ConditionalOperator::RegexMatch
+    } else if self.accepted(BangTilde)? {
+      ConditionalOperator::RegexMismatch
     } else {
       self.expect(EqualsEquals)?;
       ConditionalOperator::Equality
diff --git a/src/summary.rs b/src/summary.rs
index 76483d63..79adaba4 100644
--- a/src/summary.rs
+++ b/src/summary.rs
@@ -360,6 +360,7 @@ pub enum ConditionalOperator {
   Equality,
   Inequality,
   RegexMatch,
+  RegexMismatch,
 }
 
 impl ConditionalOperator {
@@ -368,6 +369,7 @@ impl ConditionalOperator {
       full::ConditionalOperator::Equality => Self::Equality,
       full::ConditionalOperator::Inequality => Self::Inequality,
       full::ConditionalOperator::RegexMatch => Self::RegexMatch,
+      full::ConditionalOperator::RegexMismatch => Self::RegexMismatch,
     }
   }
 }
diff --git a/src/token_kind.rs b/src/token_kind.rs
index 850afa96..be8af19f 100644
--- a/src/token_kind.rs
+++ b/src/token_kind.rs
@@ -7,6 +7,7 @@ pub(crate) enum TokenKind {
   At,
   Backtick,
   BangEquals,
+  BangTilde,
   BarBar,
   BraceL,
   BraceR,
@@ -51,6 +52,7 @@ impl Display for TokenKind {
         At => "'@'",
         Backtick => "backtick",
         BangEquals => "'!='",
+        BangTilde => "'!~'",
         BarBar => "'||'",
         BraceL => "'{'",
         BraceR => "'}'",


Test patch:
diff --git a/tests/conditional.rs b/tests/conditional.rs
index 4eab2f4d..8eae1351 100644
--- a/tests/conditional.rs
+++ b/tests/conditional.rs
@@ -136,7 +136,7 @@ test! {
   ",
   stdout: "",
   stderr: "
-    error: Expected '&&', '!=', '||', '==', '=~', '+', or '/', but found identifier
+    error: Expected '&&', '!=', '!~', '||', '==', '=~', '+', or '/', but found identifier
      ——▶ justfile:1:12
       │
     1 │ a := if '' a '' { '' } else { b }
diff --git a/tests/parser.rs b/tests/parser.rs
index 307f1aea..3512991c 100644
--- a/tests/parser.rs
+++ b/tests/parser.rs
@@ -11,3 +11,24 @@ fn dont_run_duplicate_recipes() {
     )
     .run();
 }
+
+#[test]
+fn invalid_bang_operator() {
+  Test::new()
+    .justfile(
+      "
+      x := if '' !! '' { '' } else { '' }
+      ",
+    )
+    .status(1)
+    .stderr(
+      r"
+error: Expected character `=` or `~`
+ ——▶ justfile:1:13
+  │
+1 │ x := if '' !! '' { '' } else { '' }
+  │             ^
+",
+    )
+    .run();
+}
diff --git a/tests/regexes.rs b/tests/regexes.rs
index 7a53a0af..8194381e 100644
--- a/tests/regexes.rs
+++ b/tests/regexes.rs
@@ -64,3 +64,28 @@ fn bad_regex_fails_at_runtime() {
     .status(EXIT_FAILURE)
     .run();
 }
+
+#[test]
+fn mismatch() {
+  Test::new()
+    .justfile(
+      "
+      foo := if 'Foo' !~ '^ab+c' {
+        'mismatch'
+      } else {
+        'match'
+      }
+
+      bar := if 'Foo' !~ 'Foo' {
+        'mismatch'
+      } else {
+        'match'
+      }
+
+      @default:
+        echo {{ foo }} {{ bar }}
+    ",
+    )
+    .stdout("mismatch match\n")
+    .run();
+}