Repository: casey/just
Pull request #793 (2021-04-06 03:46:10)

Multiline strings


Patch:
diff --git a/GRAMMAR.md b/GRAMMAR.md
index c2c409af..f0d6fbf2 100644
--- a/GRAMMAR.md
+++ b/GRAMMAR.md
@@ -9,17 +9,20 @@ tokens
 ------
 
 ```
-BACKTICK   = `[^`]*`
-COMMENT    = #([^!].*)?$
-DEDENT     = emitted when indentation decreases
-EOF        = emitted at the end of the file
-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 = '[^']*'
-STRING     = "[^"]*" # also processes \n \r \t \" \\ escapes
-TEXT       = recipe text, only matches in a recipe body
+BACKTICK            = `[^`]*`
+INDENTED_BACKTICK   = ```[^(```)]*```
+COMMENT             = #([^!].*)?$
+DEDENT              = emitted when indentation decreases
+EOF                 = emitted at the end of the file
+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          = '[^']*'
+INDENTED_RAW_STRING = '''[^(''')]*'''
+STRING              = "[^"]*" # also processes \n \r \t \" \\ escapes
+INDENTED_STRING     = """[^("""]*""" # also processes \n \r \t \" \\ escapes
+TEXT                = recipe text, only matches in a recipe body
 ```
 
 grammar syntax
@@ -69,14 +72,16 @@ condition     : expression '==' expression
               | expression '!=' expression
 
 value         : NAME '(' sequence? ')'
-              | STRING
-              | RAW_STRING
               | BACKTICK
+              | INDENTED_BACKTICK
               | NAME
+              | string
               | '(' expression ')'
 
 string        : STRING
+              | INDENTED_STRING
               | RAW_STRING
+              | INDENTED_RAW_STRING
 
 sequence      : expression ',' sequence
               | expression ','?
diff --git a/README.adoc b/README.adoc
index 46d9c20e..d364cdf6 100644
--- a/README.adoc
+++ b/README.adoc
@@ -545,6 +545,8 @@ string-with-newline         := "\n"
 string-with-carriage-return := "\r"
 string-with-double-quote    := "\""
 string-with-slash           := "\\"
+string-with-no-newline      := "\
+"
 ```
 
 ```sh
@@ -553,6 +555,7 @@ $ just --evaluate
 string-with-double-quote    := """
 string-with-newline         := "
 "
+string-with-no-newline      := ""
 string-with-slash           := "\"
 string-with-tab             := "     "
 ```
@@ -580,6 +583,25 @@ $ just --evaluate
 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 leading whitespace common to all non-blank lines:
+
+```make
+# this string will evaluate to `foo\nbar\n`
+x := '''
+  foo
+  bar
+'''
+
+# this string will evaluate to `abc\n  wuv\nbar\n`
+y := """
+  abc
+    wuv
+  xyz
+"""
+```
+
+Similar to unindented strings, indented double-quoted strings process escape sequences, and indented single-quoted strings ignore escape sequences. Escape sequence processing takes place after unindentation. The unindention algorithm does not take escape-sequence produced whitespace or newlines into account.
+
 === Ignoring Errors
 
 Normally, if a command returns a nonzero exit status, execution will stop. To
@@ -716,6 +738,20 @@ serve:
     ./serve {{localhost}} 8080
 ```
 
+Indented backticks, delimited by three backticks, are de-indented in the same manner as indented strings:
+
+```make
+# This backtick evaluates the command `echo foo\necho bar\n`, which produces the value `foo\nbar\n`.
+stuff := ```
+    echo foo
+    echo bar
+  ```
+```
+
+See the <<Strings>> section for details on unindenting.
+
+Backticks may not start with `#!`. This syntax is reserved for a future upgrade.
+
 === Conditional Expressions
 
 `if`/`else` expressions evaluate different branches depending on if two expressions evaluate to the same value:
diff --git a/src/common.rs b/src/common.rs
index 6b2288bd..5dd5ec03 100644
--- a/src/common.rs
+++ b/src/common.rs
@@ -1,6 +1,5 @@
 // stdlib
 pub(crate) use std::{
-  borrow::Cow,
   cmp,
   collections::{BTreeMap, BTreeSet},
   env,
@@ -31,7 +30,9 @@ pub(crate) use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
 pub(crate) use crate::{config_error, setting};
 
 // functions
-pub(crate) use crate::{default::default, empty::empty, load_dotenv::load_dotenv, output::output};
+pub(crate) use crate::{
+  default::default, empty::empty, load_dotenv::load_dotenv, output::output, unindent::unindent,
+};
 
 // traits
 pub(crate) use crate::{
diff --git a/src/compilation_error.rs b/src/compilation_error.rs
index 83d1129f..62ac6537 100644
--- a/src/compilation_error.rs
+++ b/src/compilation_error.rs
@@ -26,6 +26,9 @@ impl Display for CompilationError<'_> {
           recipe_line.ordinal(),
         )?;
       },
+      BacktickShebang => {
+        writeln!(f, "Backticks may not start with `#!`")?;
+      },
       CircularRecipeDependency { recipe, ref circle } =>
         if circle.len() == 2 {
           writeln!(f, "Recipe `{}` depends on itself", recipe)?;
@@ -242,10 +245,10 @@ impl Display for CompilationError<'_> {
       UnterminatedInterpolation => {
         writeln!(f, "Unterminated interpolation")?;
       },
-      UnterminatedString(StringKind::Cooked) | UnterminatedString(StringKind::Raw) => {
+      UnterminatedString => {
         writeln!(f, "Unterminated string")?;
       },
-      UnterminatedString(StringKind::Backtick) => {
+      UnterminatedBacktick => {
         writeln!(f, "Unterminated backtick")?;
       },
       Internal { ref message } => {
diff --git a/src/compilation_error_kind.rs b/src/compilation_error_kind.rs
index 4ac83748..86fc08b6 100644
--- a/src/compilation_error_kind.rs
+++ b/src/compilation_error_kind.rs
@@ -6,6 +6,7 @@ pub(crate) enum CompilationErrorKind<'src> {
     alias:       &'src str,
     recipe_line: usize,
   },
+  BacktickShebang,
   CircularRecipeDependency {
     recipe: &'src str,
     circle: Vec<&'src str>,
@@ -107,5 +108,6 @@ pub(crate) enum CompilationErrorKind<'src> {
     open_line: usize,
   },
   UnterminatedInterpolation,
-  UnterminatedString(StringKind),
+  UnterminatedString,
+  UnterminatedBacktick,
 }
diff --git a/src/evaluator.rs b/src/evaluator.rs
index c3cd7ab0..99a8b3c4 100644
--- a/src/evaluator.rs
+++ b/src/evaluator.rs
@@ -107,7 +107,7 @@ impl<'src, 'run> Evaluator<'src, 'run> {
           }),
         }
       },
-      Expression::StringLiteral { string_literal } => Ok(string_literal.cooked.to_string()),
+      Expression::StringLiteral { string_literal } => Ok(string_literal.cooked.clone()),
       Expression::Backtick { contents, token } =>
         if self.config.dry_run {
           Ok(format!("`{}`", contents))
diff --git a/src/expression.rs b/src/expression.rs
index 1616d349..fe8ff00f 100644
--- a/src/expression.rs
+++ b/src/expression.rs
@@ -10,7 +10,7 @@ use crate::common::*;
 pub(crate) enum Expression<'src> {
   /// `contents`
   Backtick {
-    contents: &'src str,
+    contents: String,
     token:    Token<'src>,
   },
   /// `name(arguments)`
diff --git a/src/lexer.rs b/src/lexer.rs
index 916c94d5..e87166c4 100644
--- a/src/lexer.rs
+++ b/src/lexer.rs
@@ -129,6 +129,14 @@ impl<'src> Lexer<'src> {
     Ok(())
   }
 
+  fn presume_str(&mut self, s: &str) -> CompilationResult<'src, ()> {
+    for c in s.chars() {
+      self.presume(c)?;
+    }
+
+    Ok(())
+  }
+
   /// Is next character c?
   fn next_is(&self, c: char) -> bool {
     self.next == Some(c)
@@ -210,8 +218,14 @@ 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,
+      UnterminatedString | UnterminatedBacktick => {
+        let kind = match StringKind::from_token_start(self.lexeme()) {
+          Some(kind) => kind,
+          None =>
+            return self.internal_error("Lexer::error: expected string or backtick token start"),
+        };
+        kind.delimiter().len()
+      },
       // highlight the full token
       _ => self.lexeme().len(),
     };
@@ -476,9 +490,7 @@ impl<'src> Lexer<'src> {
       '+' => self.lex_single(Plus),
       '#' => self.lex_comment(),
       ' ' => self.lex_whitespace(),
-      '`' => self.lex_string(StringKind::Backtick),
-      '"' => self.lex_string(StringKind::Cooked),
-      '\'' => self.lex_string(StringKind::Raw),
+      '`' | '"' | '\'' => self.lex_string(),
       '\n' => self.lex_eol(),
       '\r' => self.lex_eol(),
       '\t' => self.lex_whitespace(),
@@ -760,23 +772,33 @@ impl<'src> Lexer<'src> {
   /// Backtick:      `[^`]*`
   /// Cooked string: "[^"]*" # also processes escape sequences
   /// Raw string:    '[^']*'
-  fn lex_string(&mut self, kind: StringKind) -> CompilationResult<'src, ()> {
-    self.presume(kind.delimiter())?;
+  fn lex_string(&mut self) -> CompilationResult<'src, ()> {
+    let kind = if let Some(kind) = StringKind::from_token_start(self.rest()) {
+      kind
+    } else {
+      self.advance()?;
+      return Err(self.internal_error("Lexer::lex_string: invalid string start"));
+    };
+
+    self.presume_str(kind.delimiter())?;
 
     let mut escape = false;
 
     loop {
-      match self.next {
-        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())),
+      if self.next == None {
+        return Err(self.error(kind.unterminated_error_kind()));
+      } else if kind.processes_escape_sequences() && self.next_is('\\') && !escape {
+        escape = true;
+      } else if self.rest_starts_with(kind.delimiter()) && !escape {
+        break;
+      } else {
+        escape = false;
       }
 
       self.advance()?;
     }
 
-    self.presume(kind.delimiter())?;
+    self.presume_str(kind.delimiter())?;
     self.token(kind.token_kind());
 
     Ok(())
@@ -789,15 +811,11 @@ 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,
-      text:   $text:expr,
-      tokens: ($($kind:ident $(: $lexeme:literal)?),* $(,)?)$(,)?
+      name:     $name:ident,
+      text:     $text:expr,
+      tokens:   ($($kind:ident $(: $lexeme:literal)?),* $(,)?)$(,)?
     } => {
       #[test]
       fn $name() {
@@ -805,7 +823,22 @@ mod tests {
 
         let lexemes: &[&str] = &[$(lexeme!($kind $(, $lexeme)?),)* ""];
 
-        test($text, kinds, lexemes);
+        test($text, true, kinds, lexemes);
+      }
+    };
+    {
+      name:     $name:ident,
+      text:     $text:expr,
+      tokens:   ($($kind:ident $(: $lexeme:literal)?),* $(,)?)$(,)?
+      unindent: $unindent:expr,
+    } => {
+      #[test]
+      fn $name() {
+        let kinds: &[TokenKind] = &[$($kind,)* Eof];
+
+        let lexemes: &[&str] = &[$(lexeme!($kind $(, $lexeme)?),)* ""];
+
+        test($text, $unindent, kinds, lexemes);
       }
     }
   }
@@ -823,8 +856,12 @@ mod tests {
     }
   }
 
-  fn test(text: &str, want_kinds: &[TokenKind], want_lexemes: &[&str]) {
-    let text = testing::unindent(text);
+  fn test(text: &str, unindent_text: bool, want_kinds: &[TokenKind], want_lexemes: &[&str]) {
+    let text = if unindent_text {
+      unindent(text)
+    } else {
+      text.to_owned()
+    };
 
     let have = Lexer::lex(&text).unwrap();
 
@@ -901,7 +938,7 @@ mod tests {
       Dedent | Eof => "",
 
       // Variable lexemes
-      Text | StringToken(_) | Identifier | Comment | Unspecified =>
+      Text | StringToken | Backtick | Identifier | Comment | Unspecified =>
         panic!("Token {:?} has no default lexeme", kind),
     }
   }
@@ -965,37 +1002,43 @@ mod tests {
   test! {
     name:   backtick,
     text:   "`echo`",
-    tokens: (STRING_BACKTICK:"`echo`"),
+    tokens: (Backtick:"`echo`"),
   }
 
   test! {
     name:   backtick_multi_line,
     text:   "`echo\necho`",
-    tokens: (STRING_BACKTICK:"`echo\necho`"),
+    tokens: (Backtick:"`echo\necho`"),
   }
 
   test! {
     name:   raw_string,
     text:   "'hello'",
-    tokens: (STRING_RAW:"'hello'"),
+    tokens: (StringToken:"'hello'"),
   }
 
   test! {
     name:   raw_string_multi_line,
     text:   "'hello\ngoodbye'",
-    tokens: (STRING_RAW:"'hello\ngoodbye'"),
+    tokens: (StringToken:"'hello\ngoodbye'"),
   }
 
   test! {
     name:   cooked_string,
     text:   "\"hello\"",
-    tokens: (STRING_COOKED:"\"hello\""),
+    tokens: (StringToken:"\"hello\""),
   }
 
   test! {
     name:   cooked_string_multi_line,
     text:   "\"hello\ngoodbye\"",
-    tokens: (STRING_COOKED:"\"hello\ngoodbye\""),
+    tokens: (StringToken:"\"hello\ngoodbye\""),
+  }
+
+  test! {
+    name:   cooked_multiline_string,
+    text:   "\"\"\"hello\ngoodbye\"\"\"",
+    tokens: (StringToken:"\"\"\"hello\ngoodbye\"\"\""),
   }
 
   test! {
@@ -1056,11 +1099,11 @@ mod tests {
       Whitespace,
       Equals,
       Whitespace,
-      STRING_RAW:"'foo'",
+      StringToken:"'foo'",
       Whitespace,
       Plus,
       Whitespace,
-      STRING_RAW:"'bar'",
+      StringToken:"'bar'",
     )
   }
 
@@ -1075,29 +1118,31 @@ mod tests {
       Equals,
       Whitespace,
       ParenL,
-      STRING_RAW:"'foo'",
+      StringToken:"'foo'",
       Whitespace,
       Plus,
       Whitespace,
-      STRING_RAW:"'bar'",
+      StringToken:"'bar'",
       ParenR,
       Whitespace,
       Plus,
       Whitespace,
-      STRING_BACKTICK:"`baz`",
+      Backtick:"`baz`",
     ),
   }
 
   test! {
-    name:   eol_linefeed,
-    text:   "\n",
-    tokens: (Eol),
+    name:     eol_linefeed,
+    text:     "\n",
+    tokens:   (Eol),
+    unindent: false,
   }
 
   test! {
-    name:   eol_carriage_return_linefeed,
-    text:   "\r\n",
-    tokens: (Eol:"\r\n"),
+    name:     eol_carriage_return_linefeed,
+    text:     "\r\n",
+    tokens:   (Eol:"\r\n"),
+    unindent: false,
   }
 
   test! {
@@ -1142,6 +1187,7 @@ mod tests {
       Eol,
       Dedent,
     ),
+    unindent: false,
   }
 
   test! {
@@ -1324,6 +1370,7 @@ mod tests {
       Eol,
       Dedent,
     ),
+    unindent: false,
   }
 
   test! {
@@ -1411,11 +1458,11 @@ mod tests {
       Indent:" ",
       Text:"echo ",
       InterpolationStart,
-      STRING_BACKTICK:"`echo hello`",
+      Backtick:"`echo hello`",
       Whitespace,
       Plus,
       Whitespace,
-      STRING_BACKTICK:"`echo goodbye`",
+      Backtick:"`echo goodbye`",
       InterpolationEnd,
       Dedent,
     ),
@@ -1431,7 +1478,7 @@ mod tests {
       Indent:" ",
       Text:"echo ",
       InterpolationStart,
-      STRING_RAW:"'\n'",
+      StringToken:"'\n'",
       InterpolationEnd,
       Dedent,
     ),
@@ -1503,19 +1550,19 @@ mod tests {
       Whitespace,
       Equals,
       Whitespace,
-      STRING_COOKED:"\"'a'\"",
+      StringToken:"\"'a'\"",
       Whitespace,
       Plus,
       Whitespace,
-      STRING_RAW:"'\"b\"'",
+      StringToken:"'\"b\"'",
       Whitespace,
       Plus,
       Whitespace,
-      STRING_COOKED:"\"'c'\"",
+      StringToken:"\"'c'\"",
       Whitespace,
       Plus,
       Whitespace,
-      STRING_RAW:"'\"d\"'",
+      StringToken:"'\"d\"'",
       Comment:"#echo hello",
     )
   }
@@ -1583,7 +1630,7 @@ mod tests {
       Whitespace,
       Plus,
       Whitespace,
-      STRING_COOKED:"\"z\"",
+      StringToken:"\"z\"",
       Whitespace,
       Plus,
       Whitespace,
@@ -1707,7 +1754,7 @@ mod tests {
       Eol,
       Identifier:"A",
       Equals,
-      STRING_RAW:"'1'",
+      StringToken:"'1'",
       Eol,
       Identifier:"echo",
       Colon,
@@ -1732,11 +1779,11 @@ mod tests {
       Indent:" ",
       Text:"echo ",
       InterpolationStart,
-      STRING_BACKTICK:"`echo hello`",
+      Backtick:"`echo hello`",
       Whitespace,
       Plus,
       Whitespace,
-      STRING_BACKTICK:"`echo goodbye`",
+      Backtick:"`echo goodbye`",
       InterpolationEnd,
       Dedent
     ),
@@ -1765,11 +1812,11 @@ mod tests {
       Whitespace,
       Equals,
       Whitespace,
-      STRING_BACKTICK:"`echo hello`",
+      Backtick:"`echo hello`",
       Whitespace,
       Plus,
       Whitespace,
-      STRING_BACKTICK:"`echo goodbye`",
+      Backtick:"`echo goodbye`",
     ),
   }
 
@@ -2011,7 +2058,7 @@ mod tests {
     line:   0,
     column: 4,
     width:  1,
-    kind:   UnterminatedString(StringKind::Cooked),
+    kind:   UnterminatedString,
   }
 
   error! {
@@ -2021,7 +2068,7 @@ mod tests {
     line:   0,
     column: 4,
     width:  1,
-    kind:   UnterminatedString(StringKind::Raw),
+    kind:   UnterminatedString,
   }
 
   error! {
@@ -2042,7 +2089,7 @@ mod tests {
     line:   0,
     column: 0,
     width:  1,
-    kind:   UnterminatedString(StringKind::Backtick),
+    kind:   UnterminatedBacktick,
   }
 
   error! {
@@ -2102,7 +2149,7 @@ mod tests {
     line:   0,
     column: 4,
     width:  1,
-    kind:   UnterminatedString(StringKind::Cooked),
+    kind:   UnterminatedString,
   }
 
   error! {
@@ -2200,7 +2247,7 @@ mod tests {
 
     assert_eq!(
       Lexer::new("!").presume('-').unwrap_err().to_string(),
-      testing::unindent(
+      unindent(
         "
         Internal error, this may indicate a bug in just: Lexer presumed character `-`
         \
diff --git a/src/lib.rs b/src/lib.rs
index 9fccd163..bec5d566 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -125,6 +125,7 @@ mod table;
 mod thunk;
 mod token;
 mod token_kind;
+mod unindent;
 mod unresolved_dependency;
 mod unresolved_recipe;
 mod use_color;
@@ -134,5 +135,9 @@ mod warning;
 
 pub use crate::run::run;
 
+// Used in integration tests.
+#[doc(hidden)]
+pub use unindent::unindent;
+
 #[cfg(feature = "summary")]
 pub mod summary;
diff --git a/src/parser.rs b/src/parser.rs
index 1d252595..69b5d294 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -100,8 +100,8 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   ///
   /// The first token in `kinds` will be added to the expected token set.
   fn next_are(&mut self, kinds: &[TokenKind]) -> bool {
-    if let Some(kind) = kinds.first() {
-      self.expected.insert(*kind);
+    if let Some(&kind) = kinds.first() {
+      self.expected.insert(kind);
     }
 
     let mut rest = self.rest();
@@ -150,17 +150,6 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
     }
   }
 
-  /// Return an error if the next token is not one of kinds `kinds`.
-  fn expect_any(&mut self, expected: &[TokenKind]) -> CompilationResult<'src, Token<'src>> {
-    for expected in expected.iter().cloned() {
-      if let Some(token) = self.accept(expected)? {
-        return Ok(token);
-      }
-    }
-
-    Err(self.unexpected_token()?)
-  }
-
   /// Return an unexpected token error if the next token is not an EOL
   fn expect_eol(&mut self) -> CompilationResult<'src, ()> {
     self.accept(Comment)?;
@@ -453,15 +442,26 @@ 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(StringToken(StringKind::Cooked)) || self.next_is(StringToken(StringKind::Raw)) {
+    if self.next_is(StringToken) {
       Ok(Expression::StringLiteral {
         string_literal: self.parse_string_literal()?,
       })
-    } else if self.next_is(StringToken(StringKind::Backtick)) {
+    } else if self.next_is(Backtick) {
       let next = self.next()?;
-
-      let contents = &next.lexeme()[1..next.lexeme().len() - 1];
+      let kind = StringKind::from_string_or_backtick(next)?;
+      let contents =
+        &next.lexeme()[kind.delimiter_len()..next.lexeme().len() - kind.delimiter_len()];
       let token = self.advance()?;
+      let contents = if kind.indented() {
+        unindent(contents)
+      } else {
+        contents.to_owned()
+      };
+
+      if contents.starts_with("#!") {
+        return Err(next.error(CompilationErrorKind::BacktickShebang));
+      }
+
       Ok(Expression::Backtick { contents, token })
     } else if self.next_is(Identifier) {
       let name = self.parse_name()?;
@@ -486,51 +486,51 @@ 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(&[
-      StringToken(StringKind::Raw),
-      StringToken(StringKind::Cooked),
-    ])?;
-
-    let raw = &token.lexeme()[1..token.lexeme().len() - 1];
-
-    match token.kind {
-      StringToken(StringKind::Raw) => Ok(StringLiteral {
-        raw,
-        cooked: Cow::Borrowed(raw),
-      }),
-      StringToken(StringKind::Cooked) => {
-        let mut cooked = String::new();
-        let mut escape = false;
-        for c in raw.chars() {
-          if escape {
-            match c {
-              'n' => cooked.push('\n'),
-              'r' => cooked.push('\r'),
-              't' => cooked.push('\t'),
-              '\\' => cooked.push('\\'),
-              '"' => cooked.push('"'),
-              other => {
-                return Err(
-                  token.error(CompilationErrorKind::InvalidEscapeSequence { character: other }),
-                );
-              },
-            }
-            escape = false;
-          } else if c == '\\' {
-            escape = true;
-          } else {
-            cooked.push(c);
+    let token = self.expect(StringToken)?;
+
+    let kind = StringKind::from_string_or_backtick(token)?;
+
+    let delimiter_len = kind.delimiter_len();
+
+    let raw = &token.lexeme()[delimiter_len..token.lexeme().len() - delimiter_len];
+
+    let unindented = if kind.indented() {
+      unindent(raw)
+    } else {
+      raw.to_owned()
+    };
+
+    let cooked = if kind.processes_escape_sequences() {
+      let mut cooked = String::new();
+      let mut escape = false;
+      for c in unindented.chars() {
+        if escape {
+          match c {
+            'n' => cooked.push('\n'),
+            'r' => cooked.push('\r'),
+            't' => cooked.push('\t'),
+            '\\' => cooked.push('\\'),
+            '\n' => {},
+            '"' => cooked.push('"'),
+            other => {
+              return Err(
+                token.error(CompilationErrorKind::InvalidEscapeSequence { character: other }),
+              );
+            },
           }
+          escape = false;
+        } else if c == '\\' {
+          escape = true;
+        } else {
+          cooked.push(c);
         }
-        Ok(StringLiteral {
-          raw,
-          cooked: Cow::Owned(cooked),
-        })
-      },
-      _ => Err(token.error(CompilationErrorKind::Internal {
-        message: "`Parser::parse_string_literal` called on non-string token".to_owned(),
-      })),
-    }
+      }
+      cooked
+    } else {
+      unindented
+    };
+
+    Ok(StringLiteral { cooked, raw, kind })
   }
 
   /// Parse a name from an identifier token
@@ -757,7 +757,6 @@ mod tests {
   use super::*;
 
   use pretty_assertions::assert_eq;
-  use testing::unindent;
   use CompilationErrorKind::*;
 
   macro_rules! test {
@@ -1186,6 +1185,15 @@ mod tests {
     tree: (justfile (assignment x "foo\nbar")),
   }
 
+  test! {
+    name: string_escape_suppress_newline,
+    text: r#"
+      x := "foo\
+      bar"
+    "#,
+    tree: (justfile (assignment x "foobar")),
+  }
+
   test! {
     name: string_escape_carriage_return,
     text: r#"x := "foo\rbar""#,
@@ -1204,6 +1212,72 @@ mod tests {
     tree: (justfile (assignment x "foo\"bar")),
   }
 
+  test! {
+    name: indented_string_raw_with_dedent,
+    text: "
+      x := '''
+        foo\\t
+        bar\\n
+      '''
+    ",
+    tree: (justfile (assignment x "foo\\t\nbar\\n\n")),
+  }
+
+  test! {
+    name: indented_string_raw_no_dedent,
+    text: "
+      x := '''
+      foo\\t
+        bar\\n
+      '''
+    ",
+    tree: (justfile (assignment x "foo\\t\n  bar\\n\n")),
+  }
+
+  test! {
+    name: indented_string_cooked,
+    text: r#"
+      x := """
+        \tfoo\t
+        \tbar\n
+      """
+    "#,
+    tree: (justfile (assignment x "\tfoo\t\n\tbar\n\n")),
+  }
+
+  test! {
+    name: indented_string_cooked_no_dedent,
+    text: r#"
+      x := """
+      \tfoo\t
+        \tbar\n
+      """
+    "#,
+    tree: (justfile (assignment x "\tfoo\t\n  \tbar\n\n")),
+  }
+
+  test! {
+    name: indented_backtick,
+    text: r#"
+      x := ```
+        \tfoo\t
+        \tbar\n
+      ```
+    "#,
+    tree: (justfile (assignment x (backtick "\\tfoo\\t\n\\tbar\\n\n"))),
+  }
+
+  test! {
+    name: indented_backtick_no_dedent,
+    text: r#"
+      x := ```
+      \tfoo\t
+        \tbar\n
+      ```
+    "#,
+    tree: (justfile (assignment x (backtick "\\tfoo\\t\n  \\tbar\\n\n"))),
+  }
+
   test! {
     name: recipe_variadic_with_default_after_default,
     text: r#"
@@ -1724,11 +1798,10 @@ mod tests {
     width:  1,
     kind:   UnexpectedToken {
       expected: vec![
+        Backtick,
         Identifier,
         ParenL,
-        StringToken(StringKind::Backtick),
-        StringToken(StringKind::Cooked),
-        StringToken(StringKind::Raw)
+        StringToken,
       ],
       found: Eol
     },
@@ -1743,11 +1816,10 @@ mod tests {
     width:  0,
     kind:   UnexpectedToken {
       expected: vec![
+        Backtick,
         Identifier,
         ParenL,
-        StringToken(StringKind::Backtick),
-        StringToken(StringKind::Cooked),
-        StringToken(StringKind::Raw)
+        StringToken,
       ],
       found: Eof,
     },
@@ -1785,12 +1857,11 @@ mod tests {
     width:  0,
     kind: UnexpectedToken{
       expected: vec![
+        Backtick,
         Identifier,
         ParenL,
         ParenR,
-        StringToken(StringKind::Backtick),
-        StringToken(StringKind::Cooked),
-        StringToken(StringKind::Raw)
+        StringToken,
       ],
       found: Eof,
     },
@@ -1805,12 +1876,11 @@ mod tests {
     width:  2,
     kind:   UnexpectedToken{
       expected: vec![
+        Backtick,
         Identifier,
         ParenL,
         ParenR,
-        StringToken(StringKind::Backtick),
-        StringToken(StringKind::Cooked),
-        StringToken(StringKind::Raw)
+        StringToken,
       ],
       found: InterpolationEnd,
     },
@@ -1887,7 +1957,9 @@ mod tests {
     column: 14,
     width:  1,
     kind:   UnexpectedToken {
-      expected: vec![StringToken(StringKind::Cooked), StringToken(StringKind::Raw)],
+      expected: vec![
+        StringToken,
+      ],
       found: BracketR,
     },
   }
@@ -1926,7 +1998,10 @@ mod tests {
     column: 21,
     width:  0,
     kind:   UnexpectedToken {
-      expected: vec![BracketR, StringToken(StringKind::Cooked), StringToken(StringKind::Raw)],
+      expected: vec![
+        BracketR,
+        StringToken,
+      ],
       found: Eof,
     },
   }
diff --git a/src/platform.rs b/src/platform.rs
index cbaf67ee..b08d90a2 100644
--- a/src/platform.rs
+++ b/src/platform.rs
@@ -53,6 +53,8 @@ impl PlatformInterface for Platform {
     command: &str,
     argument: Option<&str>,
   ) -> Result<Command, OutputError> {
+    use std::borrow::Cow;
+
     // If the path contains forward slashes…
     let command = if command.contains('/') {
       // …translate path to the interpreter from unix style to windows style.
diff --git a/src/string_kind.rs b/src/string_kind.rs
index d4847205..45e5988f 100644
--- a/src/string_kind.rs
+++ b/src/string_kind.rs
@@ -1,33 +1,94 @@
 use crate::common::*;
 
 #[derive(Debug, PartialEq, Clone, Copy, Ord, PartialOrd, Eq)]
-pub(crate) enum StringKind {
+pub(crate) struct StringKind {
+  indented:  bool,
+  delimiter: StringDelimiter,
+}
+
+#[derive(Debug, PartialEq, Clone, Copy, Ord, PartialOrd, Eq)]
+enum StringDelimiter {
   Backtick,
-  Cooked,
-  Raw,
+  QuoteDouble,
+  QuoteSingle,
 }
 
 impl StringKind {
-  pub(crate) fn delimiter(self) -> char {
-    match self {
-      Self::Backtick => '`',
-      Self::Cooked => '"',
-      Self::Raw => '\'',
+  // Indented values must come before un-indented values, or else
+  // `Self::from_token_start` will incorrectly return indented = false
+  // for indented strings.
+  const ALL: &'static [Self] = &[
+    Self::new(StringDelimiter::Backtick, true),
+    Self::new(StringDelimiter::Backtick, false),
+    Self::new(StringDelimiter::QuoteDouble, true),
+    Self::new(StringDelimiter::QuoteDouble, false),
+    Self::new(StringDelimiter::QuoteSingle, true),
+    Self::new(StringDelimiter::QuoteSingle, false),
+  ];
+
+  const fn new(delimiter: StringDelimiter, indented: bool) -> Self {
+    Self {
+      delimiter,
+      indented,
+    }
+  }
+
+  pub(crate) fn delimiter(self) -> &'static str {
+    match (self.delimiter, self.indented) {
+      (StringDelimiter::Backtick, false) => "`",
+      (StringDelimiter::Backtick, true) => "```",
+      (StringDelimiter::QuoteDouble, false) => "\"",
+      (StringDelimiter::QuoteDouble, true) => "\"\"\"",
+      (StringDelimiter::QuoteSingle, false) => "'",
+      (StringDelimiter::QuoteSingle, true) => "'''",
     }
   }
 
+  pub(crate) fn delimiter_len(self) -> usize {
+    self.delimiter().len()
+  }
+
   pub(crate) fn token_kind(self) -> TokenKind {
-    TokenKind::StringToken(self)
+    match self.delimiter {
+      StringDelimiter::QuoteDouble | StringDelimiter::QuoteSingle => TokenKind::StringToken,
+      StringDelimiter::Backtick => TokenKind::Backtick,
+    }
   }
 
   pub(crate) fn unterminated_error_kind(self) -> CompilationErrorKind<'static> {
-    CompilationErrorKind::UnterminatedString(self)
+    match self.delimiter {
+      StringDelimiter::QuoteDouble | StringDelimiter::QuoteSingle =>
+        CompilationErrorKind::UnterminatedString,
+      StringDelimiter::Backtick => CompilationErrorKind::UnterminatedBacktick,
+    }
   }
 
   pub(crate) fn processes_escape_sequences(self) -> bool {
-    match self {
-      Self::Backtick | Self::Raw => false,
-      Self::Cooked => true,
+    match self.delimiter {
+      StringDelimiter::QuoteDouble => true,
+      StringDelimiter::Backtick | StringDelimiter::QuoteSingle => false,
     }
   }
+
+  pub(crate) fn indented(self) -> bool {
+    self.indented
+  }
+
+  pub(crate) fn from_string_or_backtick(token: Token) -> CompilationResult<Self> {
+    Self::from_token_start(token.lexeme()).ok_or_else(|| {
+      token.error(CompilationErrorKind::Internal {
+        message: "StringKind::from_token: Expected String or Backtick".to_owned(),
+      })
+    })
+  }
+
+  pub(crate) fn from_token_start(token_start: &str) -> Option<Self> {
+    for &kind in Self::ALL {
+      if token_start.starts_with(kind.delimiter()) {
+        return Some(kind);
+      }
+    }
+
+    None
+  }
 }
diff --git a/src/string_literal.rs b/src/string_literal.rs
index 7429dd7d..7034366d 100644
--- a/src/string_literal.rs
+++ b/src/string_literal.rs
@@ -2,15 +2,19 @@ use crate::common::*;
 
 #[derive(PartialEq, Debug)]
 pub(crate) struct StringLiteral<'src> {
+  pub(crate) kind:   StringKind,
   pub(crate) raw:    &'src str,
-  pub(crate) cooked: Cow<'src, str>,
+  pub(crate) cooked: String,
 }
 
 impl Display for StringLiteral<'_> {
   fn fmt(&self, f: &mut Formatter) -> fmt::Result {
-    match self.cooked {
-      Cow::Borrowed(raw) => write!(f, "'{}'", raw),
-      Cow::Owned(_) => write!(f, "\"{}\"", self.raw),
-    }
+    write!(
+      f,
+      "{}{}{}",
+      self.kind.delimiter(),
+      self.raw,
+      self.kind.delimiter()
+    )
   }
 }
diff --git a/src/token_kind.rs b/src/token_kind.rs
index 3be221b3..a437762d 100644
--- a/src/token_kind.rs
+++ b/src/token_kind.rs
@@ -4,6 +4,7 @@ use crate::common::*;
 pub(crate) enum TokenKind {
   Asterisk,
   At,
+  Backtick,
   BangEquals,
   BraceL,
   BraceR,
@@ -26,7 +27,7 @@ pub(crate) enum TokenKind {
   ParenL,
   ParenR,
   Plus,
-  StringToken(StringKind),
+  StringToken,
   Text,
   Unspecified,
   Whitespace,
@@ -38,6 +39,7 @@ impl Display for TokenKind {
     write!(f, "{}", match *self {
       Asterisk => "'*'",
       At => "'@'",
+      Backtick => "backtick",
       BangEquals => "'!='",
       BraceL => "'{'",
       BraceR => "'}'",
@@ -60,12 +62,10 @@ impl Display for TokenKind {
       ParenL => "'('",
       ParenR => "')'",
       Plus => "'+'",
-      StringToken(StringKind::Backtick) => "backtick",
-      StringToken(StringKind::Cooked) => "cooked string",
-      StringToken(StringKind::Raw) => "raw string",
+      StringToken => "string",
       Text => "command text",
-      Whitespace => "whitespace",
       Unspecified => "unspecified",
+      Whitespace => "whitespace",
     })
   }
 }
diff --git a/src/tree.rs b/src/tree.rs
index 1e86cb2d..fd138226 100644
--- a/src/tree.rs
+++ b/src/tree.rs
@@ -1,6 +1,6 @@
 use crate::common::*;
 
-use std::mem;
+use std::{borrow::Cow, mem};
 
 /// Construct a `Tree` from a symbolic expression literal. This macro, and the
 /// Tree type, are only used in the Parser unit tests, providing a concise
diff --git a/src/unindent.rs b/src/unindent.rs
new file mode 100644
index 00000000..e1c0f87a
--- /dev/null
+++ b/src/unindent.rs
@@ -0,0 +1,134 @@
+#[must_use]
+pub fn unindent(text: &str) -> String {
+  // find line start and end indices
+  let mut lines = Vec::new();
+  let mut start = 0;
+  for (i, c) in text.char_indices() {
+    if c == '\n' || i == text.len() - c.len_utf8() {
+      let end = i + 1;
+      lines.push(&text[start..end]);
+      start = end;
+    }
+  }
+
+  let common_indentation = lines
+    .iter()
+    .filter(|line| !blank(line))
+    .cloned()
+    .map(indentation)
+    .fold(
+      None,
+      |common_indentation, line_indentation| match common_indentation {
+        Some(common_indentation) => Some(common(common_indentation, line_indentation)),
+        None => Some(line_indentation),
+      },
+    )
+    .unwrap_or("");
+
+  let mut replacements = Vec::with_capacity(lines.len());
+
+  for (i, line) in lines.iter().enumerate() {
+    let blank = blank(line);
+    let first = i == 0;
+    let last = i == lines.len() - 1;
+
+    let replacement = match (blank, first, last) {
+      (true, false, false) => "\n",
+      (true, _, _) => "",
+      (false, _, _) => &line[common_indentation.len()..],
+    };
+
+    replacements.push(replacement);
+  }
+
+  replacements.into_iter().collect()
+}
+
+fn indentation(line: &str) -> &str {
+  let i = line
+    .char_indices()
+    .take_while(|(_, c)| matches!(c, ' ' | '\t'))
+    .map(|(i, _)| i + 1)
+    .last()
+    .unwrap_or(0);
+
+  &line[..i]
+}
+
+fn blank(line: &str) -> bool {
+  line.chars().all(|c| matches!(c, ' ' | '\t' | '\r' | '\n'))
+}
+
+fn common<'s>(a: &'s str, b: &'s str) -> &'s str {
+  let i = a
+    .char_indices()
+    .zip(b.chars())
+    .take_while(|((_, ac), bc)| ac == bc)
+    .map(|((i, c), _)| i + c.len_utf8())
+    .last()
+    .unwrap_or(0);
+
+  &a[0..i]
+}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+
+  #[test]
+  fn unindents() {
+    assert_eq!(unindent("foo"), "foo");
+    assert_eq!(unindent("foo\nbar\nbaz\n"), "foo\nbar\nbaz\n");
+    assert_eq!(unindent(""), "");
+    assert_eq!(unindent("  foo\n  bar"), "foo\nbar");
+    assert_eq!(unindent("  foo\n  bar\n\n"), "foo\nbar\n");
+
+    assert_eq!(
+      unindent(
+        "
+          hello
+          bar
+        "
+      ),
+      "hello\nbar\n"
+    );
+
+    assert_eq!(unindent("hello\n  bar\n  foo"), "hello\n  bar\n  foo");
+
+    assert_eq!(
+      unindent(
+        "
+
+          hello
+          bar
+
+        "
+      ),
+      "\nhello\nbar\n\n"
+    );
+  }
+
+  #[test]
+  fn indentations() {
+    assert_eq!(indentation(""), "");
+    assert_eq!(indentation("foo"), "");
+    assert_eq!(indentation("   foo"), "   ");
+    assert_eq!(indentation("\t\tfoo"), "\t\t");
+    assert_eq!(indentation("\t \t foo"), "\t \t ");
+  }
+
+  #[test]
+  fn blanks() {
+    assert!(blank("       \n"));
+    assert!(!blank("       foo\n"));
+    assert!(blank("\t\t\n"));
+  }
+
+  #[test]
+  fn commons() {
+    assert_eq!(common("foo", "foobar"), "foo");
+    assert_eq!(common("foo", "bar"), "");
+    assert_eq!(common("", ""), "");
+    assert_eq!(common("", "bar"), "");
+  }
+}


Test patch:
diff --git a/src/testing.rs b/src/testing.rs
index 455ff8bd..2af210ae 100644
--- a/src/testing.rs
+++ b/src/testing.rs
@@ -30,7 +30,7 @@ pub(crate) fn search(config: &Config) -> Search {
   }
 }
 
-pub(crate) use test_utilities::{tempdir, unindent};
+pub(crate) use test_utilities::tempdir;
 
 macro_rules! analysis_error {
   (
@@ -94,7 +94,7 @@ macro_rules! run_error {
       let search = $crate::testing::search(&config);
 
       if let Subcommand::Run{ overrides, arguments } = &config.subcommand {
-        match $crate::compiler::Compiler::compile(&$crate::testing::unindent($src))
+        match $crate::compiler::Compiler::compile(&$crate::unindent::unindent($src))
           .expect("Expected successful compilation")
           .run(
             &config,
diff --git a/test-utilities/src/lib.rs b/test-utilities/src/lib.rs
index 60e6153f..88f2a0e6 100644
--- a/test-utilities/src/lib.rs
+++ b/test-utilities/src/lib.rs
@@ -20,67 +20,6 @@ pub fn assert_stdout(output: &Output, stdout: &str) {
   assert_eq!(String::from_utf8_lossy(&output.stdout), stdout);
 }
 
-pub fn unindent(text: &str) -> String {
-  // find line start and end indices
-  let mut lines = Vec::new();
-  let mut start = 0;
-  for (i, c) in text.char_indices() {
-    if c == '\n' {
-      let end = i + 1;
-      lines.push((start, end));
-      start = end;
-    }
-  }
-
-  // if the text isn't newline-terminated, add the final line
-  if text.chars().last() != Some('\n') {
-    lines.push((start, text.len()));
-  }
-
-  // find the longest common indentation
-  let mut common_indentation = None;
-  for (start, end) in lines.iter().cloned() {
-    let line = &text[start..end];
-
-    // skip blank lines
-    if blank(line) {
-      continue;
-    }
-
-    // calculate new common indentation
-    common_indentation = match common_indentation {
-      Some(common_indentation) => Some(common(common_indentation, indentation(line))),
-      None => Some(indentation(line)),
-    };
-  }
-
-  // if common indentation is present, process the text
-  if let Some(common_indentation) = common_indentation {
-    if common_indentation != "" {
-      let mut output = String::new();
-
-      for (i, (start, end)) in lines.iter().cloned().enumerate() {
-        let line = &text[start..end];
-
-        if blank(line) {
-          // skip intial and final blank line
-          if i != 0 && i != lines.len() - 1 {
-            output.push('\n');
-          }
-        } else {
-          // otherwise push the line without the common indentation
-          output.push_str(&line[common_indentation.len()..]);
-        }
-      }
-
-      return output;
-    }
-  }
-
-  // otherwise just return the input string
-  text.to_owned()
-}
-
 pub enum Entry {
   File {
     contents: &'static str,
@@ -180,42 +119,6 @@ macro_rules! tmptree {
   }
 }
 
-fn indentation(line: &str) -> &str {
-  for (i, c) in line.char_indices() {
-    if c != ' ' && c != '\t' {
-      return &line[0..i];
-    }
-  }
-
-  line
-}
-
-fn blank(line: &str) -> bool {
-  for (i, c) in line.char_indices() {
-    if c == ' ' || c == '\t' {
-      continue;
-    }
-
-    if c == '\n' && i == line.len() - 1 {
-      continue;
-    }
-
-    return false;
-  }
-
-  true
-}
-
-fn common<'s>(a: &'s str, b: &'s str) -> &'s str {
-  for ((i, ac), bc) in a.char_indices().zip(b.chars()) {
-    if ac != bc {
-      return &a[0..i];
-    }
-  }
-
-  a
-}
-
 #[cfg(test)]
 mod tests {
   use super::*;
diff --git a/tests/common.rs b/tests/common.rs
index 4f8048a9..4602111a 100644
--- a/tests/common.rs
+++ b/tests/common.rs
@@ -9,6 +9,7 @@ pub(crate) use std::{
 };
 
 pub(crate) use executable_path::executable_path;
+pub(crate) use just::unindent;
 pub(crate) use libc::{EXIT_FAILURE, EXIT_SUCCESS};
-pub(crate) use test_utilities::{assert_stdout, tempdir, tmptree, unindent};
+pub(crate) use test_utilities::{assert_stdout, tempdir, tmptree};
 pub(crate) use which::which;
diff --git a/tests/misc.rs b/tests/misc.rs
index 55c52ba7..65e80b67 100644
--- a/tests/misc.rs
+++ b/tests/misc.rs
@@ -278,7 +278,7 @@ hello:
 recipe:
   @exit 100",
   args:     ("recipe"),
-  stderr:   "error: Recipe `recipe` failed on line 6 with exit code 100\n",
+  stderr:   "error: Recipe `recipe` failed on line 5 with exit code 100\n",
   status:   100,
 }
 
@@ -347,12 +347,12 @@ test! {
 test! {
   name:     backtick_code_interpolation_tab,
   justfile: "
-backtick-fail:
-\techo {{`exit 200`}}
-",
+    backtick-fail:
+    \techo {{`exit 200`}}
+  ",
   stderr:   "    error: Backtick failed with exit code 200
       |
-    3 |     echo {{`exit 200`}}
+    2 |     echo {{`exit 200`}}
       |            ^^^^^^^^^^
   ",
   status:   200,
@@ -361,12 +361,12 @@ backtick-fail:
 test! {
   name:     backtick_code_interpolation_tabs,
   justfile: "
-backtick-fail:
-\techo {{\t`exit 200`}}
-",
+    backtick-fail:
+    \techo {{\t`exit 200`}}
+  ",
   stderr:   "error: Backtick failed with exit code 200
   |
-3 |     echo {{    `exit 200`}}
+2 |     echo {{    `exit 200`}}
   |                ^^^^^^^^^^
 ",
   status:   200,
@@ -375,13 +375,13 @@ backtick-fail:
 test! {
   name:     backtick_code_interpolation_inner_tab,
   justfile: "
-backtick-fail:
-\techo {{\t`exit\t\t200`}}
-",
+    backtick-fail:
+    \techo {{\t`exit\t\t200`}}
+  ",
   stderr:   "
     error: Backtick failed with exit code 200
       |
-    3 |     echo {{    `exit        200`}}
+    2 |     echo {{    `exit        200`}}
       |                ^^^^^^^^^^^^^^^^^
   ",
   status:   200,
@@ -390,13 +390,13 @@ backtick-fail:
 test! {
   name:     backtick_code_interpolation_leading_emoji,
   justfile: "
-backtick-fail:
-\techo 😬{{`exit 200`}}
-",
+    backtick-fail:
+    \techo 😬{{`exit 200`}}
+  ",
   stderr: "
     error: Backtick failed with exit code 200
       |
-    3 |     echo 😬{{`exit 200`}}
+    2 |     echo 😬{{`exit 200`}}
       |              ^^^^^^^^^^
   ",
   status:   200,
@@ -405,13 +405,13 @@ backtick-fail:
 test! {
   name:     backtick_code_interpolation_unicode_hell,
   justfile: "
-backtick-fail:
-\techo \t\t\t😬鎌鼬{{\t\t`exit 200 # \t\t\tabc`}}\t\t\t😬鎌鼬
-",
+    backtick-fail:
+    \techo \t\t\t😬鎌鼬{{\t\t`exit 200 # \t\t\tabc`}}\t\t\t😬鎌鼬
+  ",
   stderr: "
     error: Backtick failed with exit code 200
       |
-    3 |     echo             😬鎌鼬{{        `exit 200 #             abc`}}            😬鎌鼬
+    2 |     echo             😬鎌鼬{{        `exit 200 #             abc`}}            😬鎌鼬
       |                                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   ",
   status:   200,
@@ -419,7 +419,18 @@ backtick-fail:
 
 test! {
   name:     backtick_code_long,
-  justfile: "\n\n\n\n\n\nb := a\na := `echo hello`\nbar:\n echo '{{`exit 200`}}'",
+  justfile: "
+
+
+
+
+
+
+    b := a
+    a := `echo hello`
+    bar:
+     echo '{{`exit 200`}}'
+  ",
   stderr:   "
     error: Backtick failed with exit code 200
        |
@@ -580,6 +591,7 @@ test! {
 
 
 
+
 ???
 "#,
   stdout:   "",
@@ -727,7 +739,7 @@ recipe:
   args:     ("--color=always"),
   stdout:   "",
   stderr:   "\u{1b}[1;31merror\u{1b}[0m: \u{1b}[1m\
-Recipe `recipe` failed on line 3 with exit code 100\u{1b}[0m\n",
+Recipe `recipe` failed on line 2 with exit code 100\u{1b}[0m\n",
   status:   100,
 }
 
@@ -1217,7 +1229,7 @@ infallable:
 "#,
   stderr: r#"exit 101
 exit 202
-error: Recipe `infallable` failed on line 4 with exit code 202
+error: Recipe `infallable` failed on line 3 with exit code 202
 "#,
   status: 202,
 }
@@ -1248,29 +1260,29 @@ test! {
 test! {
   name:     shebang_line_numbers,
   justfile: r#"
-quiet:
-  #!/usr/bin/env cat
+    quiet:
+      #!/usr/bin/env cat
 
-  a
+      a
 
-  b
+      b
 
 
-  c
+      c
 
 
-"#,
-  stdout:   "#!/usr/bin/env cat
-
+  "#,
+  stdout:   "
+    #!/usr/bin/env cat
 
 
-a
+    a
 
-b
+    b
 
 
-c
-",
+    c
+  ",
 }
 
 test! {
@@ -1404,7 +1416,7 @@ test! {
   args:     ("foo"),
   stdout:   "",
   stderr:   "error: Expected comment, end of file, end of line, \
-    identifier, or '(', but found raw string
+    identifier, or '(', but found string
   |
 1 | foo: 'bar'
   |      ^^^^^
@@ -1417,7 +1429,7 @@ test! {
   justfile: "foo 'bar'",
   args:     ("foo"),
   stdout:   "",
-  stderr:   "error: Expected '*', ':', '$', identifier, or '+', but found raw string
+  stderr:   "error: Expected '*', ':', '$', identifier, or '+', but found string
   |
 1 | foo 'bar'
   |     ^^^^^
@@ -1575,7 +1587,7 @@ foo *a +b:
   stdout:   "",
   stderr:   "error: Expected \':\' or \'=\', but found \'+\'
   |
-2 | foo *a +b:
+1 | foo *a +b:
   |        ^
 ",
   status:   EXIT_FAILURE,
@@ -1590,7 +1602,7 @@ foo +a *b:
   stdout:   "",
   stderr:   "error: Expected \':\' or \'=\', but found \'*\'
   |
-2 | foo +a *b:
+1 | foo +a *b:
   |        ^
 ",
   status:   EXIT_FAILURE,
@@ -1623,7 +1635,7 @@ a: x y
   stdout:   "",
   stderr:   "error: Recipe `a` has unknown dependency `y`
   |
-4 | a: x y
+3 | a: x y
   |      ^
 ",
   status:   EXIT_FAILURE,
@@ -1775,7 +1787,7 @@ X := "\'"
    stdout:   "",
    stderr:   r#"error: `\'` is not a valid escape sequence
   |
-2 | X := "\'"
+1 | X := "\'"
   |      ^^^^
 "#,
    status:   EXIT_FAILURE,
@@ -1784,12 +1796,12 @@ X := "\'"
 test! {
    name:     unknown_variable_in_default,
    justfile: "
-foo x=bar:
-",
+     foo x=bar:
+   ",
    stdout:   "",
    stderr:   r#"error: Variable `bar` not defined
   |
-2 | foo x=bar:
+1 | foo x=bar:
   |       ^^^
 "#,
    status:   EXIT_FAILURE,
@@ -1803,7 +1815,7 @@ foo x=bar():
    stdout:   "",
    stderr:   r#"error: Call to unknown function `bar`
   |
-2 | foo x=bar():
+1 | foo x=bar():
   |       ^^^
 "#,
    status:   EXIT_FAILURE,
@@ -1863,13 +1875,13 @@ foo a=arch() o=os() f=os_family():
 test! {
   name:     unterminated_interpolation_eol,
   justfile: "
-foo:
-  echo {{
-",
+    foo:
+      echo {{
+  ",
   stderr:   r#"
     error: Unterminated interpolation
       |
-    3 |   echo {{
+    2 |   echo {{
       |        ^^
   "#,
   status:   EXIT_FAILURE,
@@ -1878,12 +1890,13 @@ foo:
 test! {
   name:     unterminated_interpolation_eof,
   justfile: "
-foo:
-  echo {{",
+    foo:
+      echo {{
+  ",
   stderr:   r#"
     error: Unterminated interpolation
       |
-    3 |   echo {{
+    2 |   echo {{
       |        ^^
   "#,
   status:   EXIT_FAILURE,
@@ -1897,7 +1910,7 @@ assembly_source_files = %(wildcard src/arch/$(arch)/*.s)
   stderr:   r#"
     error: Unknown start of token:
       |
-    2 | assembly_source_files = %(wildcard src/arch/$(arch)/*.s)
+    1 | assembly_source_files = %(wildcard src/arch/$(arch)/*.s)
       |                         ^
   "#,
    status:   EXIT_FAILURE,
@@ -1930,19 +1943,17 @@ default stdin = `cat`:
 test! {
   name:     backtick_default_cat_justfile,
   justfile: "
-default stdin = `cat justfile`:
-  echo '{{stdin}}'
-",
+    default stdin = `cat justfile`:
+      echo '{{stdin}}'
+  ",
   stdout:   "
-
     default stdin = `cat justfile`:
       echo {{stdin}}
 
     set dotenv-load := true
   ",
   stderr:   "
-    echo '
-    default stdin = `cat justfile`:
+    echo 'default stdin = `cat justfile`:
       echo '{{stdin}}'
 
     set dotenv-load := true'
diff --git a/tests/string.rs b/tests/string.rs
index 0b684751..d1ad12bb 100644
--- a/tests/string.rs
+++ b/tests/string.rs
@@ -65,6 +65,22 @@ whatever'
 ",
 }
 
+test! {
+  name:     cooked_string_suppress_newline,
+  justfile: r#"
+    a := """
+      foo\
+      bar
+    """
+
+    @default:
+      printf %s '{{a}}'
+  "#,
+  stdout: "
+    foobar
+  ",
+}
+
 test! {
   name:     invalid_escape_sequence,
   justfile: r#"x := "\q"
@@ -93,7 +109,7 @@ a:
   stdout:   "",
   stderr:   "error: Variable `foo` not defined
   |
-7 |   echo '{{foo}}'
+6 |   echo '{{foo}}'
   |           ^^^
 ",
   status:   EXIT_FAILURE,
@@ -113,7 +129,7 @@ a:
   stdout:   "",
   stderr:   "error: Variable `bar` not defined
   |
-4 | whatever' + bar
+3 | whatever' + bar
   |             ^^^
 ",
   status:   EXIT_FAILURE,
@@ -150,7 +166,7 @@ a:
   stdout:   "",
   stderr:   "error: Variable `b` not defined
   |
-6 |   echo {{b}}
+5 |   echo {{b}}
   |          ^
 ",
   status:   EXIT_FAILURE,
@@ -159,43 +175,208 @@ a:
 test! {
   name:     unterminated_raw_string,
   justfile: "
-a b= ':
-",
+    a b= ':
+  ",
   args:     ("a"),
   stdout:   "",
-  stderr:   "error: Unterminated string
-  |
-2 | a b= ':
-  |      ^
-",
+  stderr:   "
+    error: Unterminated string
+      |
+    1 | a b= ':
+      |      ^
+  ",
   status:   EXIT_FAILURE,
 }
 
 test! {
   name:     unterminated_string,
   justfile: r#"
-a b= ":
-"#,
+    a b= ":
+  "#,
   args:     ("a"),
   stdout:   "",
-  stderr:   r#"error: Unterminated string
-  |
-2 | a b= ":
-  |      ^
-"#,
+  stderr:   r#"
+    error: Unterminated string
+      |
+    1 | a b= ":
+      |      ^
+  "#,
   status:   EXIT_FAILURE,
 }
 
 test! {
   name:     unterminated_backtick,
   justfile: "
-foo a=\t`echo blaaaaaah:
-  echo {{a}}",
+    foo a=\t`echo blaaaaaah:
+      echo {{a}}
+  ",
   stderr:   r#"
     error: Unterminated backtick
       |
-    2 | foo a=    `echo blaaaaaah:
+    1 | foo a=    `echo blaaaaaah:
       |           ^
   "#,
   status:   EXIT_FAILURE,
 }
+
+test! {
+  name:     unterminated_indented_raw_string,
+  justfile: "
+    a b= ''':
+  ",
+  args:     ("a"),
+  stdout:   "",
+  stderr:   "
+    error: Unterminated string
+      |
+    1 | a b= ''':
+      |      ^^^
+  ",
+  status:   EXIT_FAILURE,
+}
+
+test! {
+  name:     unterminated_indented_string,
+  justfile: r#"
+    a b= """:
+  "#,
+  args:     ("a"),
+  stdout:   "",
+  stderr:   r#"
+    error: Unterminated string
+      |
+    1 | a b= """:
+      |      ^^^
+  "#,
+  status:   EXIT_FAILURE,
+}
+
+test! {
+  name:     unterminated_indented_backtick,
+  justfile: "
+    foo a=\t```echo blaaaaaah:
+      echo {{a}}
+  ",
+  stderr:   r#"
+    error: Unterminated backtick
+      |
+    1 | foo a=    ```echo blaaaaaah:
+      |           ^^^
+  "#,
+  status:   EXIT_FAILURE,
+}
+
+test! {
+  name:     indented_raw_string_contents_indentation_removed,
+  justfile: "
+    a := '''
+      foo
+      bar
+    '''
+
+    @default:
+      printf '{{a}}'
+  ",
+  stdout: "
+    foo
+    bar
+  ",
+}
+
+test! {
+  name:     indented_cooked_string_contents_indentation_removed,
+  justfile: r#"
+    a := """
+      foo
+      bar
+    """
+
+    @default:
+      printf '{{a}}'
+  "#,
+  stdout: "
+    foo
+    bar
+  ",
+}
+
+test! {
+  name:     indented_backtick_string_contents_indentation_removed,
+  justfile: r#"
+    a := ```
+      printf '
+      foo
+      bar
+      '
+    ```
+
+    @default:
+      printf '{{a}}'
+  "#,
+  stdout: "\n\nfoo\nbar",
+}
+
+test! {
+  name:     indented_raw_string_escapes,
+  justfile: r#"
+    a := '''
+      foo\n
+      bar
+    '''
+
+    @default:
+      printf %s '{{a}}'
+  "#,
+  stdout: r#"
+    foo\n
+    bar
+  "#,
+}
+
+test! {
+  name:     indented_cooked_string_escapes,
+  justfile: r#"
+    a := """
+      foo\n
+      bar
+    """
+
+    @default:
+      printf %s '{{a}}'
+  "#,
+  stdout: "
+    foo
+
+    bar
+  ",
+}
+
+test! {
+  name:     indented_backtick_string_escapes,
+  justfile: r#"
+    a := ```
+      printf %s '
+      foo\n
+      bar
+      '
+    ```
+
+    @default:
+      printf %s '{{a}}'
+  "#,
+  stdout: "\n\nfoo\\n\nbar",
+}
+
+test! {
+  name:     shebang_backtick,
+  justfile: "
+    x := `#!/usr/bin/env sh`
+  ",
+  stderr:   "
+    error: Backticks may not start with `#!`
+      |
+    1 | x := `#!/usr/bin/env sh`
+      |      ^^^^^^^^^^^^^^^^^^^
+  ",
+  status:   EXIT_FAILURE,
+}