Skip to main content

treble_lang/parser/
statements.rs

1//! Statements: what the line parser reads, made from the physical lines of a
2//! buffer (§4.8).
3//!
4//! Most statements are one line. A pattern line may also break inside its
5//! quotes, and its transform chain may continue on following lines that start
6//! with `|`; those lines are joined, with a space where each break was, into
7//! one statement. Comments are stripped on the way: `--` starts one anywhere
8//! outside a `def` block, inside the quotes too. A statement remembers where
9//! each of its pieces came from, so a position in the joined text — a step's
10//! [`Source`](crate::ast::Source), an error — maps back to a line and column.
11//!
12//! `def` blocks are left exactly as written: they have their own block
13//! parser, which reads its lines one by one.
14
15use super::instrument::count_braces;
16
17/// One statement and where its text came from.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Statement {
20    /// 0-based physical line the statement starts on.
21    pub first_line: usize,
22    /// 0-based physical line it ends on (the same for most statements).
23    pub last_line: usize,
24    /// The joined text, comments removed. The first line keeps its
25    /// indentation, so offsets on it are that line's own byte columns.
26    pub text: String,
27    /// Whether a comment was written anywhere inside it. Re-flowing such a
28    /// statement would drop the comment, so a formatter leaves it alone.
29    pub comments: bool,
30    pieces: Vec<Piece>,
31}
32
33/// A run of the joined text copied from one physical line.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35struct Piece {
36    /// Byte offset in [`Statement::text`].
37    at: usize,
38    line: usize,
39    /// Byte column in that line.
40    column: usize,
41    len: usize,
42}
43
44impl Statement {
45    fn starting(line: usize) -> Self {
46        Self {
47            first_line: line,
48            last_line: line,
49            text: String::new(),
50            comments: false,
51            pieces: Vec::new(),
52        }
53    }
54
55    fn push(&mut self, text: &str, line: usize, column: usize) {
56        if text.is_empty() {
57            return;
58        }
59        if !self.text.is_empty() {
60            self.text.push(' ');
61        }
62        self.pieces.push(Piece {
63            at: self.text.len(),
64            line,
65            column,
66            len: text.len(),
67        });
68        self.text.push_str(text);
69        self.last_line = line;
70    }
71
72    /// The physical `(line, byte column)` of byte `offset` in [`Self::text`].
73    /// An offset on a joining space maps to the end of the piece before it.
74    pub fn locate(&self, offset: usize) -> (usize, usize) {
75        let Some(piece) = self.pieces.iter().rev().find(|piece| piece.at <= offset) else {
76            return (self.first_line, offset);
77        };
78        (
79            piece.line,
80            piece.column + (offset - piece.at).min(piece.len),
81        )
82    }
83
84    /// The byte offset in [`Self::text`] of a physical position, when that
85    /// position falls on the statement's text. A column past a piece (in its
86    /// comment, say) maps to the piece's end.
87    pub fn offset_of(&self, line: usize, column: usize) -> Option<usize> {
88        let piece = self
89            .pieces
90            .iter()
91            .rev()
92            .find(|piece| piece.line == line && piece.column <= column)?;
93        Some(piece.at + (column - piece.column).min(piece.len))
94    }
95
96    /// Whether physical line `line` belongs to this statement.
97    pub fn covers(&self, line: usize) -> bool {
98        (self.first_line..=self.last_line).contains(&line)
99    }
100}
101
102/// Where a line's comment starts, and whether a quote is still open there.
103/// `quoted` is the quote state the line starts in.
104fn scan(line: &str, mut quoted: bool) -> (usize, bool) {
105    let bytes = line.as_bytes();
106    for (index, &byte) in bytes.iter().enumerate() {
107        match byte {
108            b'"' => quoted = !quoted,
109            b'-' if bytes.get(index + 1) == Some(&b'-') => return (index, quoted),
110            _ => {}
111        }
112    }
113    (line.len(), quoted)
114}
115
116fn blank_or_comment(line: &str) -> bool {
117    let trimmed = line.trim_start();
118    trimmed.is_empty() || trimmed.starts_with("--")
119}
120
121fn opens_def(line: &str) -> bool {
122    let trimmed = line.trim_start();
123    trimmed.starts_with("def ") || trimmed.trim_end() == "def"
124}
125
126/// A statement whose chain may continue on a `|` line: a pattern (its quotes
127/// are closed) or a group's closing brace.
128fn takes_a_chain(statement: &Statement) -> bool {
129    let text = statement.text.trim_start();
130    text.starts_with('}') || text.matches('"').count() >= 2
131}
132
133/// Split a buffer into statements, in order. Blank and comment-only lines are
134/// statements of their own, kept verbatim, as are the lines of `def` blocks.
135pub fn statements(source: &str) -> Vec<Statement> {
136    let lines: Vec<&str> = source.lines().collect();
137    let mut out = Vec::new();
138    let mut def_depth = 0usize;
139    let mut index = 0;
140    while index < lines.len() {
141        let raw = lines[index];
142        if def_depth > 0 || opens_def(raw) {
143            def_depth += count_braces(raw, '{');
144            def_depth -= count_braces(raw, '}').min(def_depth);
145            out.push(verbatim(raw, index));
146            index += 1;
147            continue;
148        }
149        if blank_or_comment(raw) {
150            out.push(verbatim(raw, index));
151            index += 1;
152            continue;
153        }
154        let (cut, mut quoted) = scan(raw, false);
155        let mut statement = Statement::starting(index);
156        statement.comments = cut < raw.len();
157        statement.push(raw[..cut].trim_end(), index, 0);
158        let mut next = index + 1;
159        loop {
160            if quoted {
161                // Inside the quotes: every line belongs, blank or not.
162                let Some(line) = lines.get(next) else { break };
163                let (cut, still) = scan(line, true);
164                statement.comments |= cut < line.len();
165                let code = &line[..cut];
166                let lead = code.len() - code.trim_start().len();
167                statement.push(code.trim(), next, lead);
168                statement.last_line = next;
169                quoted = still;
170                next += 1;
171                continue;
172            }
173            // A chain continues on the next `|` line, past blank lines and
174            // comments in between.
175            let mut ahead = next;
176            while ahead < lines.len() && blank_or_comment(lines[ahead]) {
177                ahead += 1;
178            }
179            let continues = lines
180                .get(ahead)
181                .is_some_and(|line| line.trim_start().starts_with('|'));
182            if !continues || !takes_a_chain(&statement) {
183                break;
184            }
185            statement.comments |= lines[next..ahead]
186                .iter()
187                .any(|line| !line.trim().is_empty());
188            let line = lines[ahead];
189            let (cut, still) = scan(line, false);
190            statement.comments |= cut < line.len();
191            let code = &line[..cut];
192            let lead = code.len() - code.trim_start().len();
193            statement.push(code.trim(), ahead, lead);
194            quoted = still;
195            next = ahead + 1;
196        }
197        out.push(statement);
198        index = next;
199    }
200    out
201}
202
203fn verbatim(line: &str, index: usize) -> Statement {
204    let mut statement = Statement::starting(index);
205    statement.comments = line.trim_start().starts_with("--");
206    statement.pieces.push(Piece {
207        at: 0,
208        line: index,
209        column: 0,
210        len: line.len(),
211    });
212    statement.text = line.to_string();
213    statement
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn texts(source: &str) -> Vec<String> {
221        statements(source).into_iter().map(|s| s.text).collect()
222    }
223
224    #[test]
225    fn a_line_is_a_statement() {
226        assert_eq!(
227            texts("bpm 92\n  k kick \"x ~\" | gain 0.9\n"),
228            ["bpm 92", "  k kick \"x ~\" | gain 0.9"]
229        );
230    }
231
232    #[test]
233    fn notation_may_break_inside_its_quotes() {
234        let source = "  pad pad_dark \"<\n    [0,2,4]   -- Em\n\n    [5,7,9]\n  >\" @ 3..\n";
235        let all = statements(source);
236        assert_eq!(all.len(), 1);
237        assert_eq!(all[0].text, "  pad pad_dark \"< [0,2,4] [5,7,9] >\" @ 3..");
238        assert_eq!((all[0].first_line, all[0].last_line), (0, 4));
239        assert!(all[0].comments);
240        let at = all[0].text.find("[5").unwrap();
241        assert_eq!(all[0].locate(at), (3, 4));
242        assert_eq!(all[0].offset_of(3, 4), Some(at));
243    }
244
245    #[test]
246    fn a_chain_may_continue_on_pipe_lines() {
247        let source = "  bass bass \"0 2\"\n    | oct -2\n    -- the filter opens\n    | lpf 600..1800 | ramp 4\nbpm 90\n";
248        assert_eq!(
249            texts(source),
250            [
251                "  bass bass \"0 2\" | oct -2 | lpf 600..1800 | ramp 4",
252                "bpm 90"
253            ]
254        );
255        let group = "  } | reverb 0.1\n    | gain 0.9\n";
256        assert_eq!(texts(group), ["  } | reverb 0.1 | gain 0.9"]);
257    }
258
259    #[test]
260    fn a_pipe_line_does_not_join_a_directive() {
261        assert_eq!(texts("bpm 90\n| gain 2\n"), ["bpm 90", "| gain 2"]);
262    }
263
264    #[test]
265    fn trailing_comments_are_dropped_and_comment_lines_kept() {
266        let all = statements("-- intro\n  k kick \"x ~\" -- hits\n");
267        assert_eq!(all[0].text, "-- intro");
268        assert_eq!(all[1].text, "  k kick \"x ~\"");
269        assert!(all[1].comments);
270    }
271
272    #[test]
273    fn def_blocks_are_left_as_written() {
274        let source = "def thump {\n  tone sine gain 0.5\n}\n  k thump \"x\"\n";
275        assert_eq!(
276            texts(source),
277            [
278                "def thump {",
279                "  tone sine gain 0.5",
280                "}",
281                "  k thump \"x\""
282            ]
283        );
284    }
285}