Skip to main content

devalang_wasm/language/syntax/parser/driver/
mod.rs

1pub mod directive;
2pub mod duration;
3pub mod effects;
4pub mod helpers;
5pub mod preprocessing;
6pub mod routing;
7pub mod statements;
8pub mod trigger;
9// Re-export statement-level parsers so they are available at driver root
10use crate::language::syntax::ast::nodes::{Statement, StatementKind, Value};
11use anyhow::{Result, anyhow};
12pub use statements::*;
13use std::path::{Path, PathBuf};
14
15/// Find the closest keyword suggestion using Levenshtein distance
16/// Returns the suggestion if distance is <= 2 (typo-like)
17fn find_keyword_suggestion(input: &str, keywords: &[&str]) -> Option<String> {
18    // Calculate Levenshtein distance between two strings
19    fn levenshtein(s1: &str, s2: &str) -> usize {
20        let len1 = s1.len();
21        let len2 = s2.len();
22        let mut matrix = vec![vec![0; len2 + 1]; len1 + 1];
23
24        for i in 0..=len1 {
25            matrix[i][0] = i;
26        }
27        for j in 0..=len2 {
28            matrix[0][j] = j;
29        }
30
31        for (i, c1) in s1.chars().enumerate() {
32            for (j, c2) in s2.chars().enumerate() {
33                let cost = if c1 == c2 { 0 } else { 1 };
34                matrix[i + 1][j + 1] = std::cmp::min(
35                    std::cmp::min(
36                        matrix[i][j + 1] + 1, // deletion
37                        matrix[i + 1][j] + 1, // insertion
38                    ),
39                    matrix[i][j] + cost, // substitution
40                );
41            }
42        }
43        matrix[len1][len2]
44    }
45
46    let mut best = (usize::MAX, "");
47    for &keyword in keywords {
48        let distance = levenshtein(input, keyword);
49        if distance < best.0 && distance <= 2 {
50            best = (distance, keyword);
51        }
52    }
53
54    if best.0 <= 2 {
55        Some(best.1.to_string())
56    } else {
57        None
58    }
59}
60
61/// Entry point: parse source into a list of Statements
62pub fn parse(source: &str, path: PathBuf) -> Result<Vec<Statement>> {
63    // Pre-process: merge ALL multiline statements with braces
64    let braces_pre = preprocessing::preprocess_multiline_braces(source);
65
66    // Then merge multiline brackets (for timeline, etc.)
67    let brackets_pre = preprocessing::preprocess_multiline_brackets(&braces_pre);
68
69    // Then merge multiline arrow calls (without braces)
70    let preprocessed = preprocessing::preprocess_multiline_arrow_calls(&brackets_pre);
71
72    let lines: Vec<_> = preprocessed.lines().collect();
73    parse_lines(&lines, 0, lines.len(), 0, &path)
74}
75
76/// Parse a range of lines into statements, handling indentation for blocks.
77fn parse_lines(
78    lines: &Vec<&str>,
79    start: usize,
80    end: usize,
81    indent: usize,
82    path: &Path,
83) -> Result<Vec<Statement>> {
84    use crate::language::syntax::ast::nodes::StatementKind;
85
86    let mut i = start;
87    let mut statements: Vec<Statement> = Vec::new();
88
89    while i < end {
90        let raw = lines[i];
91        let trimmed = raw.trim();
92
93        if trimmed.is_empty() || is_comment_line(trimmed) {
94            i += 1;
95            continue;
96        }
97
98        let current_indent = raw.len() - raw.trim_start().len();
99        if current_indent < indent {
100            break;
101        }
102
103        // parse header line
104        let mut statement = parse_line(trimmed, i + 1, path)?;
105        statement.indent = current_indent;
106        statement.line = i + 1;
107
108        // determine body range for block statements
109        let body_start = i + 1;
110        let mut body_end = body_start;
111        while body_end < end {
112            let l = lines[body_end];
113            if l.trim().is_empty() || is_comment_line(l.trim()) {
114                body_end += 1;
115                continue;
116            }
117            let indent_l = l.len() - l.trim_start().len();
118            if indent_l <= current_indent {
119                break;
120            }
121            body_end += 1;
122        }
123
124        // If we found a body, parse it and attach appropriately based on kind
125        if body_end > body_start {
126            let body = parse_lines(lines, body_start, body_end, current_indent + 1, path)?;
127
128            // To avoid borrowing `statement.kind` and then assigning to it
129            // (which the borrow checker forbids), take ownership of the kind
130            // temporarily and then replace it with the updated version.
131            let orig_kind = std::mem::replace(&mut statement.kind, StatementKind::Unknown);
132            match orig_kind {
133                StatementKind::If { condition, .. } => {
134                    // Keep If simple here; attach else/else-if later in post-processing
135                    statement.kind = StatementKind::If {
136                        condition,
137                        body: body.clone(),
138                        else_body: None,
139                    };
140                }
141                StatementKind::While { condition, .. } => {
142                    // Attach body to While
143                    statement.kind = StatementKind::While {
144                        condition,
145                        body: body.clone(),
146                    };
147                }
148                StatementKind::For {
149                    variable, iterable, ..
150                } => {
151                    statement.kind = StatementKind::For {
152                        variable,
153                        iterable,
154                        body: body.clone(),
155                    };
156                }
157                StatementKind::Loop { count, .. } => {
158                    statement.kind = StatementKind::Loop {
159                        count,
160                        body: body.clone(),
161                    };
162                }
163                StatementKind::On { event, args, .. } => {
164                    statement.kind = StatementKind::On {
165                        event,
166                        args,
167                        body: body.clone(),
168                    };
169                }
170                StatementKind::Automate { target } => {
171                    statement.kind = StatementKind::Automate { target };
172                    // store raw body as value map if needed elsewhere
173                    let raw_lines: Vec<String> = lines[body_start..body_end]
174                        .iter()
175                        .map(|s| s.to_string())
176                        .collect();
177                    let raw_body = raw_lines.join("\n");
178                    let mut map = std::collections::HashMap::new();
179                    map.insert("body".to_string(), Value::String(raw_body));
180                    statement.value = Value::Map(map);
181                }
182                StatementKind::Function {
183                    name, parameters, ..
184                } => {
185                    statement.kind = StatementKind::Function {
186                        name: name.clone(),
187                        parameters,
188                        body: body.clone(),
189                    };
190                    statement.value = Value::Identifier(name.clone());
191                }
192                StatementKind::Group { duration, .. } => {
193                    // attach body for groups, keep name in value if present
194                    let group_name = match &statement.value {
195                        Value::Identifier(s) => s.clone(),
196                        _ => "".to_string(),
197                    };
198                    statement.kind = StatementKind::Group {
199                        name: group_name,
200                        body: body.clone(),
201                        duration,
202                    };
203                }
204                StatementKind::Routing { .. } => {
205                    // Parse routing body statements
206                    statement.kind = StatementKind::Routing { body: body.clone() };
207                }
208                StatementKind::Section { name, duration, .. } => {
209                    statement.kind = StatementKind::Section {
210                        name,
211                        duration,
212                        body: body.clone(),
213                    };
214                }
215                StatementKind::Schedule { .. } => {
216                    // Parse schedule events: each "at <time>:" line with following body
217                    let mut events = Vec::new();
218                    let mut i_local = body_start;
219                    while i_local < body_end {
220                        let line = lines[i_local].trim();
221
222                        // Skip empty lines and comments
223                        if line.is_empty() || is_comment_line(line) {
224                            i_local += 1;
225                            continue;
226                        }
227
228                        // Check if this is an "at" event header
229                        if line.starts_with("at ") {
230                            if let Ok((time, optional_duration)) =
231                                statements::extended::parse_schedule_event_header(lines[i_local])
232                            {
233                                // Find the body for this event
234                                let event_body_start = i_local + 1;
235                                let mut event_body_end = event_body_start;
236
237                                while event_body_end < body_end {
238                                    let l = lines[event_body_end];
239                                    if l.trim().is_empty() || is_comment_line(l.trim()) {
240                                        event_body_end += 1;
241                                        continue;
242                                    }
243
244                                    let indent_l = l.len() - l.trim_start().len();
245                                    let event_indent =
246                                        lines[i_local].len() - lines[i_local].trim_start().len();
247
248                                    // Check if it's another "at" line at same level
249                                    if l.trim().starts_with("at ") && indent_l == event_indent {
250                                        break;
251                                    }
252
253                                    if indent_l <= event_indent {
254                                        break;
255                                    }
256
257                                    event_body_end += 1;
258                                }
259
260                                // Parse the event body
261                                if event_body_end > event_body_start {
262                                    if let Ok(event_stmts) = parse_lines(
263                                        lines,
264                                        event_body_start,
265                                        event_body_end,
266                                        current_indent + 1,
267                                        path,
268                                    ) {
269                                        events.push((time, optional_duration, event_stmts));
270                                    }
271                                }
272
273                                i_local = event_body_end;
274                            } else {
275                                i_local += 1;
276                            }
277                        } else {
278                            i_local += 1;
279                        }
280                    }
281
282                    statement.kind = StatementKind::Schedule { events };
283                }
284                StatementKind::Tempo {
285                    value,
286                    body: Some(_),
287                } => {
288                    // Attach body to tempo block
289                    statement.kind = StatementKind::Tempo {
290                        value,
291                        body: Some(body.clone()),
292                    };
293                }
294
295                other => {
296                    // keep original kind for other statements
297                    statement.kind = other;
298                }
299            }
300
301            // If this statement was a plain 'else' marker (Comment with value "else"),
302            // the parsed body was not attached to the marker (we intentionally
303            // keep the marker lightweight). We must append the parsed body
304            // statements into the parent statements vector so the post-process
305            // pass can collect them and attach to the previous If.
306            i = body_end;
307            statements.push(statement);
308
309            if let Some(last_statement) = statements.last() {
310                if let StatementKind::Comment = &last_statement.kind {
311                    if let Value::String(s) = &last_statement.value {
312                        if s == "else" {
313                            // append the body statements after the marker
314                            for stmt in body.into_iter() {
315                                statements.push(stmt);
316                            }
317                        }
318                    }
319                }
320            }
321            continue;
322        }
323
324        // No body found
325        statements.push(statement);
326        i += 1;
327    }
328
329    // Post-process to attach else/else-if blocks
330    fn attach_else_blocks(statements: &mut Vec<Statement>) {
331        use crate::language::syntax::ast::StatementKind;
332
333        // Helper: attach a new else-body (Vec<Statement>) to an existing If Statement.
334        // If the If already has a nested else-if chain (encoded as a single If in
335        // its else_body vector), descend into that chain and attach to the deepest
336        // nested If instead of overwriting the entire else_body.
337        fn attach_else_to_if_statement(target: Statement, new_else: Vec<Statement>) -> Statement {
338            use crate::language::syntax::ast::StatementKind;
339
340            match target.kind {
341                StatementKind::If {
342                    condition,
343                    body,
344                    else_body,
345                } => {
346                    match else_body {
347                        None => Statement::new(
348                            StatementKind::If {
349                                condition,
350                                body,
351                                else_body: Some(new_else),
352                            },
353                            target.value,
354                            target.indent,
355                            target.line,
356                            target.column,
357                        ),
358                        Some(mut eb) => {
359                            if eb.len() == 1 {
360                                let inner = eb.remove(0);
361                                // If inner is an If, recurse into it
362                                if let StatementKind::If { .. } = inner.kind {
363                                    let updated_inner =
364                                        attach_else_to_if_statement(inner, new_else);
365                                    Statement::new(
366                                        StatementKind::If {
367                                            condition,
368                                            body,
369                                            else_body: Some(vec![updated_inner]),
370                                        },
371                                        target.value,
372                                        target.indent,
373                                        target.line,
374                                        target.column,
375                                    )
376                                } else {
377                                    // Not an If inside else_body; overwrite
378                                    Statement::new(
379                                        StatementKind::If {
380                                            condition,
381                                            body,
382                                            else_body: Some(new_else),
383                                        },
384                                        target.value,
385                                        target.indent,
386                                        target.line,
387                                        target.column,
388                                    )
389                                }
390                            } else {
391                                // Multiple statements in existing else_body; overwrite for now
392                                Statement::new(
393                                    StatementKind::If {
394                                        condition,
395                                        body,
396                                        else_body: Some(new_else),
397                                    },
398                                    target.value,
399                                    target.indent,
400                                    target.line,
401                                    target.column,
402                                )
403                            }
404                        }
405                    }
406                }
407                _ => target,
408            }
409        }
410
411        let mut idx = 0;
412        while idx < statements.len() {
413            // Handle plain 'else' marker: Comment with value "else"
414            if let StatementKind::Comment = &statements[idx].kind {
415                if let Value::String(s) = &statements[idx].value {
416                    if s == "else" {
417                        let else_indent = statements[idx].indent;
418
419                        // Collect following statements that are part of the else body
420                        let mut body: Vec<Statement> = Vec::new();
421                        let j = idx + 1;
422                        while j < statements.len() && statements[j].indent > else_indent {
423                            body.push(statements.remove(j));
424                        }
425
426                        // Remove the 'else' marker itself
427                        statements.remove(idx);
428
429                        // Find previous If to attach to (search backwards) and attach
430                        // the parsed else body to the deepest nested If in the chain.
431                        let mut k = idx as isize - 1;
432                        while k >= 0 {
433                            if let StatementKind::If { .. } = &statements[k as usize].kind {
434                                // Take ownership of the previous If statement, attach into it,
435                                // then put back the updated statement.
436                                let prev = std::mem::replace(
437                                    &mut statements[k as usize],
438                                    Statement::new(StatementKind::Unknown, Value::Null, 0, 0, 1),
439                                );
440                                let updated = attach_else_to_if_statement(prev, body);
441                                statements[k as usize] = updated;
442                                break;
443                            }
444                            k -= 1;
445                        }
446
447                        continue;
448                    }
449                }
450            }
451
452            // Handle 'else if' which was parsed as an If with value == "else-if"
453            if let StatementKind::If { .. } = &statements[idx].kind {
454                if let Value::String(s) = &statements[idx].value {
455                    if s == "else-if" {
456                        // Remove this else-if statement and attach it as the
457                        // else_body (single-statement vector) of the previous If.
458                        let else_if_stmt = statements.remove(idx);
459
460                        // find previous If and attach this else-if into its nested chain
461                        let mut k = idx as isize - 1;
462                        while k >= 0 {
463                            if let StatementKind::If { .. } = &statements[k as usize].kind {
464                                let prev = std::mem::replace(
465                                    &mut statements[k as usize],
466                                    Statement::new(StatementKind::Unknown, Value::Null, 0, 0, 1),
467                                );
468                                let updated = attach_else_to_if_statement(prev, vec![else_if_stmt]);
469                                statements[k as usize] = updated;
470                                break;
471                            }
472                            k -= 1;
473                        }
474
475                        continue;
476                    }
477                }
478            }
479
480            idx += 1;
481        }
482    }
483
484    attach_else_blocks(&mut statements);
485
486    Ok(statements)
487}
488
489fn parse_line(line: &str, line_number: usize, path: &Path) -> Result<Statement> {
490    use crate::language::syntax::parser::driver::statements::*;
491
492    if line.starts_with('.') {
493        return trigger::parse_trigger_line(line, line_number);
494    }
495
496    let mut parts = line.split_whitespace();
497    // Extract first token as keyword and strip a trailing ':' if present
498    let first_token = parts
499        .next()
500        .ok_or_else(|| anyhow!("empty line"))?
501        .to_string();
502    let keyword = first_token.trim_end_matches(':').to_lowercase();
503
504    // Check for routing statements (node, fx, route, duck, sidechain)
505    let routing_keywords = ["node", "fx", "route", "duck", "sidechain"];
506    if routing_keywords.contains(&keyword.as_str()) {
507        return crate::language::syntax::parser::driver::routing::parse_routing_statement(
508            line,
509            line_number,
510        );
511    }
512
513    if let Some(statement) = statements::structure::parse_pattern_shorthand(line, line_number)? {
514        return Ok(statement);
515    }
516
517    // Check if this is a property assignment first: target.property = value
518    if line.contains('=') && keyword.contains('.') {
519        return parse_assign(line, line_number);
520    }
521
522    // Check if this looks like a trigger (contains a dot like "drums.kick")
523    if keyword.contains('.') && !keyword.contains('(') {
524        // Reconstruct the line and parse as trigger
525        return trigger::parse_trigger_line(line, line_number);
526    }
527
528    // Check if this is a bind statement first (before arrow call)
529    if keyword == "bind" && line.contains("->") {
530        return parse_bind(line, line_number);
531    }
532
533    // If this line contains an arrow call, parse it as an ArrowCall, UNLESS the
534    // statement starts with a reserved keyword that must be handled (let/var/const/etc.).
535    // This ensures constructs like `let name = .bank.kick -> reverse(...)` are
536    // parsed by `parse_let` rather than being mis-parsed as an ArrowCall.
537    let reserved_keywords = [
538        "bpm", "tempo", "print", "sleep", "rest", "wait", "pattern", "bank", "let", "const", "for",
539        "foreach", "loop", "if", "while", "else", "group", "automate", "call", "spawn", "sequence",
540        "layer", "on", "emit", "routing", "return", "break", "import", "export", "use", "load",
541        "fade", "section", "timeline", "schedule", "stop", "silence", "mute",
542    ];
543    if line.contains("->") && !reserved_keywords.contains(&keyword.as_str()) {
544        return statements::parse_arrow_call(line, line_number);
545    }
546
547    return match keyword.as_str() {
548        "bpm" | "tempo" => statements::core::parse_tempo(line, line_number),
549        "print" => statements::core::parse_print(line, line_number),
550        "sleep" | "rest" | "wait" => statements::core::parse_sleep(parts, line_number),
551        "trigger" => Err(anyhow!(
552            "keyword 'trigger' is deprecated; use dot notation like '.alias' instead"
553        )),
554        "pattern" => parse_pattern(parts, line_number),
555        "bank" => parse_bank(parts, line_number),
556        "let" => parse_let(line, parts, line_number),
557        "const" => parse_const(line, parts, line_number),
558        "for" | "foreach" => parse_for(parts, line_number),
559        "loop" => parse_loop(parts, line_number),
560        "if" => statements::structure::parse_if(parts, line_number),
561        "while" => statements::structure::parse_while(parts, line_number),
562        "else" => statements::structure::parse_else(line, line_number),
563        "group" => statements::structure::parse_group(parts, line_number),
564        "automate" => {
565            crate::language::syntax::parser::driver::statements::structure::parse_automate(
566                parts,
567                line_number,
568            )
569        }
570        "call" | "sequence" => parse_call(line, parts, line_number),
571        "break" => statements::structure::parse_break(parts, line_number),
572        "function" => statements::structure::parse_function(line, line_number),
573        "spawn" | "layer" => parse_spawn(parts, line_number),
574        "on" => parse_on(parts, line_number),
575        "emit" => parse_emit(line, parts, line_number),
576        "fade" => statements::extended::parse_fade(line, line_number),
577        "section" => statements::extended::parse_section(parts, line_number),
578        "timeline" => statements::extended::parse_timeline(line, line_number),
579        "schedule" => statements::extended::parse_schedule(line, line_number),
580        "stop" | "silence" | "mute" => statements::extended::parse_stop(line, line_number),
581        "return" => statements::core::parse_return(line, line_number),
582        "routing" => {
583            crate::language::syntax::parser::driver::routing::parse_routing_command(line_number)
584        }
585        "import" => directive::parse_directive_keyword(line, "import", line_number, path),
586        "export" => directive::parse_directive_keyword(line, "export", line_number, path),
587        "use" => directive::parse_directive_keyword(line, "use", line_number, path),
588        "load" => directive::parse_directive_keyword(line, "load", line_number, path),
589        _ => {
590            // Provide helpful suggestions for common typos FIRST
591            let suggestion = find_keyword_suggestion(&keyword, &reserved_keywords);
592
593            // If we found a suggestion, it's likely a typo, not a trigger call
594            if let Some(suggestion_str) = suggestion {
595                // Store message in a structured format: "MESSAGE|||FILE:LINE|||SUGGESTION"
596                // This allows the collector to parse it and build a StructuredError
597                let error_msg = format!(
598                    "Unknown statement '{}' at {}:{}|||{}|||{}",
599                    keyword,
600                    path.display(),
601                    line_number,
602                    path.display(),
603                    suggestion_str
604                );
605
606                // Push structured error to WASM registry if available
607                #[cfg(feature = "wasm")]
608                {
609                    use crate::web::registry::debug;
610                    if debug::is_debug_errors_enabled() {
611                        debug::push_parse_error_from_parts(
612                            format!(
613                                "Unknown statement '{}'. Did you mean '{}' ?",
614                                keyword, suggestion_str
615                            ),
616                            line_number,
617                            1,
618                            "UnknownStatement".to_string(),
619                        );
620                    }
621                }
622
623                return Ok(Statement::new(
624                    StatementKind::Unknown,
625                    Value::String(error_msg),
626                    0,
627                    line_number,
628                    1,
629                ));
630            }
631
632            // Check if this looks like a potential trigger identifier (single word, no special chars except dots)
633            // This handles cases like variable references: let kick = drums.kick; kick
634            if keyword
635                .chars()
636                .all(|c| c.is_alphanumeric() || c == '_' || c == '.')
637            {
638                // Parse as trigger
639                return trigger::parse_trigger_line(line, line_number);
640            }
641
642            // Generic error for truly unknown statements
643            // Format: "MESSAGE|||FILE:LINE|||NO_SUGGESTION"
644            let error_msg = format!(
645                "Unknown statement '{}' at {}:{}|||{}|||",
646                keyword,
647                path.display(),
648                line_number,
649                path.display()
650            );
651
652            // Push structured error to WASM registry if available
653            #[cfg(feature = "wasm")]
654            {
655                use crate::web::registry::debug;
656                if debug::is_debug_errors_enabled() {
657                    debug::push_parse_error_from_parts(
658                        format!("Unknown statement '{}'", keyword),
659                        line_number,
660                        1,
661                        "UnknownStatement".to_string(),
662                    );
663                }
664            }
665
666            return Ok(Statement::new(
667                StatementKind::Unknown,
668                Value::String(error_msg),
669                0,
670                line_number,
671                1,
672            ));
673        }
674    };
675}
676
677fn is_comment_line(line: &str) -> bool {
678    line.starts_with('#') || line.starts_with("//")
679}
680
681pub use duration::parse_duration_token;
682/// Re-export helper parsing functions from helpers.rs so other modules can call them
683pub use helpers::{
684    parse_array_value, parse_condition, parse_function_args, parse_map_value, parse_single_arg,
685    parse_synth_definition,
686};
687
688/// SimpleParser is a small wrapper used by other modules/tests in the crate.
689/// It forwards to the top-level parse implementation above.
690pub struct SimpleParser;
691
692impl SimpleParser {
693    pub fn parse(source: &str, path: PathBuf) -> Result<Vec<Statement>> {
694        crate::language::syntax::parser::driver::parse(source, path)
695    }
696
697    pub fn parse_file<P: AsRef<Path>>(path: P) -> Result<Vec<Statement>> {
698        let buf = std::path::PathBuf::from(path.as_ref());
699        let s = std::fs::read_to_string(&buf)?;
700        let s = s.strip_prefix('\u{feff}').unwrap_or(&s);
701        Self::parse(&s, buf)
702    }
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    #[test]
710    fn test_unknown_statement_with_suggestion_is_structured() {
711        let statements = parse("pritn hello", PathBuf::from("test.deva")).unwrap();
712
713        assert_eq!(statements.len(), 1);
714        assert!(matches!(statements[0].kind, StatementKind::Unknown));
715        match &statements[0].value {
716            Value::String(message) => {
717                assert!(message.contains("Unknown statement 'pritn'"));
718                assert!(message.ends_with("|||print"));
719            }
720            other => panic!("expected structured unknown message, got {:?}", other),
721        }
722    }
723
724    #[test]
725    fn test_else_body_is_attached_to_previous_if() {
726        let source = "if true:\n  print yes\nelse:\n  print no";
727        let statements = parse(source, PathBuf::from("test.deva")).unwrap();
728
729        assert_eq!(statements.len(), 1);
730        match &statements[0].kind {
731            StatementKind::If {
732                body, else_body, ..
733            } => {
734                assert_eq!(body.len(), 1);
735                let else_body = else_body.as_ref().expect("else body should be attached");
736                assert_eq!(else_body.len(), 1);
737                assert!(matches!(else_body[0].kind, StatementKind::Print));
738            }
739            other => panic!("expected if statement, got {:?}", other),
740        }
741    }
742
743    #[test]
744    fn test_bare_synth_chained_waveform_and_preset() {
745        let source = r#"let bass = synth -> waveform("saw") -> preset("bass")"#;
746        let statements = parse(source, PathBuf::from("test.deva")).unwrap();
747
748        assert_eq!(statements.len(), 1);
749        match &statements[0].kind {
750            StatementKind::Let {
751                value: Some(Value::Map(map)),
752                ..
753            } => {
754                assert!(matches!(
755                    map.get("type"),
756                    Some(Value::String(kind)) if kind == "synth"
757                ));
758                assert!(matches!(
759                    map.get("waveform"),
760                    Some(Value::String(waveform)) if waveform == "saw"
761                ));
762                assert!(matches!(
763                    map.get("synth_type"),
764                    Some(Value::String(preset)) if preset == "bass"
765                ));
766            }
767            other => panic!("expected synth let statement, got {:?}", other),
768        }
769    }
770
771    #[test]
772    fn test_livecoding_synth_aliases_parse_to_canonical_map() {
773        let source = r#"let bass = synth -> wave("saw") -> preset("bass") -> voices(2) -> env({ attack: 0.01, release: 0.08 })"#;
774        let statements = parse(source, PathBuf::from("test.deva")).unwrap();
775
776        assert_eq!(statements.len(), 1);
777        match &statements[0].kind {
778            StatementKind::Let {
779                value: Some(Value::Map(map)),
780                ..
781            } => {
782                assert!(matches!(
783                    map.get("waveform"),
784                    Some(Value::String(waveform)) if waveform == "saw"
785                ));
786                assert!(matches!(
787                    map.get("synth_type"),
788                    Some(Value::String(preset)) if preset == "bass"
789                ));
790                assert!(
791                    matches!(map.get("attack"), Some(Value::Number(n)) if (*n - 0.01).abs() < f32::EPSILON)
792                );
793                assert!(
794                    matches!(map.get("release"), Some(Value::Number(n)) if (*n - 0.08).abs() < f32::EPSILON)
795                );
796                assert!(
797                    matches!(map.get("voices"), Some(Value::Number(n)) if (*n - 2.0).abs() < f32::EPSILON)
798                );
799            }
800            other => panic!("expected synth let statement, got {:?}", other),
801        }
802    }
803
804    #[test]
805    fn test_pattern_shorthand_without_options() {
806        let source = r#"kick: drums.kick = "x--- x---""#;
807        let statements = parse(source, PathBuf::from("test.deva")).unwrap();
808
809        assert_eq!(statements.len(), 1);
810        match &statements[0].kind {
811            StatementKind::Pattern { name, target } => {
812                assert_eq!(name, "kick");
813                assert_eq!(target.as_deref(), Some("drums.kick"));
814                assert!(
815                    matches!(&statements[0].value, Value::String(pattern) if pattern == "x--- x---")
816                );
817            }
818            other => panic!("expected pattern shorthand, got {:?}", other),
819        }
820    }
821
822    #[test]
823    fn test_pattern_shorthand_with_arrow_options() {
824        let source = r#"hat: drums.hat -> swing(0.08) -> velocity(0.7) = "x-x-""#;
825        let statements = parse(source, PathBuf::from("test.deva")).unwrap();
826
827        assert_eq!(statements.len(), 1);
828        match &statements[0].kind {
829            StatementKind::Pattern { name, target } => {
830                assert_eq!(name, "hat");
831                assert_eq!(target.as_deref(), Some("drums.hat"));
832            }
833            other => panic!("expected pattern shorthand, got {:?}", other),
834        }
835
836        match &statements[0].value {
837            Value::Map(map) => {
838                assert!(
839                    matches!(map.get("pattern"), Some(Value::String(pattern)) if pattern == "x-x-")
840                );
841                assert!(
842                    matches!(map.get("swing"), Some(Value::Number(n)) if (*n - 0.08).abs() < f32::EPSILON)
843                );
844                assert!(
845                    matches!(map.get("velocity"), Some(Value::Number(n)) if (*n - 0.7).abs() < f32::EPSILON)
846                );
847            }
848            other => panic!("expected pattern options map, got {:?}", other),
849        }
850    }
851}