#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GridShape {
pub margin_left: u8,
pub measures: u8,
pub beats: u8,
pub margin_right: u8,
}
impl Default for GridShape {
fn default() -> Self {
Self {
margin_left: 1,
measures: 4,
beats: 4,
margin_right: 1,
}
}
}
impl GridShape {
#[must_use]
pub fn parse(raw: &str) -> Self {
let inner = extract_shape_value(raw).unwrap_or(raw.trim());
parse_shape_body(inner).unwrap_or_default()
}
}
#[must_use]
pub fn extract_grid_label(raw: &str) -> Option<String> {
let start = find_substr_ci(raw, "label")?;
let after = raw[start + 5..].trim_start();
let after = after.strip_prefix('=')?.trim_start();
if let Some(rest) = after.strip_prefix('"') {
let end = rest.find('"')?;
return Some(rest[..end].to_string());
}
let end = after.find(char::is_whitespace).unwrap_or(after.len());
if end == 0 {
return None;
}
Some(after[..end].to_string())
}
fn extract_shape_value(raw: &str) -> Option<&str> {
if let Some(start) = find_substr_ci(raw, "shape") {
let after = raw[start + 5..].trim_start();
let after = after.strip_prefix('=')?.trim_start();
if let Some(rest) = after.strip_prefix('"') {
let end = rest.find('"')?;
return Some(&rest[..end]);
}
let end = after.find(char::is_whitespace).unwrap_or(after.len());
return Some(&after[..end]);
}
None
}
fn find_substr_ci(hay: &str, needle: &str) -> Option<usize> {
let n = needle.len();
if n == 0 {
return Some(0);
}
for (idx, _) in hay.char_indices() {
let end = idx.checked_add(n)?;
if end > hay.len() {
return None;
}
if !hay.is_char_boundary(end) {
continue;
}
if hay[idx..end].eq_ignore_ascii_case(needle) {
return Some(idx);
}
}
None
}
fn parse_shape_body(s: &str) -> Option<GridShape> {
let parts: Vec<&str> = s.split('+').map(str::trim).collect();
match parts.as_slice() {
[left, body, right] => {
let left = left.parse::<u8>().ok()?;
let right = right.parse::<u8>().ok()?;
let (measures, beats) = split_body_measures_beats(body)?;
Some(GridShape {
margin_left: left,
measures,
beats,
margin_right: right,
})
}
[body] => {
let (measures, beats) = split_body_measures_beats(body)?;
Some(GridShape {
margin_left: 0,
measures,
beats,
margin_right: 0,
})
}
_ => None,
}
}
fn split_body_measures_beats(body: &str) -> Option<(u8, u8)> {
let parts: Vec<&str> = body.split(['x', 'X', '*']).map(str::trim).collect();
match parts.as_slice() {
[measures, beats] => Some((measures.parse().ok()?, beats.parse().ok()?)),
[single] => Some((1, single.parse().ok()?)),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GridBarline {
Single,
Double,
Final,
RepeatStart,
RepeatEnd,
RepeatBoth,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GridToken {
Space,
Barline(GridBarline),
Volta(u8),
Cell(Vec<String>),
Percent1,
Percent2,
Continuation,
NoChord,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GridRowKind {
Chord,
Strum,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GridRow {
pub label: Option<String>,
pub kind: GridRowKind,
pub body: Vec<GridToken>,
pub trailing_comment: Option<String>,
}
#[must_use]
pub fn tokenize_grid_line(input: &str) -> Vec<GridToken> {
let bytes = input.as_bytes();
let mut out: Vec<GridToken> = Vec::new();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c == b' ' || c == b'\t' {
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
i += 1;
}
out.push(GridToken::Space);
continue;
}
if c == b'|' {
let next = bytes.get(i + 1).copied();
match next {
Some(b':') => {
out.push(GridToken::Barline(GridBarline::RepeatStart));
i += 2;
continue;
}
Some(b'.') => {
out.push(GridToken::Barline(GridBarline::Final));
i += 2;
continue;
}
Some(b'|') => {
out.push(GridToken::Barline(GridBarline::Double));
i += 2;
continue;
}
Some(d @ b'1'..=b'9') => {
out.push(GridToken::Volta(d - b'0'));
i += 2;
continue;
}
_ => {
out.push(GridToken::Barline(GridBarline::Single));
i += 1;
continue;
}
}
}
if c == b':' && bytes.get(i + 1) == Some(&b'|') {
if bytes.get(i + 2) == Some(&b':') {
out.push(GridToken::Barline(GridBarline::RepeatBoth));
i += 3;
continue;
}
out.push(GridToken::Barline(GridBarline::RepeatEnd));
i += 2;
continue;
}
if c == b'%' {
if bytes.get(i + 1) == Some(&b'%') {
out.push(GridToken::Percent2);
i += 2;
continue;
}
out.push(GridToken::Percent1);
i += 1;
continue;
}
if c == b'.' {
out.push(GridToken::Continuation);
i += 1;
continue;
}
if c == b'n' && (i + 1 >= bytes.len() || matches!(bytes[i + 1], b' ' | b'\t' | b'|')) {
out.push(GridToken::NoChord);
i += 1;
continue;
}
let start = i;
while i < bytes.len() && !matches!(bytes[i], b' ' | b'\t' | b'|' | b':') {
i += 1;
}
let mut raw = &input[start..i];
if raw.starts_with('[') && raw.ends_with(']') && raw.len() >= 2 {
raw = &raw[1..raw.len() - 1];
}
if !raw.is_empty() {
let names: Vec<String> = raw.split('~').map(|s| s.to_string()).collect();
out.push(GridToken::Cell(names));
}
}
out
}
#[must_use]
pub fn classify_grid_row(input: &str) -> GridRow {
let tokens = tokenize_grid_line(input);
if tokens.is_empty() {
return GridRow {
label: None,
kind: GridRowKind::Chord,
body: Vec::new(),
trailing_comment: None,
};
}
let first_bar = tokens.iter().position(is_barline_like);
let last_bar = tokens.iter().rposition(is_barline_like);
let label = match first_bar {
Some(idx) if idx > 0 => {
let text = render_label_text(&tokens[..idx]);
if text.is_empty() { None } else { Some(text) }
}
_ => None,
};
let trailing_comment = match last_bar {
Some(idx) if idx + 1 < tokens.len() => {
let text = render_label_text(&tokens[idx + 1..]);
if text.is_empty() { None } else { Some(text) }
}
_ => None,
};
let body_slice: &[GridToken] = match (first_bar, last_bar) {
(Some(f), Some(l)) if l >= f => &tokens[f..=l],
_ => &tokens[..],
};
let mut kind = GridRowKind::Chord;
let mut strum_marker_idx: Option<usize> = None;
for (i, t) in body_slice.iter().enumerate() {
if is_barline_like(t) {
continue;
}
if matches!(t, GridToken::Space) {
continue;
}
if let GridToken::Cell(names) = t {
if names.len() == 1 && (names[0] == "s" || names[0] == "S") {
kind = GridRowKind::Strum;
strum_marker_idx = Some(i);
}
}
break;
}
let body: Vec<GridToken> = body_slice
.iter()
.enumerate()
.filter(|(i, _)| strum_marker_idx != Some(*i))
.map(|(_, t)| t.clone())
.collect();
GridRow {
label,
kind,
body,
trailing_comment,
}
}
fn is_barline_like(t: &GridToken) -> bool {
matches!(t, GridToken::Barline(_) | GridToken::Volta(_))
}
fn render_label_text(tokens: &[GridToken]) -> String {
let mut out = String::new();
for t in tokens {
match t {
GridToken::Space => out.push(' '),
GridToken::Cell(names) => out.push_str(&names.join("~")),
GridToken::Continuation => out.push('.'),
GridToken::Percent1 => out.push('%'),
GridToken::Percent2 => out.push_str("%%"),
GridToken::NoChord => out.push('n'),
_ => {}
}
}
out.trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shape_default_when_missing() {
assert_eq!(GridShape::parse(""), GridShape::default());
assert_eq!(GridShape::parse("garbage"), GridShape::default());
}
#[test]
fn shape_parses_attribute_form() {
assert_eq!(
GridShape::parse(r#"shape="1+4x2+4""#),
GridShape {
margin_left: 1,
measures: 4,
beats: 2,
margin_right: 4,
}
);
assert_eq!(
GridShape::parse(r#"shape="0+2x4+4""#),
GridShape {
margin_left: 0,
measures: 2,
beats: 4,
margin_right: 4,
}
);
}
#[test]
fn shape_survives_multibyte_preceding_text() {
let shape = GridShape::parse("\u{3042}shape=\"2+8x3+1\"");
assert_eq!(
shape,
GridShape {
margin_left: 2,
measures: 8,
beats: 3,
margin_right: 1,
}
);
assert_eq!(GridShape::parse("\u{30B7}shape=1+4x4+1").measures, 4);
assert_eq!(GridShape::parse("\u{4E2D}\u{6587}"), GridShape::default());
}
#[test]
fn shape_parses_bare_value() {
assert_eq!(
GridShape::parse("2+8x3+1"),
GridShape {
margin_left: 2,
measures: 8,
beats: 3,
margin_right: 1,
}
);
}
#[test]
fn extract_label_quoted_form() {
assert_eq!(
extract_grid_label(r#"label="Intro" shape="4x4""#),
Some("Intro".to_string())
);
}
#[test]
fn extract_label_bare_form() {
assert_eq!(extract_grid_label("label=Outro"), Some("Outro".to_string()));
}
#[test]
fn extract_label_missing() {
assert_eq!(extract_grid_label(r#"shape="1+4x4+1""#), None);
assert_eq!(extract_grid_label(""), None);
}
#[test]
fn shape_parses_body_only_form() {
assert_eq!(
GridShape::parse(r#"shape="4x4""#),
GridShape {
margin_left: 0,
measures: 4,
beats: 4,
margin_right: 0,
}
);
}
#[test]
fn shape_parses_bare_cell_count() {
assert_eq!(
GridShape::parse(r#"shape="16""#),
GridShape {
margin_left: 0,
measures: 1,
beats: 16,
margin_right: 0,
}
);
}
#[test]
fn tokenize_basic_bar() {
let toks = tokenize_grid_line("| G . . . |");
let kinds: Vec<_> = toks
.iter()
.filter(|t| !matches!(t, GridToken::Space))
.collect();
assert_eq!(kinds.len(), 6);
assert!(matches!(kinds[0], GridToken::Barline(GridBarline::Single)));
assert!(matches!(kinds[1], GridToken::Cell(n) if n == &vec!["G".to_string()]));
assert!(matches!(kinds[2], GridToken::Continuation));
assert!(matches!(kinds[3], GridToken::Continuation));
assert!(matches!(kinds[4], GridToken::Continuation));
assert!(matches!(kinds[5], GridToken::Barline(GridBarline::Single)));
}
#[test]
fn tokenize_all_barline_variants() {
let toks = tokenize_grid_line("|: G :|: C :| D || E |.");
let bars: Vec<_> = toks
.iter()
.filter_map(|t| match t {
GridToken::Barline(b) => Some(*b),
_ => None,
})
.collect();
assert_eq!(
bars,
vec![
GridBarline::RepeatStart,
GridBarline::RepeatBoth,
GridBarline::RepeatEnd,
GridBarline::Double,
GridBarline::Final,
]
);
}
#[test]
fn tokenize_volta_endings() {
let toks = tokenize_grid_line("|1 Em |2 Am");
let voltas: Vec<u8> = toks
.iter()
.filter_map(|t| match t {
GridToken::Volta(n) => Some(*n),
_ => None,
})
.collect();
assert_eq!(voltas, vec![1, 2]);
}
#[test]
fn tokenize_percent_repeat_markers() {
let toks = tokenize_grid_line("| % . | %% . |");
let kinds: Vec<_> = toks
.iter()
.filter(|t| !matches!(t, GridToken::Space))
.map(std::mem::discriminant)
.collect();
assert_eq!(kinds.len(), 7);
assert!(matches!(toks[2], GridToken::Percent1));
assert!(toks.iter().any(|t| matches!(t, GridToken::Percent2)));
}
#[test]
fn tokenize_multi_chord_tilde_split() {
let toks = tokenize_grid_line("| C~G ~A |");
let cells: Vec<&Vec<String>> = toks
.iter()
.filter_map(|t| match t {
GridToken::Cell(n) => Some(n),
_ => None,
})
.collect();
assert_eq!(cells[0], &vec!["C".to_string(), "G".to_string()]);
assert_eq!(cells[1], &vec!["".to_string(), "A".to_string()]);
}
#[test]
fn tokenize_no_chord_marker() {
let toks = tokenize_grid_line("| n | G |");
assert!(toks.iter().any(|t| matches!(t, GridToken::NoChord)));
}
#[test]
fn tokenize_empty_bracket_cell_drops_to_no_token() {
let toks = tokenize_grid_line("| [] G |");
let cells: Vec<&Vec<String>> = toks
.iter()
.filter_map(|t| match t {
GridToken::Cell(n) => Some(n),
_ => None,
})
.collect();
assert_eq!(cells.len(), 1);
assert_eq!(cells[0], &vec!["G".to_string()]);
}
#[test]
fn tokenize_unwraps_bracketed_chord_names() {
let toks = tokenize_grid_line("| [Am] [C] |");
let cells: Vec<&Vec<String>> = toks
.iter()
.filter_map(|t| match t {
GridToken::Cell(n) => Some(n),
_ => None,
})
.collect();
assert_eq!(cells[0], &vec!["Am".to_string()]);
assert_eq!(cells[1], &vec!["C".to_string()]);
}
#[test]
fn classify_row_with_label() {
let row = classify_grid_row("A || G7 . | C . |");
assert_eq!(row.label.as_deref(), Some("A"));
assert_eq!(row.kind, GridRowKind::Chord);
assert!(row.trailing_comment.is_none());
}
#[test]
fn classify_row_with_trailing_comment() {
let row = classify_grid_row("|: G :| repeat 4 times");
assert_eq!(row.label, None);
assert_eq!(row.trailing_comment.as_deref(), Some("repeat 4 times"));
}
#[test]
fn classify_row_strum_detection() {
let row = classify_grid_row("|s dn up dn up |");
assert_eq!(row.kind, GridRowKind::Strum);
let cells: Vec<&Vec<String>> = row
.body
.iter()
.filter_map(|t| match t {
GridToken::Cell(n) => Some(n),
_ => None,
})
.collect();
assert_eq!(cells.len(), 4);
assert_eq!(cells[0], &vec!["dn".to_string()]);
}
#[test]
fn classify_row_chord_when_not_strum() {
let row = classify_grid_row("| G7 . . . |");
assert_eq!(row.kind, GridRowKind::Chord);
}
}