use crate::report::parser::opens_block;
pub const INDENT_UNIT: &str = " ";
pub fn leading_ws(line: &str) -> String {
line.chars().take_while(|c| c.is_whitespace()).collect()
}
pub fn indent_for_new_line(current_line: &str) -> String {
let mut indent = leading_ws(current_line);
if opens_block(current_line) {
indent.push_str(INDENT_UNIT);
}
indent
}
pub fn is_end_line(line: &str) -> bool {
line.trim().eq_ignore_ascii_case("END")
}
pub fn matching_opener_indent<S: AsRef<str>>(lines_above: &[S]) -> Option<String> {
let mut depth = 0i32;
for prev in lines_above.iter().rev() {
let prev = prev.as_ref();
if is_end_line(prev) {
depth += 1;
} else if opens_block(prev) {
if depth == 0 {
return Some(leading_ws(prev));
}
depth -= 1;
}
}
None
}
pub enum ReformatError {
Unparseable(String),
WouldChangeMeaning,
}
pub fn reformat(src: &str) -> Result<Option<String>, ReformatError> {
let before = crate::report::parser::parse_flow(src)
.map_err(|e| ReformatError::Unparseable(e.message))?;
let mut out = String::with_capacity(src.len());
let mut depth = 0usize;
for line in src.lines() {
let body = line.trim();
if body.is_empty() {
out.push('\n');
continue;
}
if is_end_line(body) {
depth = depth.saturating_sub(1);
}
for _ in 0..depth {
out.push_str(INDENT_UNIT);
}
out.push_str(body);
out.push('\n');
if opens_block(body) {
depth += 1;
}
}
if !src.ends_with('\n') && out.ends_with('\n') {
out.pop();
}
if out == src {
return Ok(None);
}
let after =
crate::report::parser::parse_flow(&out).map_err(|_| ReformatError::WouldChangeMeaning)?;
if after != before {
return Err(ReformatError::WouldChangeMeaning);
}
Ok(Some(out))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn leading_ws_takes_only_the_prefix() {
assert_eq!(leading_ws(" REQUEST a"), " ");
assert_eq!(leading_ws("REQUEST a"), "");
assert_eq!(leading_ws("\t x"), "\t ");
assert_eq!(leading_ws(" "), " ");
}
#[test]
fn a_new_line_inherits_the_current_indent() {
assert_eq!(indent_for_new_line(" REQUEST a"), " ");
assert_eq!(indent_for_new_line("REQUEST a"), "");
}
#[test]
fn a_new_line_after_an_opener_gains_one_level() {
assert_eq!(indent_for_new_line("FOR F IN FILES \"docs\""), INDENT_UNIT);
assert_eq!(
indent_for_new_line(" FOR F IN FILES \"docs\""),
format!(" {INDENT_UNIT}")
);
assert_eq!(
indent_for_new_line("REPORT REQUEST process WITH"),
INDENT_UNIT
);
assert_eq!(indent_for_new_line("REPORT REQUEST process"), "");
}
#[test]
fn is_end_line_ignores_case_and_padding() {
assert!(is_end_line("END"));
assert!(is_end_line(" end "));
assert!(!is_end_line("ENDS"));
assert!(!is_end_line("REQUEST END"));
}
#[test]
fn end_snaps_to_its_own_opener_through_nesting() {
let above = [
"FOR A IN [\"x\"]",
" FOR B IN [\"y\"]",
" REQUEST r",
" END",
" REQUEST s",
];
assert_eq!(matching_opener_indent(&above), Some(String::new()));
let above = [
"FOR A IN [\"x\"]",
" FOR B IN [\"y\"]",
" REQUEST r",
];
assert_eq!(matching_opener_indent(&above), Some(" ".to_string()));
}
#[test]
fn an_unbalanced_end_has_no_opener() {
assert_eq!(matching_opener_indent::<&str>(&[]), None);
assert_eq!(matching_opener_indent(&["REQUEST a", "END"]), None);
}
#[test]
fn reformat_reindents_a_newly_wrapped_block() {
let src = "# collection: c\n\nFOR T IN FILES \"*.txt\"\nFOR F IN FILES \"*.png\"\nREQUEST a\nEND\nEND\n";
let out = reformat(src).ok().flatten().expect("reindented");
assert_eq!(
out,
"# collection: c\n\nFOR T IN FILES \"*.txt\"\n FOR F IN FILES \"*.png\"\n REQUEST a\n END\nEND\n"
);
}
#[test]
fn reformat_keeps_comments_and_blank_lines() {
let src =
"# collection: c\n\nFOR T IN FILES \"*.txt\"\n# why we do this\n\nREQUEST a\nEND\n";
let out = reformat(src).ok().flatten().expect("reindented");
assert!(
out.contains(" # why we do this"),
"comment kept: {out:?}"
);
assert!(out.contains("\n\n"), "blank line kept: {out:?}");
}
#[test]
fn reformat_indents_a_with_block() {
let src =
"# collection: c\n\nREPORT REQUEST proc AS p WITH\nframe: jsonpath \"$.a\"\nEND\n";
let out = reformat(src).ok().flatten().expect("reindented");
assert!(out.contains(" frame: jsonpath"), "{out:?}");
}
#[test]
fn reformat_of_tidy_text_changes_nothing() {
let src = "# collection: c\n\nFOR T IN FILES \"*.txt\"\n REQUEST a\nEND\n";
assert!(matches!(reformat(src), Ok(None)));
}
#[test]
fn reformat_refuses_source_that_does_not_parse() {
assert!(matches!(
reformat("# collection: c\nFOR X IN\n"),
Err(ReformatError::Unparseable(_))
));
}
#[test]
fn reformat_does_not_touch_the_inside_of_a_statement() {
let src = "# collection: c\n\nFOR T IN FILES \"a b\"\nREQUEST \"x y\"\nEND\n";
let out = reformat(src).ok().flatten().expect("reindented");
assert!(out.contains("FILES \"a b\""), "{out:?}");
assert!(out.contains("REQUEST \"x y\""), "{out:?}");
}
#[test]
fn reformat_preserves_a_missing_final_newline() {
let src = "# collection: c\n\nFOR T IN FILES \"*.txt\"\nREQUEST a\nEND";
let out = reformat(src).ok().flatten().expect("reindented");
assert!(!out.ends_with('\n'), "{out:?}");
}
}