Skip to main content

flutmax_objdb/
parser.rs

1use std::path::Path;
2
3use quick_xml::de::from_str;
4use serde::Deserialize;
5
6use crate::{ArgDef, InletSpec, Module, ObjectDb, ObjectDef, OutletSpec, PortDef, PortType};
7
8/// XML parse error
9#[derive(Debug)]
10pub enum ParseError {
11    Xml(quick_xml::DeError),
12    Io(std::io::Error),
13    /// c74object element is missing the name attribute
14    MissingName,
15}
16
17impl std::fmt::Display for ParseError {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        match self {
20            ParseError::Xml(e) => write!(f, "XML parse error: {}", e),
21            ParseError::Io(e) => write!(f, "IO error: {}", e),
22            ParseError::MissingName => write!(f, "Missing 'name' attribute on c74object"),
23        }
24    }
25}
26
27impl std::error::Error for ParseError {}
28
29impl From<quick_xml::DeError> for ParseError {
30    fn from(e: quick_xml::DeError) -> Self {
31        ParseError::Xml(e)
32    }
33}
34
35impl From<std::io::Error> for ParseError {
36    fn from(e: std::io::Error) -> Self {
37        ParseError::Io(e)
38    }
39}
40
41// ---- XML serde structs ----
42
43/// c74object root element
44#[derive(Debug, Deserialize)]
45#[serde(rename = "c74object")]
46struct XmlC74Object {
47    #[serde(rename = "@name")]
48    name: Option<String>,
49    #[serde(rename = "@module")]
50    module: Option<String>,
51    #[serde(rename = "@category")]
52    category: Option<String>,
53    digest: Option<XmlDigest>,
54    inletlist: Option<XmlInletList>,
55    outletlist: Option<XmlOutletList>,
56    objarglist: Option<XmlObjArgList>,
57}
58
59#[derive(Debug, Deserialize)]
60struct XmlInletList {
61    #[serde(rename = "inlet", default)]
62    inlets: Vec<XmlInlet>,
63}
64
65#[derive(Debug, Deserialize)]
66struct XmlInlet {
67    #[serde(rename = "@id")]
68    id: Option<u32>,
69    #[serde(rename = "@type")]
70    inlet_type: Option<String>,
71    digest: Option<XmlDigest>,
72}
73
74#[derive(Debug, Deserialize)]
75struct XmlOutletList {
76    #[serde(rename = "outlet", default)]
77    outlets: Vec<XmlOutlet>,
78}
79
80#[derive(Debug, Deserialize)]
81struct XmlOutlet {
82    #[serde(rename = "@id")]
83    id: Option<u32>,
84    #[serde(rename = "@type")]
85    outlet_type: Option<String>,
86    digest: Option<XmlDigest>,
87}
88
89#[derive(Debug, Deserialize)]
90struct XmlObjArgList {
91    #[serde(rename = "objarg", default)]
92    args: Vec<XmlObjArg>,
93}
94
95#[derive(Debug, Deserialize)]
96struct XmlObjArg {
97    #[serde(rename = "@name")]
98    name: Option<String>,
99    #[serde(rename = "@optional")]
100    optional: Option<String>,
101    #[serde(rename = "@type")]
102    arg_type: Option<String>,
103}
104
105#[derive(Debug, Deserialize)]
106struct XmlDigest {
107    #[serde(rename = "$text")]
108    text: Option<String>,
109}
110
111// ---- Conversion logic ----
112
113/// Heuristic to determine whether inlet/outlet count is variable (depends on argument count)
114fn has_variable_ports(args: &[XmlObjArg], ports: &[XmlInlet]) -> bool {
115    // If a port has INLET_TYPE, it's likely variable
116    let has_dynamic_type = ports
117        .iter()
118        .any(|p| matches!(p.inlet_type.as_deref(), Some("INLET_TYPE")));
119
120    // Check for descriptions like "number of inlets is determined" in objarg
121    // Since description parsing with serde is omitted, only type-based heuristics are used
122    if has_dynamic_type && !args.is_empty() {
123        return true;
124    }
125
126    false
127}
128
129fn has_variable_outlet_ports(args: &[XmlObjArg], ports: &[XmlOutlet]) -> bool {
130    let has_dynamic_type = ports
131        .iter()
132        .any(|p| matches!(p.outlet_type.as_deref(), Some("OUTLET_TYPE")));
133
134    if has_dynamic_type && !args.is_empty() {
135        return true;
136    }
137
138    false
139}
140
141fn convert_inlet(inlet: &XmlInlet, index: usize) -> PortDef {
142    let type_str = inlet.inlet_type.as_deref().unwrap_or("");
143    let digest_text = inlet
144        .digest
145        .as_ref()
146        .and_then(|d| d.text.as_deref())
147        .unwrap_or("")
148        .trim()
149        .to_string();
150
151    PortDef {
152        id: inlet.id.unwrap_or(index as u32),
153        port_type: PortType::from_xml_type(type_str),
154        is_hot: inlet.id.unwrap_or(index as u32) == 0,
155        description: digest_text,
156    }
157}
158
159fn convert_outlet(outlet: &XmlOutlet, index: usize) -> PortDef {
160    let type_str = outlet.outlet_type.as_deref().unwrap_or("");
161    let digest_text = outlet
162        .digest
163        .as_ref()
164        .and_then(|d| d.text.as_deref())
165        .unwrap_or("")
166        .trim()
167        .to_string();
168
169    PortDef {
170        id: outlet.id.unwrap_or(index as u32),
171        port_type: PortType::from_xml_type(type_str),
172        is_hot: false,
173        description: digest_text,
174    }
175}
176
177fn convert_arg(arg: &XmlObjArg) -> ArgDef {
178    ArgDef {
179        name: arg.name.clone().unwrap_or_default(),
180        arg_type: arg.arg_type.clone().unwrap_or_default(),
181        optional: arg.optional.as_deref() == Some("1"),
182    }
183}
184
185/// Parse a .maxref.xml content string and return an ObjectDef
186pub fn parse_maxref(xml_content: &str) -> Result<ObjectDef, ParseError> {
187    let obj: XmlC74Object = from_str(xml_content)?;
188
189    let name = obj.name.ok_or(ParseError::MissingName)?;
190    let module = Module::parse(obj.module.as_deref().unwrap_or("max"));
191    let category = obj.category.unwrap_or_default();
192    let digest = obj
193        .digest
194        .and_then(|d| d.text)
195        .unwrap_or_default()
196        .trim()
197        .to_string();
198
199    let xml_inlets = obj.inletlist.map(|il| il.inlets).unwrap_or_default();
200    let xml_outlets = obj.outletlist.map(|ol| ol.outlets).unwrap_or_default();
201    let xml_args = obj.objarglist.map(|al| al.args).unwrap_or_default();
202
203    let inlet_defs: Vec<PortDef> = xml_inlets
204        .iter()
205        .enumerate()
206        .map(|(i, inlet)| convert_inlet(inlet, i))
207        .collect();
208
209    let outlet_defs: Vec<PortDef> = xml_outlets
210        .iter()
211        .enumerate()
212        .map(|(i, outlet)| convert_outlet(outlet, i))
213        .collect();
214
215    let args: Vec<ArgDef> = xml_args.iter().map(convert_arg).collect();
216
217    let inlets = if has_variable_ports(&xml_args, &xml_inlets) {
218        InletSpec::Variable {
219            min_inlets: if inlet_defs.is_empty() { 0 } else { 1 },
220            defaults: inlet_defs,
221        }
222    } else {
223        InletSpec::Fixed(inlet_defs)
224    };
225
226    let outlets = if has_variable_outlet_ports(&xml_args, &xml_outlets) {
227        OutletSpec::Variable {
228            min_outlets: if outlet_defs.is_empty() { 0 } else { 1 },
229            defaults: outlet_defs,
230        }
231    } else {
232        OutletSpec::Fixed(outlet_defs)
233    };
234
235    Ok(ObjectDef {
236        name,
237        module,
238        category,
239        digest,
240        inlets,
241        outlets,
242        args,
243    })
244}
245
246/// Parse all .maxref.xml files in a directory and return an ObjectDb.
247/// Files that fail to parse are skipped (error count is returned).
248pub fn load_directory(dir: &Path) -> Result<(ObjectDb, usize), ParseError> {
249    let mut db = ObjectDb::new();
250    let mut error_count = 0;
251
252    if !dir.is_dir() {
253        return Err(ParseError::Io(std::io::Error::new(
254            std::io::ErrorKind::NotFound,
255            format!("Directory not found: {:?}", dir),
256        )));
257    }
258
259    let entries = std::fs::read_dir(dir)?;
260    for entry in entries {
261        let entry = entry?;
262        let path = entry.path();
263
264        if path.extension().and_then(|e| e.to_str()) != Some("xml") {
265            continue;
266        }
267
268        let file_name = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
269
270        if !file_name.ends_with(".maxref.xml") {
271            continue;
272        }
273
274        let content = match std::fs::read_to_string(&path) {
275            Ok(c) => c,
276            Err(_) => {
277                error_count += 1;
278                continue;
279            }
280        };
281
282        match parse_maxref(&content) {
283            Ok(def) => {
284                db.insert(def);
285            }
286            Err(_) => {
287                error_count += 1;
288            }
289        }
290    }
291
292    Ok((db, error_count))
293}
294
295/// Load all .maxref.xml files recursively from a directory tree.
296///
297/// Useful for Package refpages that have subdirectories
298/// (e.g., `packages/Gen/docs/refpages1/common/`, `packages/RNBO/docs/refpages/rnbo/`).
299pub fn load_directory_recursive(dir: &Path) -> Result<(ObjectDb, usize), ParseError> {
300    let mut db = ObjectDb::new();
301    let mut error_count = 0;
302    load_recursive_inner(dir, &mut db, &mut error_count)?;
303    Ok((db, error_count))
304}
305
306fn load_recursive_inner(
307    dir: &Path,
308    db: &mut ObjectDb,
309    error_count: &mut usize,
310) -> Result<(), ParseError> {
311    if !dir.is_dir() {
312        return Ok(());
313    }
314
315    let entries = std::fs::read_dir(dir).map_err(ParseError::Io)?;
316    for entry in entries {
317        let entry = entry.map_err(ParseError::Io)?;
318        let path = entry.path();
319
320        if path.is_dir() {
321            load_recursive_inner(&path, db, error_count)?;
322        } else if path.extension().and_then(|e| e.to_str()) == Some("xml") {
323            let file_name = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
324            if !file_name.ends_with(".maxref.xml") {
325                continue;
326            }
327
328            match std::fs::read_to_string(&path) {
329                Ok(content) => match parse_maxref(&content) {
330                    Ok(def) => {
331                        db.insert(def);
332                    }
333                    Err(_) => {
334                        *error_count += 1;
335                    }
336                },
337                Err(_) => {
338                    *error_count += 1;
339                }
340            }
341        }
342    }
343
344    Ok(())
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    const CYCLE_XML: &str = r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
352<c74object name="cycle~" module="msp" category="MSP Synthesis">
353    <digest>Sinusoidal oscillator</digest>
354    <description>Use the cycle~ object to generate a periodic waveform.</description>
355    <inletlist>
356        <inlet id="0" type="signal/float">
357            <digest>Frequency</digest>
358            <description>TEXT_HERE</description>
359        </inlet>
360        <inlet id="1" type="signal/float">
361            <digest>Phase (0-1)</digest>
362            <description>TEXT_HERE</description>
363        </inlet>
364    </inletlist>
365    <outletlist>
366        <outlet id="0" type="signal">
367            <digest>Output</digest>
368            <description>TEXT_HERE</description>
369        </outlet>
370    </outletlist>
371    <objarglist>
372        <objarg name="frequency" optional="1" units="hz" type="number">
373            <digest>Oscillator frequency (initial)</digest>
374        </objarg>
375        <objarg name="buffer-name" optional="1" type="symbol">
376            <digest>Buffer name</digest>
377        </objarg>
378    </objarglist>
379</c74object>"#;
380
381    const BIQUAD_XML: &str = r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
382<c74object name="biquad~" module="msp" category="MSP Filters">
383    <digest>Two-pole, two-zero filter</digest>
384    <description>biquad~ implements a two-pole, two-zero filter.</description>
385    <inletlist>
386        <inlet id="0" type="signal">
387            <digest>Input</digest>
388            <description>TEXT_HERE</description>
389        </inlet>
390        <inlet id="1" type="signal/float">
391            <digest>Input Gain (Filter coefficient a0)</digest>
392            <description>TEXT_HERE</description>
393        </inlet>
394        <inlet id="2" type="signal/float">
395            <digest>Filter coefficient a1</digest>
396            <description>TEXT_HERE</description>
397        </inlet>
398        <inlet id="3" type="signal/float">
399            <digest>Filter coefficient a2</digest>
400            <description>TEXT_HERE</description>
401        </inlet>
402        <inlet id="4" type="signal/float">
403            <digest>Filter coefficient b1</digest>
404            <description>TEXT_HERE</description>
405        </inlet>
406        <inlet id="5" type="signal/float">
407            <digest>Filter coefficient b2</digest>
408            <description>TEXT_HERE</description>
409        </inlet>
410    </inletlist>
411    <outletlist>
412        <outlet id="0" type="signal">
413            <digest>Output</digest>
414            <description>TEXT_HERE</description>
415        </outlet>
416    </outletlist>
417    <objarglist>
418        <objarg name="a0" optional="0" type="float">
419            <digest>a0 coefficient initial value</digest>
420        </objarg>
421        <objarg name="a1" optional="0" type="float">
422            <digest>a1 coefficient initial value</digest>
423        </objarg>
424        <objarg name="a2" optional="0" type="float">
425            <digest>a2 coefficient initial value</digest>
426        </objarg>
427        <objarg name="b1" optional="0" type="float">
428            <digest>b1 coefficient initial value</digest>
429        </objarg>
430        <objarg name="b2" optional="0" type="float">
431            <digest>b2 coefficient initial value</digest>
432        </objarg>
433    </objarglist>
434</c74object>"#;
435
436    const TRIGGER_XML: &str = r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
437<c74object name="trigger" module="max" category="Control, Right-to-Left">
438    <digest>Send input to many places</digest>
439    <description>Outputs any input received in order from right to left.</description>
440    <inletlist>
441        <inlet id="0" type="INLET_TYPE">
442            <digest>Message to be Fanned to Multiple Outputs</digest>
443            <description>TEXT_HERE</description>
444        </inlet>
445    </inletlist>
446    <outletlist>
447        <outlet id="0" type="OUTLET_TYPE">
448            <digest>Output Order 2 (int)</digest>
449            <description>TEXT_HERE</description>
450        </outlet>
451        <outlet id="1" type="OUTLET_TYPE">
452            <digest>Output Order 1 (int)</digest>
453            <description>TEXT_HERE</description>
454        </outlet>
455    </outletlist>
456    <objarglist>
457        <objarg name="formats" optional="1" type="symbol">
458            <digest>Output types</digest>
459            <description>The number of arguments determines the number of outlets.</description>
460        </objarg>
461    </objarglist>
462</c74object>"#;
463
464    const PACK_XML: &str = r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
465<c74object name="pack" module="max" category="Lists">
466    <digest>Create a list</digest>
467    <description>Combine items into an output list.</description>
468    <inletlist>
469        <inlet id="0" type="INLET_TYPE">
470            <digest>value for the first list element, causes output</digest>
471            <description></description>
472        </inlet>
473        <inlet id="1" type="INLET_TYPE">
474            <digest>value for the second list element</digest>
475            <description></description>
476        </inlet>
477    </inletlist>
478    <outletlist>
479        <outlet id="0" type="OUTLET_TYPE">
480            <digest>Output list</digest>
481            <description></description>
482        </outlet>
483    </outletlist>
484    <objarglist>
485        <objarg name="list-elements" optional="1" type="any">
486            <digest>List elements</digest>
487            <description>The number of inlets is determined by the number of arguments.</description>
488        </objarg>
489    </objarglist>
490</c74object>"#;
491
492    const SELECTOR_XML: &str = r#"<?xml version="1.0" encoding="utf-8" standalone="yes"?>
493<c74object name="selector~" module="msp" category="MSP Routing">
494    <digest>Assign one of several inputs to an outlet</digest>
495    <description>Use the selector~ object to choose between one of several input signals.</description>
496    <inletlist>
497        <inlet id="0" type="int/signal">
498            <digest>int/signal Turns Input Off or Routes to Output</digest>
499            <description>TEXT_HERE</description>
500        </inlet>
501        <inlet id="1" type="signal">
502            <digest>(signal) Input</digest>
503            <description>TEXT_HERE</description>
504        </inlet>
505    </inletlist>
506    <outletlist>
507        <outlet id="0" type="signal">
508            <digest>(signal) Output</digest>
509            <description>TEXT_HERE</description>
510        </outlet>
511    </outletlist>
512    <objarglist>
513        <objarg name="number-of-inputs" optional="1" type="int">
514            <digest>Number of inputs</digest>
515        </objarg>
516        <objarg name="initially-open-inlet" optional="1" type="int">
517            <digest>Initial input selected</digest>
518        </objarg>
519    </objarglist>
520</c74object>"#;
521
522    // ---- cycle~ tests ----
523
524    #[test]
525    fn test_parse_cycle() {
526        let def = parse_maxref(CYCLE_XML).unwrap();
527        assert_eq!(def.name, "cycle~");
528        assert_eq!(def.module, Module::Msp);
529        assert_eq!(def.category, "MSP Synthesis");
530        assert_eq!(def.digest, "Sinusoidal oscillator");
531
532        // cycle~ has fixed inlets
533        assert!(!def.has_variable_inlets());
534        assert_eq!(def.default_inlet_count(), 2);
535
536        if let InletSpec::Fixed(ref inlets) = def.inlets {
537            assert_eq!(inlets[0].id, 0);
538            assert_eq!(inlets[0].port_type, PortType::SignalFloat);
539            assert!(inlets[0].is_hot);
540            assert_eq!(inlets[0].description, "Frequency");
541
542            assert_eq!(inlets[1].id, 1);
543            assert_eq!(inlets[1].port_type, PortType::SignalFloat);
544            assert!(!inlets[1].is_hot);
545            assert_eq!(inlets[1].description, "Phase (0-1)");
546        } else {
547            panic!("Expected Fixed inlets for cycle~");
548        }
549
550        // outlet
551        assert!(!def.has_variable_outlets());
552        assert_eq!(def.default_outlet_count(), 1);
553
554        if let OutletSpec::Fixed(ref outlets) = def.outlets {
555            assert_eq!(outlets[0].port_type, PortType::Signal);
556        } else {
557            panic!("Expected Fixed outlets for cycle~");
558        }
559
560        // args
561        assert_eq!(def.args.len(), 2);
562        assert_eq!(def.args[0].name, "frequency");
563        assert!(def.args[0].optional);
564    }
565
566    // ---- biquad~ tests ----
567
568    #[test]
569    fn test_parse_biquad() {
570        let def = parse_maxref(BIQUAD_XML).unwrap();
571        assert_eq!(def.name, "biquad~");
572        assert_eq!(def.module, Module::Msp);
573        assert!(!def.has_variable_inlets());
574        assert_eq!(def.default_inlet_count(), 6);
575
576        if let InletSpec::Fixed(ref inlets) = def.inlets {
577            // inlet 0 is signal only
578            assert_eq!(inlets[0].port_type, PortType::Signal);
579            assert!(inlets[0].is_hot);
580            // inlet 1-5 are signal/float
581            for i in 1..6 {
582                assert_eq!(inlets[i].port_type, PortType::SignalFloat);
583                assert!(!inlets[i].is_hot);
584            }
585        } else {
586            panic!("Expected Fixed inlets for biquad~");
587        }
588
589        assert_eq!(def.default_outlet_count(), 1);
590        assert_eq!(def.args.len(), 5);
591        assert!(!def.args[0].optional); // biquad~ args are required
592    }
593
594    // ---- trigger tests ----
595
596    #[test]
597    fn test_parse_trigger() {
598        let def = parse_maxref(TRIGGER_XML).unwrap();
599        assert_eq!(def.name, "trigger");
600        assert_eq!(def.module, Module::Max);
601
602        // trigger has variable outlets
603        assert!(def.has_variable_outlets());
604        assert_eq!(def.default_outlet_count(), 2);
605
606        // inlet is INLET_TYPE -> Dynamic, Variable because there are arguments
607        assert!(def.has_variable_inlets());
608        if let InletSpec::Variable { ref defaults, .. } = def.inlets {
609            assert_eq!(defaults.len(), 1);
610            assert_eq!(defaults[0].port_type, PortType::Dynamic);
611            assert!(defaults[0].is_hot);
612        } else {
613            panic!("Expected Variable inlets for trigger");
614        }
615
616        if let OutletSpec::Variable { ref defaults, .. } = def.outlets {
617            assert_eq!(defaults.len(), 2);
618            assert_eq!(defaults[0].port_type, PortType::Dynamic);
619            assert_eq!(defaults[1].port_type, PortType::Dynamic);
620        } else {
621            panic!("Expected Variable outlets for trigger");
622        }
623    }
624
625    // ---- pack tests ----
626
627    #[test]
628    fn test_parse_pack() {
629        let def = parse_maxref(PACK_XML).unwrap();
630        assert_eq!(def.name, "pack");
631        assert_eq!(def.module, Module::Max);
632
633        // pack has variable inlets
634        assert!(def.has_variable_inlets());
635        assert_eq!(def.default_inlet_count(), 2);
636
637        if let InletSpec::Variable {
638            ref defaults,
639            min_inlets,
640        } = def.inlets
641        {
642            assert_eq!(min_inlets, 1);
643            assert_eq!(defaults.len(), 2);
644            assert_eq!(defaults[0].port_type, PortType::Dynamic);
645            assert!(defaults[0].is_hot);
646            assert!(!defaults[1].is_hot);
647        } else {
648            panic!("Expected Variable inlets for pack");
649        }
650
651        // outlet is OUTLET_TYPE + has arguments -> Variable
652        assert!(def.has_variable_outlets());
653    }
654
655    // ---- selector~ tests ----
656
657    #[test]
658    fn test_parse_selector() {
659        let def = parse_maxref(SELECTOR_XML).unwrap();
660        assert_eq!(def.name, "selector~");
661        assert_eq!(def.module, Module::Msp);
662        assert_eq!(def.category, "MSP Routing");
663
664        // selector~ has fixed inlets (not INLET_TYPE)
665        assert!(!def.has_variable_inlets());
666        assert_eq!(def.default_inlet_count(), 2);
667
668        if let InletSpec::Fixed(ref inlets) = def.inlets {
669            assert_eq!(inlets[0].port_type, PortType::IntSignal);
670            assert!(inlets[0].is_hot);
671            assert_eq!(inlets[1].port_type, PortType::Signal);
672            assert!(!inlets[1].is_hot);
673        } else {
674            panic!("Expected Fixed inlets for selector~");
675        }
676
677        assert!(!def.has_variable_outlets());
678        assert_eq!(def.default_outlet_count(), 1);
679    }
680
681    // ---- Error handling tests ----
682
683    #[test]
684    fn test_parse_missing_name() {
685        let xml = r#"<?xml version="1.0"?>
686<c74object module="msp">
687    <digest>Test</digest>
688</c74object>"#;
689        let result = parse_maxref(xml);
690        assert!(result.is_err());
691    }
692
693    #[test]
694    fn test_parse_minimal() {
695        let xml = r#"<?xml version="1.0"?>
696<c74object name="test" module="max">
697    <digest>Minimal test</digest>
698</c74object>"#;
699        let def = parse_maxref(xml).unwrap();
700        assert_eq!(def.name, "test");
701        assert_eq!(def.default_inlet_count(), 0);
702        assert_eq!(def.default_outlet_count(), 0);
703    }
704
705    // ---- Tests using real XML files (when Max.app is installed) ----
706
707    #[test]
708    fn test_parse_real_cycle_xml() {
709        let path = Path::new(
710            "/Applications/Max.app/Contents/Resources/C74/docs/refpages/msp-ref/cycle~.maxref.xml",
711        );
712        if !path.exists() {
713            eprintln!("Skipping test: Max.app not found");
714            return;
715        }
716
717        let content = std::fs::read_to_string(path).unwrap();
718        let def = parse_maxref(&content).unwrap();
719
720        assert_eq!(def.name, "cycle~");
721        assert_eq!(def.module, Module::Msp);
722        assert_eq!(def.default_inlet_count(), 2);
723        assert_eq!(def.default_outlet_count(), 1);
724    }
725
726    #[test]
727    fn test_parse_real_biquad_xml() {
728        let path = Path::new(
729            "/Applications/Max.app/Contents/Resources/C74/docs/refpages/msp-ref/biquad~.maxref.xml",
730        );
731        if !path.exists() {
732            eprintln!("Skipping test: Max.app not found");
733            return;
734        }
735
736        let content = std::fs::read_to_string(path).unwrap();
737        let def = parse_maxref(&content).unwrap();
738
739        assert_eq!(def.name, "biquad~");
740        assert_eq!(def.default_inlet_count(), 6);
741    }
742
743    #[test]
744    fn test_parse_real_trigger_xml() {
745        let path = Path::new(
746            "/Applications/Max.app/Contents/Resources/C74/docs/refpages/max-ref/trigger.maxref.xml",
747        );
748        if !path.exists() {
749            eprintln!("Skipping test: Max.app not found");
750            return;
751        }
752
753        let content = std::fs::read_to_string(path).unwrap();
754        let def = parse_maxref(&content).unwrap();
755
756        assert_eq!(def.name, "trigger");
757        assert!(def.has_variable_outlets());
758    }
759
760    #[test]
761    fn test_load_msp_ref_directory() {
762        let dir = Path::new("/Applications/Max.app/Contents/Resources/C74/docs/refpages/msp-ref");
763        if !dir.exists() {
764            eprintln!("Skipping test: Max.app not found");
765            return;
766        }
767
768        let (db, errors) = load_directory(dir).unwrap();
769
770        // msp-ref has ~455 files. Most should succeed even with some parse errors
771        assert!(db.len() > 400, "Expected > 400 objects, got {}", db.len());
772        assert!(errors < 60, "Too many parse errors: {}", errors);
773
774        // Verify representative objects are included
775        assert!(db.lookup("cycle~").is_some());
776        assert!(db.lookup("biquad~").is_some());
777        assert!(db.lookup("selector~").is_some());
778    }
779
780    #[test]
781    fn test_load_max_ref_directory() {
782        let dir = Path::new("/Applications/Max.app/Contents/Resources/C74/docs/refpages/max-ref");
783        if !dir.exists() {
784            eprintln!("Skipping test: Max.app not found");
785            return;
786        }
787
788        let (db, errors) = load_directory(dir).unwrap();
789
790        assert!(db.len() > 400, "Expected > 400 objects, got {}", db.len());
791        assert!(errors < 80, "Too many parse errors: {}", errors);
792
793        assert!(db.lookup("trigger").is_some());
794        assert!(db.lookup("pack").is_some());
795    }
796
797    // ---- load_directory_recursive tests ----
798
799    #[test]
800    fn test_load_directory_recursive_on_flat_dir() {
801        // load_directory_recursive should also work on a flat directory (same as load_directory)
802        let dir = Path::new("/Applications/Max.app/Contents/Resources/C74/docs/refpages/msp-ref");
803        if !dir.exists() {
804            eprintln!("Skipping test: Max.app not found");
805            return;
806        }
807
808        let (db_flat, errors_flat) = load_directory(dir).unwrap();
809        let (db_recursive, errors_recursive) = load_directory_recursive(dir).unwrap();
810
811        // Recursive should find at least as many as flat on the same directory
812        assert_eq!(
813            db_flat.len(),
814            db_recursive.len(),
815            "Flat ({}) and recursive ({}) should match on a flat directory",
816            db_flat.len(),
817            db_recursive.len()
818        );
819        assert_eq!(errors_flat, errors_recursive);
820    }
821
822    #[test]
823    fn test_load_directory_recursive_finds_subdirectories() {
824        // Package directories have subdirectories with refpages
825        let packages_dir = Path::new("/Applications/Max.app/Contents/Resources/C74/packages");
826        if !packages_dir.exists() {
827            eprintln!("Skipping test: Max.app packages not found");
828            return;
829        }
830
831        // Try a known package with subdirectories (e.g., Gen)
832        let gen_refpages = packages_dir.join("Gen").join("docs").join("refpages1");
833        if !gen_refpages.exists() {
834            eprintln!("Skipping test: Gen package refpages1 not found");
835            return;
836        }
837
838        let (db, _errors) = load_directory_recursive(&gen_refpages).unwrap();
839        eprintln!(
840            "load_directory_recursive on Gen/docs/refpages1: {} objects",
841            db.len()
842        );
843        assert!(
844            db.len() > 0,
845            "Expected at least 1 object from recursive scan of Gen refpages1"
846        );
847    }
848
849    #[test]
850    fn test_load_directory_recursive_nonexistent_dir() {
851        let dir = Path::new("/nonexistent/path/that/does/not/exist");
852        let (db, error_count) = load_directory_recursive(dir).unwrap();
853        assert!(db.is_empty());
854        assert_eq!(error_count, 0);
855    }
856}