use super::tree::{AstNode, ParsedFile};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MultilineKind {
Source {
enclosing_calls: Vec<String>,
},
Escaped {
newlines: usize,
},
}
pub struct MultilineString<'a> {
pub node: AstNode<'a>,
pub kind: MultilineKind,
}
#[must_use]
pub fn collect_multiline_strings(file: &ParsedFile) -> Vec<MultilineString<'_>> {
dispatch_lang!(file.lang(), collect_multiline_strings(file))
}
pub(super) fn escaped_multiline_newlines(decoded: &[u8]) -> Option<usize> {
let is_blank = |line: &&[u8]| line.iter().all(u8::is_ascii_whitespace);
let lines: Vec<&[u8]> = decoded
.split(|&byte| byte == b'\n')
.skip_while(is_blank)
.collect();
let newlines = lines.iter().rposition(|line| !is_blank(line))?;
(newlines > 0).then_some(newlines)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diagnostic::Language;
#[rstest::rstest]
#[case::single_line_no_newline(b"hello", None)]
#[case::single_line_trailing_newline(b"hello\n", None)]
#[case::blank_newlines_only(b"\n\n\n", None)]
#[case::whitespace_lines_only(b" \n\t\n ", None)]
#[case::single_line_bounded_by_newlines(b"\n## Configuration\n", None)]
#[case::single_line_with_paragraph_break(b"line 1\n\n", None)]
#[case::two_lines_one_newline(b"line 1\nline 2", Some(1))]
#[case::two_lines_trailing_newline(
indoc::indoc! {b"
line 1
line 2
"},
Some(1),
)]
#[case::two_lines_leading_and_trailing_newlines(b"\n\nline 1\nline 2\n\n", Some(1))]
#[case::two_lines_with_blank_line_between(
indoc::indoc! {b"
line 1
line 2"},
Some(2),
)]
#[case::three_lines_two_newlines(
indoc::indoc! {b"
line 1
line 2
line 3"},
Some(2),
)]
fn test_escaped_multiline_newlines(#[case] decoded: &[u8], #[case] expected: Option<usize>) {
assert_eq!(escaped_multiline_newlines(decoded), expected);
}
#[rstest::rstest]
#[case::source_multiline_with_wrapper(
Language::Python,
indoc::indoc! {r#"
from inspect import cleandoc as clean
def local_wrap(s):
return s
x = local_wrap(clean("""
line 1
line 2
"""))
"#},
vec![(
indoc::indoc! {r#"
"""
line 1
line 2
""""#},
MultilineKind::Source {
enclosing_calls: vec!["inspect.cleandoc".to_string()],
},
)],
)]
#[case::python_escaped_strings_and_fstrings(
Language::Python,
indoc::indoc! {r#"
a = "line 1\nline 2\nline 3"
b = b"bytes 1\nbytes 2\n"
c = f"head {x}\ntail {y}\n"
d = r"raw 1\nraw 2\nraw 3"
e = "\n## Section\n"
"#},
vec![
(
"\"line 1\\nline 2\\nline 3\"",
MultilineKind::Escaped { newlines: 2 },
),
(
"b\"bytes 1\\nbytes 2\\n\"",
MultilineKind::Escaped { newlines: 1 },
),
(
"f\"head {x}\\ntail {y}\\n\"",
MultilineKind::Escaped { newlines: 1 },
),
],
)]
#[case::rust_escaped_strings_byte_strings_and_c_strings(
Language::Rust,
indoc::indoc! {r#"
fn demo() {
let s = "line 1\nline 2\nline 3";
let b = b"bytes 1\nbytes 2\n";
let c = c"cstr 1\ncstr 2\n";
let r = r"raw 1\nraw 2\nraw 3";
}
"#},
vec![
(
"\"line 1\\nline 2\\nline 3\"",
MultilineKind::Escaped { newlines: 2 },
),
(
"b\"bytes 1\\nbytes 2\\n\"",
MultilineKind::Escaped { newlines: 1 },
),
(
"c\"cstr 1\\ncstr 2\\n\"",
MultilineKind::Escaped { newlines: 1 },
),
],
)]
fn test_collect_multiline_strings(
#[case] lang: Language,
#[case] source: &str,
#[case] expected: Vec<(&str, MultilineKind)>,
) {
let file = ParsedFile::new(source, lang);
let actual: Vec<(String, MultilineKind)> = collect_multiline_strings(&file)
.into_iter()
.map(|item| (item.node.text().into_owned(), item.kind))
.collect();
let expected: Vec<(String, MultilineKind)> = expected
.into_iter()
.map(|(text, kind)| (text.to_string(), kind))
.collect();
assert_eq!(actual, expected);
}
}