use crate::ast::DirectiveKind;
use crate::chord::parse_chord;
#[derive(Debug, Clone)]
pub struct FormatOptions {
pub normalize_directive_names: bool,
pub normalize_chord_spelling: bool,
pub section_blank_lines: bool,
}
impl Default for FormatOptions {
fn default() -> Self {
Self {
normalize_directive_names: true,
normalize_chord_spelling: true,
section_blank_lines: true,
}
}
}
#[must_use]
pub fn format(input: &str, options: &FormatOptions) -> String {
let normalized = input.replace("\r\n", "\n").replace('\r', "\n");
let mut out: Vec<String> = Vec::new();
let mut pending_blanks: usize = 0;
let mut after_section_end = false;
for raw_line in normalized.lines() {
if raw_line.trim().is_empty() {
pending_blanks += 1;
continue;
}
let formatted = format_line(raw_line, options);
let is_end = is_section_end_directive(&formatted);
if options.section_blank_lines && after_section_end {
out.push(String::new());
} else if pending_blanks > 0 {
out.push(String::new());
}
pending_blanks = 0;
out.push(formatted);
after_section_end = is_end;
}
if out.is_empty() {
return String::new();
}
let mut result = out.join("\n");
result.push('\n');
result
}
fn format_line(line: &str, options: &FormatOptions) -> String {
let trimmed = line.trim_end();
if trimmed.trim_start().starts_with('#') {
return trimmed.to_string();
}
if let Some(formatted) = try_format_directive(trimmed, options) {
return formatted;
}
if options.normalize_chord_spelling {
normalize_chords_in_line(trimmed)
} else {
trimmed.to_string()
}
}
fn try_format_directive(line: &str, options: &FormatOptions) -> Option<String> {
let inner = line.strip_prefix('{')?.strip_suffix('}')?;
if inner.starts_with('+') {
return Some(line.to_string());
}
let (name_raw, value_opt) = match inner.find(':') {
Some(pos) => (&inner[..pos], Some(&inner[pos + 1..])),
None => (inner, None),
};
let name_trimmed = name_raw.trim();
let (kind, selector) = DirectiveKind::resolve_with_selector(name_trimmed);
let canonical_name = if options.normalize_directive_names {
kind.full_canonical_name()
} else {
name_trimmed.to_string()
};
let mut result = String::from("{");
result.push_str(&canonical_name);
if options.normalize_directive_names {
if let Some(sel) = &selector {
result.push('-');
result.push_str(sel);
}
}
if let Some(value) = value_opt {
let v = value.trim();
result.push_str(": ");
result.push_str(v);
}
result.push('}');
Some(result)
}
fn is_section_end_directive(line: &str) -> bool {
let inner = match line.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
Some(s) => s,
None => return false,
};
let name = inner.split(':').next().unwrap_or(inner).trim();
let (kind, _) = DirectiveKind::resolve_with_selector(name);
matches!(
kind,
DirectiveKind::EndOfChorus
| DirectiveKind::EndOfVerse
| DirectiveKind::EndOfBridge
| DirectiveKind::EndOfTab
| DirectiveKind::EndOfGrid
| DirectiveKind::EndOfAbc
| DirectiveKind::EndOfLy
| DirectiveKind::EndOfSvg
| DirectiveKind::EndOfTextblock
| DirectiveKind::EndOfSection(_)
)
}
fn normalize_chords_in_line(line: &str) -> String {
let mut result = String::with_capacity(line.len());
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
if c != '[' {
result.push(c);
continue;
}
let mut chord_raw = String::new();
let mut closed = false;
for ch in chars.by_ref() {
if ch == ']' {
closed = true;
break;
}
chord_raw.push(ch);
}
result.push('[');
result.push_str(&normalize_chord_name(&chord_raw));
if closed {
result.push(']');
}
}
result
}
fn normalize_chord_name(raw: &str) -> String {
if raw.is_empty() {
return raw.to_string();
}
let capitalized = crate::capitalize(raw);
match parse_chord(&capitalized) {
Some(detail) => detail.to_string(),
None => raw.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn opts() -> FormatOptions {
FormatOptions::default()
}
#[test]
fn directive_alias_soc_expanded() {
assert_eq!(format("{soc}\n", &opts()), "{start_of_chorus}\n");
}
#[test]
fn directive_alias_eoc_expanded() {
assert_eq!(format("{eoc}\n", &opts()), "{end_of_chorus}\n");
}
#[test]
fn directive_alias_sov_expanded() {
assert_eq!(format("{sov}\n", &opts()), "{start_of_verse}\n");
}
#[test]
fn directive_alias_t_with_value() {
assert_eq!(format("{t: My Song}\n", &opts()), "{title: My Song}\n");
}
#[test]
fn directive_alias_np_expanded() {
assert_eq!(format("{np}\n", &opts()), "{new_page}\n");
}
#[test]
fn directive_spacing_added_after_colon() {
assert_eq!(format("{title:My Song}\n", &opts()), "{title: My Song}\n");
}
#[test]
fn directive_spacing_idempotent() {
assert_eq!(format("{title: My Song}\n", &opts()), "{title: My Song}\n");
}
#[test]
fn directive_no_value_preserved() {
assert_eq!(format("{new_page}\n", &opts()), "{new_page}\n");
}
#[test]
fn directive_with_selector_preserved() {
assert_eq!(
format("{textfont-piano: Courier}\n", &opts()),
"{textfont-piano: Courier}\n"
);
}
#[test]
fn directive_name_normalization_disabled() {
let opts = FormatOptions {
normalize_directive_names: false,
..FormatOptions::default()
};
assert_eq!(format("{soc}\n", &opts), "{soc}\n");
}
#[test]
fn directive_with_selector_normalization_disabled() {
let opts = FormatOptions {
normalize_directive_names: false,
..FormatOptions::default()
};
assert_eq!(
format("{textfont-piano: Courier}\n", &opts),
"{textfont-piano: Courier}\n"
);
}
#[test]
fn chord_root_capitalized() {
assert_eq!(format("[am]Hello\n", &opts()), "[Am]Hello\n");
}
#[test]
fn chord_sharp_root_capitalized() {
assert_eq!(
format("[c#m7]Hello [g]World\n", &opts()),
"[C#m7]Hello [G]World\n"
);
}
#[test]
fn chord_already_canonical_unchanged() {
assert_eq!(format("[Am]Hello\n", &opts()), "[Am]Hello\n");
}
#[test]
fn chord_spelling_disabled() {
let opts = FormatOptions {
normalize_chord_spelling: false,
..FormatOptions::default()
};
assert_eq!(format("[am]Hello\n", &opts), "[am]Hello\n");
}
#[test]
fn section_blank_line_inserted_after_end() {
let input = "{start_of_chorus}\n[C]Hello\n{end_of_chorus}\n{start_of_verse}\n[G]World\n{end_of_verse}\n";
let result = format(input, &opts());
assert!(
result.contains("{end_of_chorus}\n\n{start_of_verse}"),
"expected blank line between sections, got:\n{result}"
);
}
#[test]
fn section_blank_line_not_doubled() {
let input = "{start_of_chorus}\n[C]Hello\n{end_of_chorus}\n\n{start_of_verse}\n[G]World\n{end_of_verse}\n";
let result = format(input, &opts());
assert!(
!result.contains("{end_of_chorus}\n\n\n"),
"unexpected double blank line, got:\n{result}"
);
}
#[test]
fn section_blank_lines_disabled() {
let opts = FormatOptions {
section_blank_lines: false,
..FormatOptions::default()
};
let input = "{start_of_chorus}\n[C]Hello\n{end_of_chorus}\n{start_of_verse}\n[G]World\n{end_of_verse}\n";
let result = format(input, &opts);
assert!(
!result.contains("{end_of_chorus}\n\n"),
"expected no blank line insertion, got:\n{result}"
);
}
#[test]
fn multiple_blank_lines_collapsed() {
let result = format("[C]Hello\n\n\n[G]World\n", &opts());
assert_eq!(result, "[C]Hello\n\n[G]World\n");
}
#[test]
fn trailing_blank_lines_removed() {
let result = format("[C]Hello\n\n\n", &opts());
assert_eq!(result, "[C]Hello\n");
}
#[test]
fn crlf_normalized() {
let result = format("[C]Hello\r\n[G]World\r\n", &opts());
assert_eq!(result, "[C]Hello\n[G]World\n");
}
#[test]
fn cr_normalized() {
let result = format("[C]Hello\r[G]World\r", &opts());
assert_eq!(result, "[C]Hello\n[G]World\n");
}
#[test]
fn file_ends_with_newline() {
let result = format("[C]Hello", &opts());
assert!(result.ends_with('\n'));
}
#[test]
fn empty_input_returns_empty() {
assert_eq!(format("", &opts()), "");
}
#[test]
fn blank_only_input_returns_empty() {
assert_eq!(format("\n\n\n", &opts()), "");
}
#[test]
fn comment_line_preserved() {
assert_eq!(
format("# This is a comment\n", &opts()),
"# This is a comment\n"
);
}
#[test]
fn idempotent_full_song() {
let input = "{t:My Song}\n{artist:Test}\n{soc}\n[am]Hello [g]World\n{eoc}\n";
let first = format(input, &opts());
let second = format(&first, &opts());
assert_eq!(first, second, "format is not idempotent");
}
#[test]
fn idempotent_already_clean() {
let clean = "{title: My Song}\n{start_of_chorus}\n[Am]Hello [G]World\n{end_of_chorus}\n";
let result = format(clean, &opts());
assert_eq!(result, clean, "clean input should be unchanged");
}
}