use crate::MAX_INTERPOLATION_NESTING;
use crate::template::{quoted_run, skip_scalar, template_end};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TextEnd {
Closed {
end: usize,
first_hole: Option<usize>,
},
Unterminated { stopped: usize },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FragmentEnd {
Hole(usize),
Close(usize),
}
#[must_use]
pub fn text_end(src: &str, open: usize) -> TextEnd {
match run(src, open, 1) {
Ok((end, first_hole)) => TextEnd::Closed { end, first_hole },
Err(stopped) => TextEnd::Unterminated { stopped },
}
}
#[must_use]
pub fn fragment_end(src: &str, at: usize) -> Option<FragmentEnd> {
debug_assert!(matches!(src.as_bytes().get(at), Some(b'"') | Some(b'}')));
fragment(src.as_bytes(), at).ok()
}
fn run(src: &str, open: usize, level: usize) -> Result<(usize, Option<usize>), usize> {
let bytes = src.as_bytes();
let mut at = open;
let mut first_hole = None;
loop {
match fragment(bytes, at)? {
FragmentEnd::Close(end) => return Ok((end, first_hole)),
FragmentEnd::Hole(brace) => {
first_hole.get_or_insert(brace);
at = hole(src, brace, level)?;
}
}
}
}
fn fragment(bytes: &[u8], at: usize) -> Result<FragmentEnd, usize> {
let mut pos = at + 1;
while pos < bytes.len() {
match bytes[pos] {
b'\n' => return Err(pos),
b'\r' if bytes.get(pos + 1) == Some(&b'\n') => return Err(pos),
b'\\' => {
if matches!(bytes.get(pos + 1), Some(b'\n') | None)
|| (bytes.get(pos + 1) == Some(&b'\r') && bytes.get(pos + 2) == Some(&b'\n'))
{
return Err(pos + 1);
}
pos = skip_scalar(bytes, pos + 1);
}
b'{' => return Ok(FragmentEnd::Hole(pos)),
b'"' => return Ok(FragmentEnd::Close(pos + 1)),
_ => pos = skip_scalar(bytes, pos),
}
}
Err(bytes.len())
}
fn hole(src: &str, open: usize, level: usize) -> Result<usize, usize> {
if level > MAX_INTERPOLATION_NESTING {
return Err(open);
}
let bytes = src.as_bytes();
let mut pos = open + 1;
let mut depth = 1usize;
while pos < bytes.len() {
match bytes[pos] {
b'\n' => return Err(pos),
b'\r' if bytes.get(pos + 1) == Some(&b'\n') => return Err(pos),
b'{' => {
depth += 1;
pos += 1;
}
b'}' => {
depth -= 1;
if depth == 0 {
return Ok(pos);
}
pos += 1;
}
b'"' => pos = run(src, pos, level + 1)?.0,
b'\'' => pos = quoted_run(bytes, pos, b'\'')?,
b'`' => match template_end(src, pos) {
crate::template::TemplateEnd::Closed(end) => pos = end,
crate::template::TemplateEnd::Unterminated(stopped) => return Err(stopped),
},
b'/' if bytes.get(pos + 1) == Some(&b'/') => return Err(pos),
b'/' if bytes.get(pos + 1) == Some(&b'*') => pos = block_comment(bytes, pos)?,
_ => pos = skip_scalar(bytes, pos),
}
}
Err(bytes.len())
}
fn block_comment(bytes: &[u8], open: usize) -> Result<usize, usize> {
let mut pos = open + 2;
let mut depth = 1usize;
while pos < bytes.len() {
if bytes[pos] == b'\n' {
return Err(pos);
}
if bytes[pos..].starts_with(b"/*") {
depth += 1;
pos += 2;
} else if bytes[pos..].starts_with(b"*/") {
depth -= 1;
pos += 2;
if depth == 0 {
return Ok(pos);
}
} else {
pos = skip_scalar(bytes, pos);
}
}
Err(bytes.len())
}
#[cfg(test)]
mod tests {
use super::*;
fn closed(src: &str) -> bool {
matches!(text_end(src, 0), TextEnd::Closed { end, .. } if end == src.len())
}
fn holes(src: &str) -> Option<usize> {
match text_end(src, 0) {
TextEnd::Closed { first_hole, .. } => first_hole,
TextEnd::Unterminated { .. } => panic!("expected a closed literal: {src}"),
}
}
#[test]
fn a_literal_with_no_brace_has_no_holes() {
for src in [r#""""#, r#""hello""#, r#""a\"b""#, r#""tab\there""#] {
assert!(closed(src), "{src}");
assert_eq!(holes(src), None, "{src}");
}
}
#[test]
fn a_brace_that_closes_nothing_is_literal_text() {
assert!(closed(r#""}""#));
assert_eq!(holes(r#""}""#), None);
assert!(closed(r#""a } b""#));
assert_eq!(holes(r#""a } b""#), None);
}
#[test]
fn a_hole_is_found_at_its_opening_brace() {
assert_eq!(holes(r#""Part 2: {p}""#), Some(9));
assert_eq!(holes(r#""{p}""#), Some(1));
assert_eq!(holes(r#""{a}{b}""#), Some(1));
}
#[test]
fn an_escaped_brace_opens_no_hole() {
assert!(closed(r#""\{""#));
assert_eq!(holes(r#""\{""#), None);
assert_eq!(holes(r#""\{not a hole\}""#), None);
assert_eq!(holes(r#""\{{x}""#), Some(3));
}
#[test]
fn a_hole_holds_a_full_expression() {
for src in [
r#""{a + b}""#,
r#""{p.0}""#,
r#""{xs.len()}""#,
r#""{m["k"]}""#,
r#""{if x { 1 } else { 2 }}""#,
r#""{xs.map(|v| v * 2).sum()}""#,
] {
assert!(closed(src), "{src}");
assert!(holes(src).is_some(), "{src}");
}
}
#[test]
fn a_brace_inside_something_skipped_whole_is_not_the_end_of_a_hole() {
for src in [
r#""{m["}"]}""#,
r#""{c == '}'}""#,
r#""{parse(s, `{x:int}`)}""#,
r#""{a /* } */ + b}""#,
r#""{f("{y}")}""#,
] {
assert!(closed(src), "{src}");
assert_eq!(
text_end(src, 0),
TextEnd::Closed {
end: src.len(),
first_hole: Some(1)
},
"{src}"
);
}
}
#[test]
fn a_literal_that_does_not_close_on_its_line_is_unterminated() {
assert_eq!(
text_end("\"a {b\ncd\"", 0),
TextEnd::Unterminated { stopped: 5 }
);
assert_eq!(
text_end("\"never closes\n", 0),
TextEnd::Unterminated { stopped: 13 }
);
assert_eq!(
text_end("\"{a // b}\"\n", 0),
TextEnd::Unterminated { stopped: 4 }
);
assert_eq!(
text_end("\"{a /* x\ny */}\"", 0),
TextEnd::Unterminated { stopped: 8 }
);
assert_eq!(
text_end("\"abc\\\ndef\"", 0),
TextEnd::Unterminated { stopped: 5 }
);
assert_eq!(text_end("\"{a}", 0), TextEnd::Unterminated { stopped: 4 });
}
#[test]
fn a_dangling_escape_in_a_char_literal_stops_at_the_line_terminator() {
assert_eq!(
text_end("\"{c == '\\\nx'}\"", 0),
TextEnd::Unterminated { stopped: 9 }
);
assert_eq!(
text_end("\"{c == '\\\r\nx'}\"", 0),
TextEnd::Unterminated { stopped: 9 }
);
}
#[test]
fn a_quote_inside_a_char_literal_does_not_end_the_hole() {
assert!(closed(r#""{c == '"'}""#));
assert_eq!(holes(r#""{c == '"'}""#), Some(1));
}
#[test]
fn nesting_is_bounded_and_the_bound_refuses_to_enter() {
fn nested(n: usize) -> String {
let mut s = String::new();
for _ in 0..n {
s.push_str("\"{");
}
s.push('x');
for _ in 0..n {
s.push_str("}\"");
}
s
}
let at_the_bound = nested(MAX_INTERPOLATION_NESTING);
assert!(
closed(&at_the_bound),
"a literal nested exactly to the bound still closes"
);
let past = nested(MAX_INTERPOLATION_NESTING + 1);
assert!(
matches!(text_end(&past, 0), TextEnd::Unterminated { .. }),
"one past the bound is an ordinary unterminated literal"
);
let deep = "\"{".repeat(5_000);
assert!(matches!(text_end(&deep, 0), TextEnd::Unterminated { .. }));
}
#[test]
fn the_resume_path_walks_the_same_fragments() {
let src = r#""a{x}b{y}c""#;
assert_eq!(fragment_end(src, 0), Some(FragmentEnd::Hole(2)));
assert_eq!(fragment_end(src, 4), Some(FragmentEnd::Hole(6)));
assert_eq!(fragment_end(src, 8), Some(FragmentEnd::Close(src.len())));
}
#[test]
fn adjacent_holes_leave_empty_fragments() {
let src = r#""{a}{b}""#;
assert_eq!(fragment_end(src, 0), Some(FragmentEnd::Hole(1)));
assert_eq!(fragment_end(src, 3), Some(FragmentEnd::Hole(4)));
assert_eq!(fragment_end(src, 6), Some(FragmentEnd::Close(src.len())));
}
#[test]
fn a_multibyte_scalar_is_stepped_over_whole() {
let src = "\"héllo {x} wörld\"";
assert!(closed(src));
let TextEnd::Closed { end, first_hole } = text_end(src, 0) else {
panic!("expected closed");
};
assert!(src.is_char_boundary(end));
assert!(src.is_char_boundary(first_hole.unwrap()));
assert!(closed("\"a\\λb\""));
}
}