///|
/// Check one escape at `at` (which holds the `\`): the offset after it and
/// its diagnostic, if any. The token is still a string; `unescape` keeps a
/// bad escape verbatim.
fn check_escape(
  text : String,
  at : @span.Offset,
) -> (@span.Offset, @diagnostic.Diagnostic?) {
  match unit_at(text, at.next()) {
    Some('\\' | '"' | 'b' | 'f' | 'n' | 'r' | 't' | '/') =>
      (at.next().next(), None)
    // `\` before a line break is the text block line-continuation escape
    // (the specification's "escapes in text blocks"); `decode_escapes`
    // already reads it as adding nothing. A quoted string can span lines
    // too, so this is accepted there as well, for the same reason. A CRLF
    // line break is one break: `\` + `\r\n` is the same escape.
    Some('\n') => (at.next().next(), None)
    Some('\r') if unit_at(text, at.next().next()) is Some('\n') =>
      (at.next().next().next(), None)
    Some('u') => {
      let mut end = at.next().next()
      let mut good = 0
      while good < 4 && unit_at(text, end) is Some(c) && is_hex(c) {
        end = end.next()
        good += 1
      }
      if good < 4 {
        (
          end,
          Some(
            lexical(
              "SM-SCAN-006",
              "INVALID UNICODE ESCAPE",
              "a `\\u` escape needs exactly four hex digits",
              at.to(end),
              hint="Write the code point as four hex digits, like `\\u00e9`.",
            ),
          ),
        )
      } else {
        (end, None)
      }
    }
    Some(c) => {
      let end = at.next().next()
      (
        end,
        Some(
          lexical(
            "SM-SCAN-005",
            "UNKNOWN ESCAPE",
            "I don't know the escape `\\\{c}`",
            at.to(end),
            notes=[
              "The escapes I know are `\\\\`, `\\\"`, `\\b`, `\\f`, `\\n`, `\\r`, `\\t`, `\\/` and `\\uXXXX`.",
            ],
          ),
        ),
      )
    }
    None => (at.next(), None)
  }
}

///|
/// A quoted string (which may span lines) or a text block.
fn scan_string_or_text_block(text : String, at : @span.Offset) -> Step {
  if looking_at(text, at, "\"\"\"") {
    scan_text_block(text, at)
  } else {
    scan_quoted(text, at)
  }
}

///|
/// Read string content from `from` until `closer` matches. The offset of
/// the closer (or the end of the text) and the escape diagnostics found.
fn string_body(
  text : String,
  from : @span.Offset,
  closer : String,
) -> (@span.Offset, Array[@diagnostic.Diagnostic]) {
  let found : Array[@diagnostic.Diagnostic] = []
  let mut at = from
  while unit_at(text, at) is Some(c) {
    if c == '"' && looking_at(text, at, closer) {
      return (at, found)
    }
    if c == '\\' {
      let (after, d) = check_escape(text, at)
      if d is Some(x) {
        found.push(x)
      }
      at = after
    } else {
      at = at.next()
    }
  }
  (at, found)
}

///|
fn scan_quoted(text : String, at : @span.Offset) -> Step {
  let (closer, found) = string_body(text, at.next(), "\"")
  match unit_at(text, closer) {
    Some(_) => {
      let end = closer.next()
      {
        piece: Tok(String, slice(text, at, end), at.to(end)),
        end,
        diagnostics: found[:],
      }
    }
    None =>
      error_step(
        text,
        at,
        closer,
        lexical(
          "SM-SCAN-002",
          "UNTERMINATED STRING",
          "this string never ends",
          at.to(closer),
          hint="Add the closing `\"`.",
          notes=[
            "I found an opening quote but reached the end of the file before the closing one. A string may span lines, so I read everything after the quote as the string.",
          ],
        ),
      )
  }
}

///|
/// `TextBlock = ThreeDquotes [SP] NL *TextBlockContent ThreeDquotes`.
fn scan_text_block(text : String, at : @span.Offset) -> Step {
  let three = @span.Length::new(3).unwrap()
  let after_quotes = skip_while(text, at.advance(three), c => {
    c == ' ' || c == '\t'
  })
  let (content_start, has_break) = match unit_at(text, after_quotes) {
    Some('\n') => (after_quotes.next(), true)
    Some('\r') if peek(text, after_quotes, 1) == Some('\n') =>
      (after_quotes.next().next(), true)
    _ => (at.advance(three), false)
  }
  let found : Array[@diagnostic.Diagnostic] = []
  if !has_break {
    found.push(
      lexical(
        "SM-SCAN-004",
        "TEXT BLOCK MUST START WITH A LINE BREAK",
        "the opening `\"\"\"` must be followed by a line break",
        at.to(content_start),
        hint="Put the text on the line after the opening quotes, and the closing quotes on their own line.",
      ),
    )
  }
  let (closer, escapes) = string_body(text, content_start, "\"\"\"")
  found.append(escapes)
  match unit_at(text, closer) {
    Some(_) => {
      let end = closer.advance(three)
      {
        piece: Tok(TextBlock, slice(text, at, end), at.to(end)),
        end,
        diagnostics: found[:],
      }
    }
    None =>
      error_step(
        text,
        at,
        closer,
        lexical(
          "SM-SCAN-003",
          "UNTERMINATED TEXT BLOCK",
          "this text block never ends",
          at.to(closer),
          hint="Add the closing `\"\"\"`.",
        ),
      )
  }
}