Repository: casey/just
Pull request #790 (2021-04-04 23:34:18)

Unify string lexing
Unify lexing of backticks, cooked strings, and raw strings. Also allow
newlines in backticks and cooked strings, since I can't think of a reason
not to.


Patch:
diff --git a/GRAMMAR.md b/GRAMMAR.md
index 01e25305..c2c409af 100644
--- a/GRAMMAR.md
+++ b/GRAMMAR.md
@@ -9,7 +9,7 @@ tokens
 ------
 
 ```
-BACKTICK   = `[^`\n\r]*`
+BACKTICK   = `[^`]*`
 COMMENT    = #([^!].*)?$
 DEDENT     = emitted when indentation decreases
 EOF        = emitted at the end of the file
@@ -17,7 +17,7 @@ INDENT     = emitted when indentation increases
 LINE       = emitted before a recipe line
 NAME       = [a-zA-Z_][a-zA-Z0-9_-]*
 NEWLINE    = \n|\r\n
-RAW_STRING = '[^'\r\n]*'
+RAW_STRING = '[^']*'
 STRING     = "[^"]*" # also processes \n \r \t \" \\ escapes
 TEXT       = recipe text, only matches in a recipe body
 ```
diff --git a/README.adoc b/README.adoc
index 554a352c..46d9c20e 100644
--- a/README.adoc
+++ b/README.adoc
@@ -557,31 +557,27 @@ string-with-slash           := "\"
 string-with-tab             := "     "
 ```
 
-Single-quoted strings do not recognize escape sequences and may contain line breaks:
+Strings may contain line breaks:
 
 ```make
-escapes := '\t\n\r\"\\'
-
-line-breaks := 'hello
-this
-is
-  a
-     raw
-string!
+single := '
+hello
 '
+
+double := "
+goodbye
+"
+```
+
+Single-quoted strings do not recognize escape sequences:
+
+```make
+escapes := '\t\n\r\"\\'
 ```
 
 ```sh
 $ just --evaluate
 escapes := "\t\n\r\"\\"
-
-line-breaks := "hello
-this
-is
-  a
-     raw
-string!
-"
 ```
 
 === Ignoring Errors
diff --git a/src/common.rs b/src/common.rs
index f0ecb033..6b2288bd 100644
--- a/src/common.rs
+++ b/src/common.rs
@@ -55,10 +55,10 @@ pub(crate) use crate::{
   recipe_context::RecipeContext, recipe_resolver::RecipeResolver, runtime_error::RuntimeError,
   scope::Scope, search::Search, search_config::SearchConfig, search_error::SearchError, set::Set,
   setting::Setting, settings::Settings, shebang::Shebang, show_whitespace::ShowWhitespace,
-  string_literal::StringLiteral, subcommand::Subcommand, suggestion::Suggestion, table::Table,
-  thunk::Thunk, token::Token, token_kind::TokenKind, unresolved_dependency::UnresolvedDependency,
-  unresolved_recipe::UnresolvedRecipe, use_color::UseColor, variables::Variables,
-  verbosity::Verbosity, warning::Warning,
+  string_kind::StringKind, string_literal::StringLiteral, subcommand::Subcommand,
+  suggestion::Suggestion, table::Table, thunk::Thunk, token::Token, token_kind::TokenKind,
+  unresolved_dependency::UnresolvedDependency, unresolved_recipe::UnresolvedRecipe,
+  use_color::UseColor, variables::Variables, verbosity::Verbosity, warning::Warning,
 };
 
 // type aliases
diff --git a/src/compilation_error.rs b/src/compilation_error.rs
index c4f9e5e7..83d1129f 100644
--- a/src/compilation_error.rs
+++ b/src/compilation_error.rs
@@ -242,10 +242,10 @@ impl Display for CompilationError<'_> {
       UnterminatedInterpolation => {
         writeln!(f, "Unterminated interpolation")?;
       },
-      UnterminatedString => {
+      UnterminatedString(StringKind::Cooked) | UnterminatedString(StringKind::Raw) => {
         writeln!(f, "Unterminated string")?;
       },
-      UnterminatedBacktick => {
+      UnterminatedString(StringKind::Backtick) => {
         writeln!(f, "Unterminated backtick")?;
       },
       Internal { ref message } => {
diff --git a/src/compilation_error_kind.rs b/src/compilation_error_kind.rs
index 7763cf62..4ac83748 100644
--- a/src/compilation_error_kind.rs
+++ b/src/compilation_error_kind.rs
@@ -107,6 +107,5 @@ pub(crate) enum CompilationErrorKind<'src> {
     open_line: usize,
   },
   UnterminatedInterpolation,
-  UnterminatedString,
-  UnterminatedBacktick,
+  UnterminatedString(StringKind),
 }
diff --git a/src/lexer.rs b/src/lexer.rs
index 9b1e181d..916c94d5 100644
--- a/src/lexer.rs
+++ b/src/lexer.rs
@@ -30,8 +30,8 @@ pub(crate) struct Lexer<'src> {
   recipe_body:         bool,
   /// Indentation stack
   indentation:         Vec<&'src str>,
-  /// Current interpolation start token
-  interpolation_start: Option<Token<'src>>,
+  /// Interpolation token start stack
+  interpolation_stack: Vec<Token<'src>>,
   /// Current open delimiters
   open_delimiters:     Vec<(Delimiter, usize)>,
 }
@@ -60,7 +60,7 @@ impl<'src> Lexer<'src> {
       token_end: start,
       recipe_body_pending: false,
       recipe_body: false,
-      interpolation_start: None,
+      interpolation_stack: Vec::new(),
       open_delimiters: Vec::new(),
       chars,
       next,
@@ -210,10 +210,8 @@ impl<'src> Lexer<'src> {
 
     // The width of the error site to highlight depends on the kind of error:
     let length = match kind {
-      // highlight ' or "
-      UnterminatedString => 1,
-      // highlight `
-      UnterminatedBacktick => 1,
+      // highlight ', ", or `
+      UnterminatedString(_) => 1,
       // highlight the full token
       _ => self.lexeme().len(),
     };
@@ -280,7 +278,7 @@ impl<'src> Lexer<'src> {
 
       match self.next {
         Some(first) => {
-          if let Some(interpolation_start) = self.interpolation_start {
+          if let Some(&interpolation_start) = self.interpolation_stack.last() {
             self.lex_interpolation(interpolation_start, first)?
           } else if self.recipe_body {
             self.lex_body()?
@@ -292,7 +290,7 @@ impl<'src> Lexer<'src> {
       }
     }
 
-    if let Some(interpolation_start) = self.interpolation_start {
+    if let Some(&interpolation_start) = self.interpolation_stack.last() {
       return Err(Self::unterminated_interpolation_error(interpolation_start));
     }
 
@@ -477,10 +475,10 @@ impl<'src> Lexer<'src> {
       '}' => self.lex_delimiter(BraceR),
       '+' => self.lex_single(Plus),
       '#' => self.lex_comment(),
-      '`' => self.lex_backtick(),
       ' ' => self.lex_whitespace(),
-      '"' => self.lex_cooked_string(),
-      '\'' => self.lex_raw_string(),
+      '`' => self.lex_string(StringKind::Backtick),
+      '"' => self.lex_string(StringKind::Cooked),
+      '\'' => self.lex_string(StringKind::Raw),
       '\n' => self.lex_eol(),
       '\r' => self.lex_eol(),
       '\t' => self.lex_whitespace(),
@@ -500,7 +498,13 @@ impl<'src> Lexer<'src> {
   ) -> CompilationResult<'src, ()> {
     if self.rest_starts_with("}}") {
       // end current interpolation
-      self.interpolation_start = None;
+      if self.interpolation_stack.pop().is_none() {
+        self.advance()?;
+        self.advance()?;
+        return Err(self.internal_error(
+          "Lexer::lex_interpolation found `}}` but was called with empty interpolation stack.",
+        ));
+      }
       // Emit interpolation end token
       self.lex_double(InterpolationEnd)
     } else if self.at_eol_or_eof() {
@@ -530,7 +534,7 @@ impl<'src> Lexer<'src> {
         continue;
       }
 
-      if let Some('\n') = self.next {
+      if self.rest_starts_with("\n") {
         break Newline;
       }
 
@@ -542,7 +546,7 @@ impl<'src> Lexer<'src> {
         break Interpolation;
       }
 
-      if self.next.is_none() {
+      if self.rest().is_empty() {
         break EndOfFile;
       }
 
@@ -559,7 +563,9 @@ impl<'src> Lexer<'src> {
       NewlineCarriageReturn => self.lex_double(Eol),
       Interpolation => {
         self.lex_double(InterpolationStart)?;
-        self.interpolation_start = Some(self.tokens[self.tokens.len() - 1]);
+        self
+          .interpolation_stack
+          .push(self.tokens[self.tokens.len() - 1]);
         Ok(())
       },
       EndOfFile => Ok(()),
@@ -738,25 +744,6 @@ impl<'src> Lexer<'src> {
     Ok(())
   }
 
-  /// Lex backtick: `[^\r\n]*`
-  fn lex_backtick(&mut self) -> CompilationResult<'src, ()> {
-    // advance over initial `
-    self.advance()?;
-
-    while !self.next_is('`') {
-      if self.at_eol_or_eof() {
-        return Err(self.error(UnterminatedBacktick));
-      }
-
-      self.advance()?;
-    }
-
-    self.advance()?;
-    self.token(Backtick);
-
-    Ok(())
-  }
-
   /// Lex whitespace: [ \t]+
   fn lex_whitespace(&mut self) -> CompilationResult<'src, ()> {
     while self.next_is_whitespace() {
@@ -768,48 +755,29 @@ impl<'src> Lexer<'src> {
     Ok(())
   }
 
-  /// Lex raw string: '[^']*'
-  fn lex_raw_string(&mut self) -> CompilationResult<'src, ()> {
-    self.presume('\'')?;
-
-    loop {
-      match self.next {
-        Some('\'') => break,
-        None => return Err(self.error(UnterminatedString)),
-        Some(_) => {},
-      }
-
-      self.advance()?;
-    }
-
-    self.presume('\'')?;
-
-    self.token(StringRaw);
-
-    Ok(())
-  }
-
-  /// Lex cooked string: "[^"\n\r]*" (also processes escape sequences)
-  fn lex_cooked_string(&mut self) -> CompilationResult<'src, ()> {
-    self.presume('"')?;
+  /// Lex a backtick, cooked string, or raw string.
+  ///
+  /// Backtick:      `[^`]*`
+  /// Cooked string: "[^"]*" # also processes escape sequences
+  /// Raw string:    '[^']*'
+  fn lex_string(&mut self, kind: StringKind) -> CompilationResult<'src, ()> {
+    self.presume(kind.delimiter())?;
 
     let mut escape = false;
 
     loop {
       match self.next {
-        Some('\r') | Some('\n') | None => return Err(self.error(UnterminatedString)),
-        Some('"') if !escape => break,
-        Some('\\') if !escape => escape = true,
-        _ => escape = false,
+        Some(c) if c == kind.delimiter() && !escape => break,
+        Some('\\') if kind.processes_escape_sequences() && !escape => escape = true,
+        Some(_) => escape = false,
+        None => return Err(self.error(kind.unterminated_error_kind())),
       }
 
       self.advance()?;
     }
 
-    // advance over closing "
-    self.advance()?;
-
-    self.token(StringCooked);
+    self.presume(kind.delimiter())?;
+    self.token(kind.token_kind());
 
     Ok(())
   }
@@ -821,6 +789,10 @@ mod tests {
 
   use pretty_assertions::assert_eq;
 
+  const STRING_BACKTICK: TokenKind = StringToken(StringKind::Backtick);
+  const STRING_RAW: TokenKind = StringToken(StringKind::Raw);
+  const STRING_COOKED: TokenKind = StringToken(StringKind::Cooked);
+
   macro_rules! test {
     {
       name:   $name:ident,
@@ -929,7 +901,7 @@ mod tests {
       Dedent | Eof => "",
 
       // Variable lexemes
-      Text | StringCooked | StringRaw | Identifier | Comment | Backtick | Unspecified =>
+      Text | StringToken(_) | Identifier | Comment | Unspecified =>
         panic!("Token {:?} has no default lexeme", kind),
     }
   }
@@ -993,19 +965,37 @@ mod tests {
   test! {
     name:   backtick,
     text:   "`echo`",
-    tokens: (Backtick:"`echo`"),
+    tokens: (STRING_BACKTICK:"`echo`"),
+  }
+
+  test! {
+    name:   backtick_multi_line,
+    text:   "`echo\necho`",
+    tokens: (STRING_BACKTICK:"`echo\necho`"),
   }
 
   test! {
     name:   raw_string,
     text:   "'hello'",
-    tokens: (StringRaw:"'hello'"),
+    tokens: (STRING_RAW:"'hello'"),
+  }
+
+  test! {
+    name:   raw_string_multi_line,
+    text:   "'hello\ngoodbye'",
+    tokens: (STRING_RAW:"'hello\ngoodbye'"),
   }
 
   test! {
     name:   cooked_string,
     text:   "\"hello\"",
-    tokens: (StringCooked:"\"hello\""),
+    tokens: (STRING_COOKED:"\"hello\""),
+  }
+
+  test! {
+    name:   cooked_string_multi_line,
+    text:   "\"hello\ngoodbye\"",
+    tokens: (STRING_COOKED:"\"hello\ngoodbye\""),
   }
 
   test! {
@@ -1066,11 +1056,11 @@ mod tests {
       Whitespace,
       Equals,
       Whitespace,
-      StringRaw:"'foo'",
+      STRING_RAW:"'foo'",
       Whitespace,
       Plus,
       Whitespace,
-      StringRaw:"'bar'",
+      STRING_RAW:"'bar'",
     )
   }
 
@@ -1085,16 +1075,16 @@ mod tests {
       Equals,
       Whitespace,
       ParenL,
-      StringRaw:"'foo'",
+      STRING_RAW:"'foo'",
       Whitespace,
       Plus,
       Whitespace,
-      StringRaw:"'bar'",
+      STRING_RAW:"'bar'",
       ParenR,
       Whitespace,
       Plus,
       Whitespace,
-      Backtick:"`baz`",
+      STRING_BACKTICK:"`baz`",
     ),
   }
 
@@ -1421,11 +1411,11 @@ mod tests {
       Indent:" ",
       Text:"echo ",
       InterpolationStart,
-      Backtick:"`echo hello`",
+      STRING_BACKTICK:"`echo hello`",
       Whitespace,
       Plus,
       Whitespace,
-      Backtick:"`echo goodbye`",
+      STRING_BACKTICK:"`echo goodbye`",
       InterpolationEnd,
       Dedent,
     ),
@@ -1441,7 +1431,7 @@ mod tests {
       Indent:" ",
       Text:"echo ",
       InterpolationStart,
-      StringRaw:"'\n'",
+      STRING_RAW:"'\n'",
       InterpolationEnd,
       Dedent,
     ),
@@ -1513,19 +1503,19 @@ mod tests {
       Whitespace,
       Equals,
       Whitespace,
-      StringCooked:"\"'a'\"",
+      STRING_COOKED:"\"'a'\"",
       Whitespace,
       Plus,
       Whitespace,
-      StringRaw:"'\"b\"'",
+      STRING_RAW:"'\"b\"'",
       Whitespace,
       Plus,
       Whitespace,
-      StringCooked:"\"'c'\"",
+      STRING_COOKED:"\"'c'\"",
       Whitespace,
       Plus,
       Whitespace,
-      StringRaw:"'\"d\"'",
+      STRING_RAW:"'\"d\"'",
       Comment:"#echo hello",
     )
   }
@@ -1593,7 +1583,7 @@ mod tests {
       Whitespace,
       Plus,
       Whitespace,
-      StringCooked:"\"z\"",
+      STRING_COOKED:"\"z\"",
       Whitespace,
       Plus,
       Whitespace,
@@ -1717,7 +1707,7 @@ mod tests {
       Eol,
       Identifier:"A",
       Equals,
-      StringRaw:"'1'",
+      STRING_RAW:"'1'",
       Eol,
       Identifier:"echo",
       Colon,
@@ -1742,11 +1732,11 @@ mod tests {
       Indent:" ",
       Text:"echo ",
       InterpolationStart,
-      Backtick:"`echo hello`",
+      STRING_BACKTICK:"`echo hello`",
       Whitespace,
       Plus,
       Whitespace,
-      Backtick:"`echo goodbye`",
+      STRING_BACKTICK:"`echo goodbye`",
       InterpolationEnd,
       Dedent
     ),
@@ -1775,11 +1765,11 @@ mod tests {
       Whitespace,
       Equals,
       Whitespace,
-      Backtick:"`echo hello`",
+      STRING_BACKTICK:"`echo hello`",
       Whitespace,
       Plus,
       Whitespace,
-      Backtick:"`echo goodbye`",
+      STRING_BACKTICK:"`echo goodbye`",
     ),
   }
 
@@ -2021,7 +2011,7 @@ mod tests {
     line:   0,
     column: 4,
     width:  1,
-    kind:   UnterminatedString,
+    kind:   UnterminatedString(StringKind::Cooked),
   }
 
   error! {
@@ -2031,7 +2021,7 @@ mod tests {
     line:   0,
     column: 4,
     width:  1,
-    kind:   UnterminatedString,
+    kind:   UnterminatedString(StringKind::Raw),
   }
 
   error! {
@@ -2052,7 +2042,7 @@ mod tests {
     line:   0,
     column: 0,
     width:  1,
-    kind:   UnterminatedBacktick,
+    kind:   UnterminatedString(StringKind::Backtick),
   }
 
   error! {
@@ -2112,7 +2102,7 @@ mod tests {
     line:   0,
     column: 4,
     width:  1,
-    kind:   UnterminatedString,
+    kind:   UnterminatedString(StringKind::Cooked),
   }
 
   error! {
diff --git a/src/lib.rs b/src/lib.rs
index fe9a0a44..9fccd163 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -117,6 +117,7 @@ mod setting;
 mod settings;
 mod shebang;
 mod show_whitespace;
+mod string_kind;
 mod string_literal;
 mod subcommand;
 mod suggestion;
diff --git a/src/parser.rs b/src/parser.rs
index 25f4c6e9..1d252595 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -453,11 +453,11 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
 
   /// Parse a value, e.g. `(bar)`
   fn parse_value(&mut self) -> CompilationResult<'src, Expression<'src>> {
-    if self.next_is(StringCooked) || self.next_is(StringRaw) {
+    if self.next_is(StringToken(StringKind::Cooked)) || self.next_is(StringToken(StringKind::Raw)) {
       Ok(Expression::StringLiteral {
         string_literal: self.parse_string_literal()?,
       })
-    } else if self.next_is(Backtick) {
+    } else if self.next_is(StringToken(StringKind::Backtick)) {
       let next = self.next()?;
 
       let contents = &next.lexeme()[1..next.lexeme().len() - 1];
@@ -486,16 +486,19 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
 
   /// Parse a string literal, e.g. `"FOO"`
   fn parse_string_literal(&mut self) -> CompilationResult<'src, StringLiteral<'src>> {
-    let token = self.expect_any(&[StringRaw, StringCooked])?;
+    let token = self.expect_any(&[
+      StringToken(StringKind::Raw),
+      StringToken(StringKind::Cooked),
+    ])?;
 
     let raw = &token.lexeme()[1..token.lexeme().len() - 1];
 
     match token.kind {
-      StringRaw => Ok(StringLiteral {
+      StringToken(StringKind::Raw) => Ok(StringLiteral {
         raw,
         cooked: Cow::Borrowed(raw),
       }),
-      StringCooked => {
+      StringToken(StringKind::Cooked) => {
         let mut cooked = String::new();
         let mut escape = false;
         for c in raw.chars() {
@@ -1720,7 +1723,13 @@ mod tests {
     column: 10,
     width:  1,
     kind:   UnexpectedToken {
-      expected: vec![Backtick, Identifier, ParenL, StringCooked, StringRaw],
+      expected: vec![
+        Identifier,
+        ParenL,
+        StringToken(StringKind::Backtick),
+        StringToken(StringKind::Cooked),
+        StringToken(StringKind::Raw)
+      ],
       found: Eol
     },
   }
@@ -1733,7 +1742,13 @@ mod tests {
     column: 10,
     width:  0,
     kind:   UnexpectedToken {
-      expected: vec![Backtick, Identifier, ParenL, StringCooked, StringRaw],
+      expected: vec![
+        Identifier,
+        ParenL,
+        StringToken(StringKind::Backtick),
+        StringToken(StringKind::Cooked),
+        StringToken(StringKind::Raw)
+      ],
       found: Eof,
     },
   }
@@ -1769,7 +1784,14 @@ mod tests {
     column: 9,
     width:  0,
     kind: UnexpectedToken{
-      expected: vec![Backtick, Identifier, ParenL, ParenR, StringCooked, StringRaw],
+      expected: vec![
+        Identifier,
+        ParenL,
+        ParenR,
+        StringToken(StringKind::Backtick),
+        StringToken(StringKind::Cooked),
+        StringToken(StringKind::Raw)
+      ],
       found: Eof,
     },
   }
@@ -1782,7 +1804,14 @@ mod tests {
     column: 12,
     width:  2,
     kind:   UnexpectedToken{
-      expected: vec![Backtick, Identifier, ParenL, ParenR, StringCooked, StringRaw],
+      expected: vec![
+        Identifier,
+        ParenL,
+        ParenR,
+        StringToken(StringKind::Backtick),
+        StringToken(StringKind::Cooked),
+        StringToken(StringKind::Raw)
+      ],
       found: InterpolationEnd,
     },
   }
@@ -1858,7 +1887,7 @@ mod tests {
     column: 14,
     width:  1,
     kind:   UnexpectedToken {
-      expected: vec![StringCooked, StringRaw],
+      expected: vec![StringToken(StringKind::Cooked), StringToken(StringKind::Raw)],
       found: BracketR,
     },
   }
@@ -1897,7 +1926,7 @@ mod tests {
     column: 21,
     width:  0,
     kind:   UnexpectedToken {
-      expected: vec![BracketR, StringCooked, StringRaw],
+      expected: vec![BracketR, StringToken(StringKind::Cooked), StringToken(StringKind::Raw)],
       found: Eof,
     },
   }
diff --git a/src/string_kind.rs b/src/string_kind.rs
new file mode 100644
index 00000000..d4847205
--- /dev/null
+++ b/src/string_kind.rs
@@ -0,0 +1,33 @@
+use crate::common::*;
+
+#[derive(Debug, PartialEq, Clone, Copy, Ord, PartialOrd, Eq)]
+pub(crate) enum StringKind {
+  Backtick,
+  Cooked,
+  Raw,
+}
+
+impl StringKind {
+  pub(crate) fn delimiter(self) -> char {
+    match self {
+      Self::Backtick => '`',
+      Self::Cooked => '"',
+      Self::Raw => '\'',
+    }
+  }
+
+  pub(crate) fn token_kind(self) -> TokenKind {
+    TokenKind::StringToken(self)
+  }
+
+  pub(crate) fn unterminated_error_kind(self) -> CompilationErrorKind<'static> {
+    CompilationErrorKind::UnterminatedString(self)
+  }
+
+  pub(crate) fn processes_escape_sequences(self) -> bool {
+    match self {
+      Self::Backtick | Self::Raw => false,
+      Self::Cooked => true,
+    }
+  }
+}
diff --git a/src/token_kind.rs b/src/token_kind.rs
index 42564d91..3be221b3 100644
--- a/src/token_kind.rs
+++ b/src/token_kind.rs
@@ -4,7 +4,6 @@ use crate::common::*;
 pub(crate) enum TokenKind {
   Asterisk,
   At,
-  Backtick,
   BangEquals,
   BraceL,
   BraceR,
@@ -27,8 +26,7 @@ pub(crate) enum TokenKind {
   ParenL,
   ParenR,
   Plus,
-  StringCooked,
-  StringRaw,
+  StringToken(StringKind),
   Text,
   Unspecified,
   Whitespace,
@@ -40,7 +38,6 @@ impl Display for TokenKind {
     write!(f, "{}", match *self {
       Asterisk => "'*'",
       At => "'@'",
-      Backtick => "backtick",
       BangEquals => "'!='",
       BraceL => "'{'",
       BraceR => "'}'",
@@ -63,8 +60,9 @@ impl Display for TokenKind {
       ParenL => "'('",
       ParenR => "')'",
       Plus => "'+'",
-      StringCooked => "cooked string",
-      StringRaw => "raw string",
+      StringToken(StringKind::Backtick) => "backtick",
+      StringToken(StringKind::Cooked) => "cooked string",
+      StringToken(StringKind::Raw) => "raw string",
       Text => "command text",
       Whitespace => "whitespace",
       Unspecified => "unspecified",


Test patch:
diff --git a/tests/lib.rs b/tests/lib.rs
index 76ac0797..ef906786 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -20,4 +20,5 @@ mod quiet;
 mod readme;
 mod search;
 mod shell;
+mod string;
 mod working_directory;
diff --git a/tests/misc.rs b/tests/misc.rs
index eb9ef9e7..69e4b1c1 100644
--- a/tests/misc.rs
+++ b/tests/misc.rs
@@ -588,18 +588,6 @@ hello := "c"
 "#,
 }
 
-test! {
-  name:     raw_string,
-  justfile: r#"
-export EXPORTED_VARIABLE := '\z'
-
-recipe:
-  printf "$EXPORTED_VARIABLE"
-"#,
-  stdout:   "\\z",
-  stderr:   "printf \"$EXPORTED_VARIABLE\"\n",
-}
-
 test! {
   name:     line_error_spacing,
   justfile: r#"
@@ -1508,145 +1496,6 @@ test! {
   status:   EXIT_FAILURE,
 }
 
-test! {
-  name:     invalid_escape_sequence,
-  justfile: r#"x := "\q"
-a:"#,
-  args:     ("a"),
-  stdout:   "",
-  stderr:   "error: `\\q` is not a valid escape sequence
-  |
-1 | x := \"\\q\"
-  |      ^^^^
-",
-  status:   EXIT_FAILURE,
-}
-
-test! {
-  name:     multiline_raw_string,
-  justfile: "
-string := 'hello
-whatever'
-
-a:
-  echo '{{string}}'
-",
-  args:     ("a"),
-  stdout:   "hello
-whatever
-",
-  stderr:   "echo 'hello
-whatever'
-",
-}
-
-test! {
-  name:     error_line_after_multiline_raw_string,
-  justfile: "
-string := 'hello
-
-whatever' + 'yo'
-
-a:
-  echo '{{foo}}'
-",
-  args:     ("a"),
-  stdout:   "",
-  stderr:   "error: Variable `foo` not defined
-  |
-7 |   echo '{{foo}}'
-  |           ^^^
-",
-  status:   EXIT_FAILURE,
-}
-
-test! {
-  name:     error_column_after_multiline_raw_string,
-  justfile: "
-string := 'hello
-
-whatever' + bar
-
-a:
-  echo '{{string}}'
-",
-  args:     ("a"),
-  stdout:   "",
-  stderr:   "error: Variable `bar` not defined
-  |
-4 | whatever' + bar
-  |             ^^^
-",
-  status:   EXIT_FAILURE,
-}
-
-test! {
-  name:     multiline_raw_string_in_interpolation,
-  justfile: r#"
-a:
-  echo '{{"a" + '
-  ' + "b"}}'
-"#,
-  args:     ("a"),
-  stdout:   "
-    a
-      b
-  ",
-  stderr:   "
-    echo 'a
-      b'
-  ",
-}
-
-test! {
-  name:     error_line_after_multiline_raw_string_in_interpolation,
-  justfile: r#"
-a:
-  echo '{{"a" + '
-  ' + "b"}}'
-
-  echo {{b}}
-"#,
-  args:     ("a"),
-  stdout:   "",
-  stderr:   "error: Variable `b` not defined
-  |
-6 |   echo {{b}}
-  |          ^
-",
-  status:   EXIT_FAILURE,
-}
-
-test! {
-  name:     unterminated_raw_string,
-  justfile: "
-a b= ':
-",
-  args:     ("a"),
-  stdout:   "",
-  stderr:   "error: Unterminated string
-  |
-2 | a b= ':
-  |      ^
-",
-  status:   EXIT_FAILURE,
-}
-
-test! {
-  name:     unterminated_string,
-  justfile: r#"
-a b= ":
-"#,
-  args:     ("a"),
-  stdout:   "",
-  stderr:   r#"error: Unterminated string
-  |
-2 | a b= ":
-  |      ^
-"#,
-  status:   EXIT_FAILURE,
-}
-
 test! {
   name:     plus_variadic_recipe,
   justfile: "
@@ -2059,20 +1908,6 @@ foo:
   status:   EXIT_FAILURE,
 }
 
-test! {
-  name:     unterminated_backtick,
-  justfile: "
-foo a=\t`echo blaaaaaah:
-  echo {{a}}",
-  stderr:   r#"
-    error: Unterminated backtick
-      |
-    2 | foo a=    `echo blaaaaaah:
-      |           ^
-  "#,
-  status:   EXIT_FAILURE,
-}
-
 test! {
   name:     unknown_start_of_token,
   justfile: "
diff --git a/tests/string.rs b/tests/string.rs
new file mode 100644
index 00000000..0b684751
--- /dev/null
+++ b/tests/string.rs
@@ -0,0 +1,201 @@
+use crate::common::*;
+
+test! {
+  name:     raw_string,
+  justfile: r#"
+export EXPORTED_VARIABLE := '\z'
+
+recipe:
+  printf "$EXPORTED_VARIABLE"
+"#,
+  stdout:   "\\z",
+  stderr:   "printf \"$EXPORTED_VARIABLE\"\n",
+}
+
+test! {
+  name:     multiline_raw_string,
+  justfile: "
+string := 'hello
+whatever'
+
+a:
+  echo '{{string}}'
+",
+  args:     ("a"),
+  stdout:   "hello
+whatever
+",
+  stderr:   "echo 'hello
+whatever'
+",
+}
+
+test! {
+  name:     multiline_backtick,
+  justfile: "
+string := `echo hello
+echo goodbye
+`
+
+a:
+  echo '{{string}}'
+",
+  args:     ("a"),
+  stdout:   "hello\ngoodbye\n",
+  stderr:   "echo 'hello
+goodbye'
+",
+}
+
+test! {
+  name:     multiline_cooked_string,
+  justfile: r#"
+string := "hello
+whatever"
+
+a:
+  echo '{{string}}'
+"#,
+  args:     ("a"),
+  stdout:   "hello
+whatever
+",
+  stderr:   "echo 'hello
+whatever'
+",
+}
+
+test! {
+  name:     invalid_escape_sequence,
+  justfile: r#"x := "\q"
+a:"#,
+  args:     ("a"),
+  stdout:   "",
+  stderr:   "error: `\\q` is not a valid escape sequence
+  |
+1 | x := \"\\q\"
+  |      ^^^^
+",
+  status:   EXIT_FAILURE,
+}
+
+test! {
+  name:     error_line_after_multiline_raw_string,
+  justfile: "
+string := 'hello
+
+whatever' + 'yo'
+
+a:
+  echo '{{foo}}'
+",
+  args:     ("a"),
+  stdout:   "",
+  stderr:   "error: Variable `foo` not defined
+  |
+7 |   echo '{{foo}}'
+  |           ^^^
+",
+  status:   EXIT_FAILURE,
+}
+
+test! {
+  name:     error_column_after_multiline_raw_string,
+  justfile: "
+string := 'hello
+
+whatever' + bar
+
+a:
+  echo '{{string}}'
+",
+  args:     ("a"),
+  stdout:   "",
+  stderr:   "error: Variable `bar` not defined
+  |
+4 | whatever' + bar
+  |             ^^^
+",
+  status:   EXIT_FAILURE,
+}
+
+test! {
+  name:     multiline_raw_string_in_interpolation,
+  justfile: r#"
+a:
+  echo '{{"a" + '
+  ' + "b"}}'
+"#,
+  args:     ("a"),
+  stdout:   "
+    a
+      b
+  ",
+  stderr:   "
+    echo 'a
+      b'
+  ",
+}
+
+test! {
+  name:     error_line_after_multiline_raw_string_in_interpolation,
+  justfile: r#"
+a:
+  echo '{{"a" + '
+  ' + "b"}}'
+
+  echo {{b}}
+"#,
+  args:     ("a"),
+  stdout:   "",
+  stderr:   "error: Variable `b` not defined
+  |
+6 |   echo {{b}}
+  |          ^
+",
+  status:   EXIT_FAILURE,
+}
+
+test! {
+  name:     unterminated_raw_string,
+  justfile: "
+a b= ':
+",
+  args:     ("a"),
+  stdout:   "",
+  stderr:   "error: Unterminated string
+  |
+2 | a b= ':
+  |      ^
+",
+  status:   EXIT_FAILURE,
+}
+
+test! {
+  name:     unterminated_string,
+  justfile: r#"
+a b= ":
+"#,
+  args:     ("a"),
+  stdout:   "",
+  stderr:   r#"error: Unterminated string
+  |
+2 | a b= ":
+  |      ^
+"#,
+  status:   EXIT_FAILURE,
+}
+
+test! {
+  name:     unterminated_backtick,
+  justfile: "
+foo a=\t`echo blaaaaaah:
+  echo {{a}}",
+  stderr:   r#"
+    error: Unterminated backtick
+      |
+    2 | foo a=    `echo blaaaaaah:
+      |           ^
+  "#,
+  status:   EXIT_FAILURE,
+}