Skip to main content

treble_lang/parser/
mod.rs

1//! Parser for Treble Live DSL.
2//!
3//! The parser is line-oriented: each source line is parsed independently.
4//! `def` blocks are the sole exception — [`parse_program`] gathers a block's
5//! lines and parses them as a unit. Mini-notation (inside double quotes) is
6//! parsed with nom combinators.
7
8mod instrument;
9mod lines;
10mod mini;
11mod statements;
12
13pub use instrument::parse_instrument_def;
14pub use lines::parse_line;
15pub use mini::parse_mini;
16pub use statements::{Statement, statements};
17
18use instrument::count_braces;
19
20use crate::ast::{Program, SourceLine};
21use crate::error::{CompileError, CompileErrorKind, SourceLocation};
22
23/// Parse an entire source string into a [`Program`].
24///
25/// Each line is parsed independently. Lines that fail to parse produce a
26/// [`CompileError`] in the error list but do not prevent other lines from
27/// being parsed (best-effort / error-recovery).
28pub fn parse_program(source: &str) -> (Program, Vec<CompileError>) {
29    let mut lines = Vec::new();
30    let mut errors = Vec::new();
31    // A `def` block spans lines, so it is collected before being parsed.
32    let mut block: Option<(usize, Vec<&str>)> = None;
33    let mut depth = 0usize;
34    // The group currently open, if any. Group members stay ordinary lines;
35    // only their `group` tag records the membership.
36    let mut open_group: Option<(usize, String)> = None;
37    // The section currently open, if any. A group nests one level inside a
38    // section, so both can be open at once and a `}` closes the inner one.
39    let mut open_section: Option<(usize, String)> = None;
40
41    let report = |errors: &mut Vec<CompileError>, line: usize, message: String| {
42        errors.push(CompileError {
43            kind: CompileErrorKind::ParseError,
44            location: SourceLocation {
45                line,
46                column: 1,
47                file: None,
48            },
49            message,
50            suggestion: None,
51        });
52    };
53
54    // Statements, not physical lines: a pattern broken over several lines is
55    // read as one, and reported against the line it starts on (§4.8).
56    let statements = statements(source);
57    for statement in &statements {
58        let line_idx = statement.first_line;
59        let raw = statement.text.as_str();
60        if let Some((_, collected)) = &mut block {
61            depth += count_braces(raw, '{');
62            depth -= count_braces(raw, '}').min(depth);
63            collected.push(raw);
64            if depth == 0 {
65                let (start, collected) = block.take().expect("inside a block");
66                match parse_instrument_def(&collected) {
67                    Ok(definition) => lines.push(SourceLine::Def(Box::new(definition))),
68                    Err(message) => {
69                        report(&mut errors, start + 1, message);
70                        // Preserve the block's text so nothing is silently lost.
71                        for line in collected {
72                            lines.push(SourceLine::Comment(line.to_string()));
73                        }
74                    }
75                }
76            }
77            continue;
78        }
79
80        if raw.trim_start().starts_with("def ") || raw.trim() == "def" {
81            if let Some((_, group)) = &open_group {
82                report(
83                    &mut errors,
84                    line_idx + 1,
85                    format!("def: an instrument definition cannot live inside group '{group}'"),
86                );
87            } else if let Some((_, section)) = &open_section {
88                report(
89                    &mut errors,
90                    line_idx + 1,
91                    format!(
92                        "def: an instrument definition cannot live inside section '{section}' \
93                         — move it above the section"
94                    ),
95                );
96            }
97            depth = count_braces(raw, '{');
98            depth -= count_braces(raw, '}').min(depth);
99            if depth == 0 {
100                // A block opened and closed on one line.
101                match parse_instrument_def(&[raw]) {
102                    Ok(definition) => lines.push(SourceLine::Def(Box::new(definition))),
103                    Err(message) => {
104                        report(&mut errors, line_idx + 1, message);
105                        lines.push(SourceLine::Comment(raw.to_string()));
106                    }
107                }
108            } else {
109                block = Some((line_idx, vec![raw]));
110            }
111            continue;
112        }
113
114        match parse_line(raw) {
115            Ok(source_line) => {
116                match source_line {
117                    SourceLine::GroupStart { muted, name } => {
118                        if let Some((_, outer)) = &open_group {
119                            report(
120                                &mut errors,
121                                line_idx + 1,
122                                format!("group: groups don't nest; '{outer}' is still open"),
123                            );
124                        }
125                        open_group = Some((line_idx, name.clone()));
126                        lines.push(SourceLine::GroupStart { muted, name });
127                    }
128                    // A `}` closes the innermost open block: the group if one
129                    // is open, otherwise the section.
130                    SourceLine::GroupEnd(transforms) => {
131                        if open_group.take().is_some() {
132                            lines.push(SourceLine::GroupEnd(transforms));
133                        } else if open_section.take().is_some() {
134                            if !transforms.is_empty() {
135                                report(
136                                    &mut errors,
137                                    line_idx + 1,
138                                    "section: '}' takes no filters — a section is a span of \
139                                     time, not a bus; put them on a group inside it"
140                                        .to_string(),
141                                );
142                            }
143                            lines.push(SourceLine::SectionEnd);
144                        } else {
145                            report(
146                                &mut errors,
147                                line_idx + 1,
148                                "'}' without an open group or section".to_string(),
149                            );
150                            lines.push(SourceLine::GroupEnd(transforms));
151                        }
152                    }
153                    SourceLine::SectionStart {
154                        muted,
155                        name,
156                        cycles,
157                    } => {
158                        if let Some((_, group)) = &open_group {
159                            report(
160                                &mut errors,
161                                line_idx + 1,
162                                format!(
163                                    "section: '{name}' cannot open inside group '{group}' — a \
164                                     group nests inside a section, not the other way round"
165                                ),
166                            );
167                        } else if let Some((_, outer)) = &open_section {
168                            report(
169                                &mut errors,
170                                line_idx + 1,
171                                format!("section: sections don't nest; '{outer}' is still open"),
172                            );
173                        }
174                        open_section = Some((line_idx, name.clone()));
175                        lines.push(SourceLine::SectionStart {
176                            muted,
177                            name,
178                            cycles,
179                        });
180                    }
181                    SourceLine::SectionEnd => lines.push(SourceLine::SectionEnd),
182                    SourceLine::Pattern(mut def) => {
183                        def.group = open_group.as_ref().map(|(_, name)| name.clone());
184                        def.section = open_section.as_ref().map(|(_, name)| name.clone());
185                        if def.span.is_some() && def.section.is_none() {
186                            report(
187                                &mut errors,
188                                line_idx + 1,
189                                format!(
190                                    "'{}': a span needs a section — its cycles are counted \
191                                     from the section's start (§8.3)",
192                                    def.name
193                                ),
194                            );
195                            def.span = None;
196                        }
197                        lines.push(SourceLine::Pattern(def));
198                    }
199                    // A group holds patterns; a directive inside one would
200                    // silently act on the whole session, so it is refused.
201                    directive @ (SourceLine::Bpm(_)
202                    | SourceLine::Sig(_, _)
203                    | SourceLine::Phrase(_)
204                    | SourceLine::Scale(_, _)
205                    | SourceLine::Load(_)
206                    | SourceLine::Include(_)
207                    | SourceLine::Arrange(_)
208                    | SourceLine::Tail(_)
209                    | SourceLine::Seed(_))
210                        if open_group.is_some() =>
211                    {
212                        report(
213                            &mut errors,
214                            line_idx + 1,
215                            "group: directives cannot live inside a group — move this above it"
216                                .to_string(),
217                        );
218                        lines.push(directive);
219                    }
220                    // A section scopes `bpm`, `sig` and `scale` (§8.5). The
221                    // rest configure the whole buffer, so a section is the
222                    // wrong place to write them and they are refused rather
223                    // than silently applied to everything.
224                    directive @ (SourceLine::Phrase(_)
225                    | SourceLine::Load(_)
226                    | SourceLine::Include(_)
227                    | SourceLine::Arrange(_)
228                    | SourceLine::Tail(_)
229                    | SourceLine::Seed(_))
230                        if open_section.is_some() =>
231                    {
232                        report(
233                            &mut errors,
234                            line_idx + 1,
235                            format!(
236                                "section: '{}' configures the whole piece — move it above the \
237                                 section (a section scopes only bpm, sig and scale)",
238                                directive_keyword(&directive)
239                            ),
240                        );
241                        lines.push(directive);
242                    }
243                    other => lines.push(other),
244                }
245            }
246            Err(msg) => {
247                report(&mut errors, line_idx + 1, msg);
248                // Keep the line as a comment so we don't lose it
249                lines.push(SourceLine::Comment(raw.to_string()));
250            }
251        }
252    }
253
254    if let Some((start, name)) = open_group {
255        report(
256            &mut errors,
257            start + 1,
258            format!("group: '{name}' is never closed with '}}'"),
259        );
260    }
261
262    if let Some((start, name)) = open_section {
263        report(
264            &mut errors,
265            start + 1,
266            format!("section: '{name}' is never closed with '}}'"),
267        );
268    }
269
270    if let Some((start, collected)) = block {
271        report(
272            &mut errors,
273            start + 1,
274            "def: the block is never closed with '}'".to_string(),
275        );
276        for line in collected {
277            lines.push(SourceLine::Comment(line.to_string()));
278        }
279    }
280
281    (Program { lines }, errors)
282}
283
284/// The keyword a directive was written with, for diagnostics.
285fn directive_keyword(line: &SourceLine) -> &'static str {
286    match line {
287        SourceLine::Bpm(_) => "bpm",
288        SourceLine::Sig(_, _) => "sig",
289        SourceLine::Phrase(_) => "phrase",
290        SourceLine::Scale(_, _) => "scale",
291        SourceLine::Load(_) => "load",
292        SourceLine::Include(_) => "include",
293        SourceLine::Arrange(_) => "arrange",
294        SourceLine::Tail(_) => "tail",
295        SourceLine::Seed(_) => "seed",
296        _ => "directive",
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::ast::*;
304
305    #[test]
306    fn test_group_block() {
307        let source = "\
308include kick
309include hihat
310
311group drums {
312  kick kick \"x ~ x ~\"
313  ; hat hihat \"x*8\"
314} | lpf 1800 | gain 0.9
315
316lead bass \"0 3 5 3\"";
317        let (prog, errs) = parse_program(source);
318        assert!(errs.is_empty(), "{errs:?}");
319        let patterns: Vec<&PatternDef> = prog
320            .lines
321            .iter()
322            .filter_map(|line| match line {
323                SourceLine::Pattern(def) => Some(def),
324                _ => None,
325            })
326            .collect();
327        // Members are tagged; the line after the close is not.
328        assert_eq!(patterns[0].group.as_deref(), Some("drums"));
329        assert_eq!(patterns[1].group.as_deref(), Some("drums"));
330        assert!(patterns[1].muted, "member mute survives inside a group");
331        assert_eq!(patterns[2].group, None);
332        // The close carries the shared chain.
333        let end = prog
334            .lines
335            .iter()
336            .find_map(|line| match line {
337                SourceLine::GroupEnd(transforms) => Some(transforms),
338                _ => None,
339            })
340            .unwrap();
341        assert_eq!(end.len(), 2);
342    }
343
344    #[test]
345    fn test_muted_group_and_bare_close() {
346        let (prog, errs) = parse_program("; group drums {\nkick kick \"x\"\n}");
347        assert!(errs.is_empty(), "{errs:?}");
348        assert!(matches!(
349            &prog.lines[0],
350            SourceLine::GroupStart { muted: true, name } if name == "drums"
351        ));
352        assert!(matches!(&prog.lines[2], SourceLine::GroupEnd(t) if t.is_empty()));
353    }
354
355    #[test]
356    fn test_group_structural_errors() {
357        // Unclosed, stray close, nesting, directives and defs inside.
358        assert!(!parse_program("group drums {").1.is_empty());
359        assert!(!parse_program("}").1.is_empty());
360        assert!(
361            !parse_program("group a {\ngroup b {\n}\n}").1.is_empty(),
362            "groups must not nest"
363        );
364        assert!(!parse_program("group a {\nbpm 130\n}").1.is_empty());
365        assert!(
366            !parse_program("group a {\ndef x { tone sine }\n}")
367                .1
368                .is_empty(),
369            "defs must not live inside groups"
370        );
371        // Header must end with the brace; members go on their own lines.
372        assert!(
373            !parse_program("group drums { kick kick \"x\" }")
374                .1
375                .is_empty()
376        );
377    }
378
379    #[test]
380    fn test_parse_empty_program() {
381        let (prog, errs) = parse_program("");
382        assert!(errs.is_empty());
383        // "".lines() yields an empty iterator, so 0 lines
384        assert_eq!(prog.lines.len(), 0);
385    }
386
387    #[test]
388    fn test_parse_full_example() {
389        let source = "\
390-- techno
391bpm 128
392sig 4/4
393
394kick kick \"x ~ x ~\"
395; snare snare \"~ x ~ x\"
396lead saw \"c4 eb4 g4\" | rev | slow 2";
397
398        let (prog, errs) = parse_program(source);
399        assert!(errs.is_empty(), "errors: {:?}", errs);
400        assert_eq!(prog.lines.len(), 7);
401        assert!(matches!(prog.lines[0], SourceLine::Comment(_)));
402        assert!(matches!(prog.lines[1], SourceLine::Bpm(128)));
403        assert!(matches!(prog.lines[2], SourceLine::Sig(4, 4)));
404        assert!(matches!(prog.lines[3], SourceLine::Blank));
405        // kick pattern
406        if let SourceLine::Pattern(ref p) = prog.lines[4] {
407            assert_eq!(p.name, "kick");
408            assert_eq!(p.instrument, "kick");
409            assert!(!p.muted);
410            assert!(p.transforms.is_empty());
411        } else {
412            panic!("expected pattern, got {:?}", prog.lines[4]);
413        }
414        // muted snare
415        if let SourceLine::Pattern(ref p) = prog.lines[5] {
416            assert!(p.muted);
417            assert_eq!(p.name, "snare");
418        } else {
419            panic!("expected muted pattern, got {:?}", prog.lines[5]);
420        }
421        // lead with transforms
422        if let SourceLine::Pattern(ref p) = prog.lines[6] {
423            assert_eq!(p.name, "lead");
424            assert_eq!(p.transforms.len(), 2);
425            assert_eq!(p.transforms[0], Transform::Rev);
426            assert_eq!(p.transforms[1], Transform::Slow(Ramp::fixed(2.0)));
427        } else {
428            panic!("expected pattern, got {:?}", prog.lines[6]);
429        }
430    }
431
432    #[test]
433    fn test_error_recovery() {
434        let source = "\
435bpm 128
436this is garbage ???
437kick kick \"x ~ x ~\"";
438        let (prog, errs) = parse_program(source);
439        assert_eq!(errs.len(), 1);
440        assert_eq!(errs[0].location.line, 2);
441        // The valid lines are still parsed
442        assert!(matches!(prog.lines[0], SourceLine::Bpm(128)));
443        // Error line kept as comment
444        assert!(matches!(prog.lines[1], SourceLine::Comment(_)));
445        // Third line still valid
446        assert!(matches!(prog.lines[2], SourceLine::Pattern(_)));
447    }
448}