use super::instrument::count_braces;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Statement {
pub first_line: usize,
pub last_line: usize,
pub text: String,
pub comments: bool,
pieces: Vec<Piece>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Piece {
at: usize,
line: usize,
column: usize,
len: usize,
}
impl Statement {
fn starting(line: usize) -> Self {
Self {
first_line: line,
last_line: line,
text: String::new(),
comments: false,
pieces: Vec::new(),
}
}
fn push(&mut self, text: &str, line: usize, column: usize) {
if text.is_empty() {
return;
}
if !self.text.is_empty() {
self.text.push(' ');
}
self.pieces.push(Piece {
at: self.text.len(),
line,
column,
len: text.len(),
});
self.text.push_str(text);
self.last_line = line;
}
pub fn locate(&self, offset: usize) -> (usize, usize) {
let Some(piece) = self.pieces.iter().rev().find(|piece| piece.at <= offset) else {
return (self.first_line, offset);
};
(
piece.line,
piece.column + (offset - piece.at).min(piece.len),
)
}
pub fn offset_of(&self, line: usize, column: usize) -> Option<usize> {
let piece = self
.pieces
.iter()
.rev()
.find(|piece| piece.line == line && piece.column <= column)?;
Some(piece.at + (column - piece.column).min(piece.len))
}
pub fn covers(&self, line: usize) -> bool {
(self.first_line..=self.last_line).contains(&line)
}
}
fn scan(line: &str, mut quoted: bool) -> (usize, bool) {
let bytes = line.as_bytes();
for (index, &byte) in bytes.iter().enumerate() {
match byte {
b'"' => quoted = !quoted,
b'-' if bytes.get(index + 1) == Some(&b'-') => return (index, quoted),
_ => {}
}
}
(line.len(), quoted)
}
fn blank_or_comment(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed.is_empty() || trimmed.starts_with("--")
}
fn opens_def(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed.starts_with("def ") || trimmed.trim_end() == "def"
}
fn takes_a_chain(statement: &Statement) -> bool {
let text = statement.text.trim_start();
text.starts_with('}') || text.matches('"').count() >= 2
}
pub fn statements(source: &str) -> Vec<Statement> {
let lines: Vec<&str> = source.lines().collect();
let mut out = Vec::new();
let mut def_depth = 0usize;
let mut index = 0;
while index < lines.len() {
let raw = lines[index];
if def_depth > 0 || opens_def(raw) {
def_depth += count_braces(raw, '{');
def_depth -= count_braces(raw, '}').min(def_depth);
out.push(verbatim(raw, index));
index += 1;
continue;
}
if blank_or_comment(raw) {
out.push(verbatim(raw, index));
index += 1;
continue;
}
let (cut, mut quoted) = scan(raw, false);
let mut statement = Statement::starting(index);
statement.comments = cut < raw.len();
statement.push(raw[..cut].trim_end(), index, 0);
let mut next = index + 1;
loop {
if quoted {
let Some(line) = lines.get(next) else { break };
let (cut, still) = scan(line, true);
statement.comments |= cut < line.len();
let code = &line[..cut];
let lead = code.len() - code.trim_start().len();
statement.push(code.trim(), next, lead);
statement.last_line = next;
quoted = still;
next += 1;
continue;
}
let mut ahead = next;
while ahead < lines.len() && blank_or_comment(lines[ahead]) {
ahead += 1;
}
let continues = lines
.get(ahead)
.is_some_and(|line| line.trim_start().starts_with('|'));
if !continues || !takes_a_chain(&statement) {
break;
}
statement.comments |= lines[next..ahead]
.iter()
.any(|line| !line.trim().is_empty());
let line = lines[ahead];
let (cut, still) = scan(line, false);
statement.comments |= cut < line.len();
let code = &line[..cut];
let lead = code.len() - code.trim_start().len();
statement.push(code.trim(), ahead, lead);
quoted = still;
next = ahead + 1;
}
out.push(statement);
index = next;
}
out
}
fn verbatim(line: &str, index: usize) -> Statement {
let mut statement = Statement::starting(index);
statement.comments = line.trim_start().starts_with("--");
statement.pieces.push(Piece {
at: 0,
line: index,
column: 0,
len: line.len(),
});
statement.text = line.to_string();
statement
}
#[cfg(test)]
mod tests {
use super::*;
fn texts(source: &str) -> Vec<String> {
statements(source).into_iter().map(|s| s.text).collect()
}
#[test]
fn a_line_is_a_statement() {
assert_eq!(
texts("bpm 92\n k kick \"x ~\" | gain 0.9\n"),
["bpm 92", " k kick \"x ~\" | gain 0.9"]
);
}
#[test]
fn notation_may_break_inside_its_quotes() {
let source = " pad pad_dark \"<\n [0,2,4] -- Em\n\n [5,7,9]\n >\" @ 3..\n";
let all = statements(source);
assert_eq!(all.len(), 1);
assert_eq!(all[0].text, " pad pad_dark \"< [0,2,4] [5,7,9] >\" @ 3..");
assert_eq!((all[0].first_line, all[0].last_line), (0, 4));
assert!(all[0].comments);
let at = all[0].text.find("[5").unwrap();
assert_eq!(all[0].locate(at), (3, 4));
assert_eq!(all[0].offset_of(3, 4), Some(at));
}
#[test]
fn a_chain_may_continue_on_pipe_lines() {
let source = " bass bass \"0 2\"\n | oct -2\n -- the filter opens\n | lpf 600..1800 | ramp 4\nbpm 90\n";
assert_eq!(
texts(source),
[
" bass bass \"0 2\" | oct -2 | lpf 600..1800 | ramp 4",
"bpm 90"
]
);
let group = " } | reverb 0.1\n | gain 0.9\n";
assert_eq!(texts(group), [" } | reverb 0.1 | gain 0.9"]);
}
#[test]
fn a_pipe_line_does_not_join_a_directive() {
assert_eq!(texts("bpm 90\n| gain 2\n"), ["bpm 90", "| gain 2"]);
}
#[test]
fn trailing_comments_are_dropped_and_comment_lines_kept() {
let all = statements("-- intro\n k kick \"x ~\" -- hits\n");
assert_eq!(all[0].text, "-- intro");
assert_eq!(all[1].text, " k kick \"x ~\"");
assert!(all[1].comments);
}
#[test]
fn def_blocks_are_left_as_written() {
let source = "def thump {\n tone sine gain 0.5\n}\n k thump \"x\"\n";
assert_eq!(
texts(source),
[
"def thump {",
" tone sine gain 0.5",
"}",
" k thump \"x\""
]
);
}
}