Skip to main content

mimium_cli/
lib.rs

1mod async_compiler;
2
3use std::{
4    env, fs,
5    path::{Path, PathBuf},
6    process::Command,
7    sync::{Mutex, mpsc},
8};
9
10use crate::async_compiler::{CompileRequest, Errors, Response};
11use clap::{Parser, ValueEnum};
12use mimium_audiodriver::{
13    AudioDriverOptions,
14    backends::{csv::csv_driver, local_buffer::LocalBufferDriver},
15    driver::{Driver, RuntimeData, SampleRate},
16    load_runtime_with_options,
17};
18use mimium_lang::{
19    Config, ExecContext,
20    compiler::{
21        self,
22        bytecodegen::SelfEvalMode,
23        emit_ast,
24        parser::{self as cst_parser, parser_errors_to_reportable},
25    },
26    log,
27    plugin::Plugin,
28    runtime::ProgramPayload,
29    utils::{
30        error::{ReportableError, report},
31        fileloader,
32        miniprint::MiniPrint,
33    },
34};
35#[cfg(target_os = "macos")]
36use notify::event::{AccessKind, EventKind, ModifyKind};
37#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
38use notify::event::{AccessKind, EventKind, ModifyKind};
39use notify::{Event, RecursiveMode, Watcher};
40use serde::{Deserialize, Serialize};
41
42#[cfg(not(target_arch = "wasm32"))]
43use mimium_lang::mir::StateType;
44#[cfg(not(target_arch = "wasm32"))]
45use mimium_lang::plugin::ExtFunTypeInfo;
46#[cfg(not(target_arch = "wasm32"))]
47use state_tree::StateStoragePatchPlan;
48#[cfg(not(target_arch = "wasm32"))]
49use state_tree::patch::CopyFromPatch;
50#[cfg(not(target_arch = "wasm32"))]
51use state_tree::tree::StateTreeSkeleton;
52
53#[derive(clap::Parser, Debug, Clone)]
54#[command(author, version, about, long_about = None)]
55pub struct Args {
56    #[command(flatten)]
57    pub mode: Mode,
58
59    /// File name
60    #[clap(value_parser)]
61    pub file: Option<String>,
62
63    /// Write out the signal values to a file (e.g. out.csv).
64    #[arg(long, short)]
65    pub output: Option<PathBuf>,
66
67    /// How many times to execute the code. This is only effective when --output
68    /// is specified.
69    #[arg(long, default_value_t = 10)]
70    pub times: usize,
71
72    /// Output format
73    #[arg(long, value_enum)]
74    pub output_format: Option<OutputFileFormat>,
75
76    /// Don't launch GUI
77    #[arg(long, default_value_t = false)]
78    pub no_gui: bool,
79
80    /// Execution backend (default: wasm).
81    #[arg(long, value_enum, default_value_t = Backend::Wasm)]
82    pub backend: Backend,
83
84    /// Path to config.toml (default: ~/.mimium/config.toml)
85    #[arg(long)]
86    pub config: Option<PathBuf>,
87
88    /// Change the behavior of `self` in the code. It this is set to true, `| | {self+1}` will return 0 at t=0, which normally returns 1.
89    #[arg(long, default_value_t = false)]
90    pub self_init_0: bool,
91}
92
93impl Args {
94    pub fn to_execctx_config(self) -> mimium_lang::Config {
95        mimium_lang::Config {
96            compiler: mimium_lang::compiler::Config {
97                self_eval_mode: if self.self_init_0 {
98                    SelfEvalMode::ZeroAtInit
99                } else {
100                    SelfEvalMode::SimpleState
101                },
102            },
103        }
104    }
105}
106
107#[derive(Clone, Debug, ValueEnum)]
108pub enum OutputFileFormat {
109    Csv,
110}
111
112#[derive(Clone, Copy, Debug, ValueEnum, Eq, PartialEq)]
113pub enum Backend {
114    Vm,
115    Wasm,
116}
117
118#[derive(Clone, Debug, Deserialize, Serialize, Default)]
119pub struct CliConfig {
120    #[serde(default)]
121    pub audio_setting: AudioSetting,
122}
123
124#[derive(Clone, Debug, Deserialize, Serialize)]
125#[serde(default)]
126#[serde(rename_all = "kebab-case")]
127pub struct AudioSetting {
128    pub input_device: String,
129    pub output_device: String,
130    pub buffer_size: u32,
131    pub sample_rate: u32,
132}
133
134impl Default for AudioSetting {
135    fn default() -> Self {
136        Self {
137            input_device: String::new(),
138            output_device: String::new(),
139            buffer_size: 512,
140            sample_rate: 48000,
141        }
142    }
143}
144
145impl AudioSetting {
146    fn to_driver_options(&self) -> AudioDriverOptions {
147        AudioDriverOptions {
148            input_device: (!self.input_device.trim().is_empty())
149                .then_some(self.input_device.clone()),
150            output_device: (!self.output_device.trim().is_empty())
151                .then_some(self.output_device.clone()),
152            buffer_size: (self.buffer_size > 0).then_some(self.buffer_size as usize),
153        }
154    }
155
156    fn effective_sample_rate(&self) -> u32 {
157        if self.sample_rate > 0 {
158            self.sample_rate
159        } else {
160            48000
161        }
162    }
163}
164
165fn home_dir() -> Option<PathBuf> {
166    env::var_os("HOME")
167        .map(PathBuf::from)
168        .or_else(|| env::var_os("USERPROFILE").map(PathBuf::from))
169}
170
171fn default_config_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
172    home_dir()
173        .map(|home| home.join(".mimium").join("config.toml"))
174        .ok_or_else(|| "Could not resolve home directory for default config path".into())
175}
176
177fn expand_tilde(path: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
178    let raw = path.to_string_lossy();
179    if raw == "~" {
180        return home_dir().ok_or_else(|| "Could not resolve home directory".into());
181    }
182    if let Some(suffix) = raw.strip_prefix("~/").or_else(|| raw.strip_prefix("~\\")) {
183        return home_dir()
184            .map(|home| home.join(suffix))
185            .ok_or_else(|| "Could not resolve home directory".into());
186    }
187    Ok(path.to_path_buf())
188}
189
190fn resolve_config_path(path: Option<&PathBuf>) -> Result<PathBuf, Box<dyn std::error::Error>> {
191    path.map_or_else(default_config_path, |p| expand_tilde(p.as_path()))
192}
193
194fn load_or_create_cli_config(path: &Path) -> Result<CliConfig, Box<dyn std::error::Error>> {
195    if !path.exists() {
196        if let Some(parent) = path.parent() {
197            fs::create_dir_all(parent)?;
198        }
199        let default_cfg = CliConfig::default();
200        let serialized = toml::to_string_pretty(&default_cfg)?;
201        fs::write(path, serialized)?;
202        log::info!("Created default config at {}", path.display());
203        return Ok(default_cfg);
204    }
205
206    let content = fs::read_to_string(path)?;
207    let parsed: CliConfig = toml::from_str(&content)?;
208    Ok(parsed)
209}
210
211#[derive(clap::Args, Debug, Clone, Copy)]
212#[group(required = false, multiple = false)]
213pub struct Mode {
214    /// Print CST (Concrete Syntax Tree / GreenTree) and exit
215    #[arg(long, default_value_t = false)]
216    pub emit_cst: bool,
217
218    /// Print AST and exit
219    #[arg(long, default_value_t = false)]
220    pub emit_ast: bool,
221
222    /// Print MIR and exit
223    #[arg(long, default_value_t = false)]
224    pub emit_mir: bool,
225
226    /// Print bytecode and exit
227    #[arg(long, default_value_t = false)]
228    pub emit_bytecode: bool,
229
230    /// Generate WASM module and exit
231    #[arg(long, default_value_t = false)]
232    pub emit_wasm: bool,
233}
234
235pub enum RunMode {
236    EmitCst,
237    EmitAst,
238    EmitMir,
239    EmitByteCode,
240    #[cfg(not(target_arch = "wasm32"))]
241    EmitWasm {
242        output: Option<PathBuf>,
243    },
244    NativeAudio,
245    #[cfg(not(target_arch = "wasm32"))]
246    WasmAudio,
247    WriteCsv {
248        times: usize,
249        output: Option<PathBuf>,
250    },
251}
252
253/// Execution options derived from CLI arguments.
254pub struct RunOptions {
255    mode: RunMode,
256    with_gui: bool,
257    /// Use the WASM backend instead of the native VM.
258    use_wasm: bool,
259    audio_setting: AudioSetting,
260    config: Config,
261}
262
263impl RunOptions {
264    /// Convert parsed command line arguments into [`RunOptions`].
265    pub fn from_args(args: &Args, audio_setting: &AudioSetting) -> Self {
266        let config = args.clone().to_execctx_config();
267        #[cfg(not(target_arch = "wasm32"))]
268        let use_wasm_backend = matches!(args.backend, Backend::Wasm);
269        #[cfg(target_arch = "wasm32")]
270        let use_wasm_backend = false;
271
272        if args.mode.emit_cst {
273            return Self {
274                mode: RunMode::EmitCst,
275                with_gui: false,
276                use_wasm: false,
277                audio_setting: audio_setting.clone(),
278                config,
279            };
280        }
281
282        if args.mode.emit_ast {
283            return Self {
284                mode: RunMode::EmitAst,
285                with_gui: true,
286                use_wasm: false,
287                audio_setting: audio_setting.clone(),
288                config,
289            };
290        }
291
292        if args.mode.emit_mir {
293            return Self {
294                mode: RunMode::EmitMir,
295                with_gui: true,
296                use_wasm: false,
297                audio_setting: audio_setting.clone(),
298                config,
299            };
300        }
301
302        if args.mode.emit_bytecode {
303            return Self {
304                mode: RunMode::EmitByteCode,
305                with_gui: true,
306                use_wasm: false,
307                audio_setting: audio_setting.clone(),
308                config,
309            };
310        }
311
312        #[cfg(not(target_arch = "wasm32"))]
313        if args.mode.emit_wasm {
314            return Self {
315                mode: RunMode::EmitWasm {
316                    output: args.output.clone(),
317                },
318                with_gui: false,
319                use_wasm: false,
320                audio_setting: audio_setting.clone(),
321                config,
322            };
323        }
324
325        #[cfg(not(target_arch = "wasm32"))]
326        if use_wasm_backend {
327            // For WASM backend, respect output format
328            let mode = match (&args.output_format, args.output.as_ref()) {
329                (Some(OutputFileFormat::Csv), path) => RunMode::WriteCsv {
330                    times: args.times,
331                    output: path.cloned(),
332                },
333                (None, Some(output))
334                    if output.extension().and_then(|x| x.to_str()) == Some("csv") =>
335                {
336                    RunMode::WriteCsv {
337                        times: args.times,
338                        output: Some(output.clone()),
339                    }
340                }
341                _ => RunMode::WasmAudio,
342            };
343
344            let with_gui = match &mode {
345                RunMode::WasmAudio => !args.no_gui,
346                _ => false,
347            };
348
349            return Self {
350                mode,
351                with_gui,
352                use_wasm: true,
353                audio_setting: audio_setting.clone(),
354                config,
355            };
356        }
357
358        let mode = match (&args.output_format, args.output.as_ref()) {
359            // if none of the output options is specified, make sounds.
360            (None, None) => RunMode::NativeAudio,
361            // When --output-format is explicitly specified, use it.
362            (Some(OutputFileFormat::Csv), path) => RunMode::WriteCsv {
363                times: args.times,
364                output: path.cloned(),
365            },
366            // Otherwise, guess from the file extension.
367            (None, Some(output)) => match output.extension() {
368                Some(x) if &x.to_os_string() == "csv" => RunMode::WriteCsv {
369                    times: args.times,
370                    output: Some(output.clone()),
371                },
372                _ => panic!("cannot determine the output file format"),
373            },
374        };
375
376        let with_gui = match &mode {
377            // launch except when --no-gui is specified
378            RunMode::NativeAudio => !args.no_gui,
379            // do not launch in other mode
380            _ => false,
381        };
382
383        Self {
384            mode,
385            with_gui,
386            use_wasm: false,
387            audio_setting: audio_setting.clone(),
388            config,
389        }
390    }
391
392    fn get_driver(&self) -> Box<dyn Driver<Sample = f64>> {
393        match &self.mode {
394            RunMode::NativeAudio => {
395                load_runtime_with_options(&self.audio_setting.to_driver_options())
396            }
397            #[cfg(not(target_arch = "wasm32"))]
398            RunMode::WasmAudio => {
399                load_runtime_with_options(&self.audio_setting.to_driver_options())
400            }
401            RunMode::WriteCsv { times, output } => csv_driver(*times, output),
402            _ => unreachable!(),
403        }
404    }
405}
406
407/// Construct an [`ExecContext`] with the default set of plugins.
408pub fn get_default_context(
409    path: Option<PathBuf>,
410    with_gui: bool,
411    use_wasm_backend: bool,
412    config: Config,
413) -> ExecContext {
414    let plugins: Vec<Box<dyn Plugin>> = vec![];
415    let mut ctx = ExecContext::new(plugins.into_iter(), path, config);
416
417    // Load dynamic plugins
418    #[cfg(not(target_arch = "wasm32"))]
419    {
420        ctx.init_plugin_loader();
421
422        let mut loaded_count = 0;
423
424        // Try to load all mimium_*.dylib/so/dll from the executable directory first
425        if let Ok(exe_path) = std::env::current_exe()
426            && let Some(exe_dir) = exe_path.parent()
427            && let Some(loader) = ctx.get_plugin_loader_mut()
428        {
429            // Load all plugins except guitools when GUI is requested as SystemPlugin
430            // (guitools will be loaded as SystemPlugin to avoid duplicates)
431            loaded_count = if use_wasm_backend {
432                loader
433                    .load_plugins_from_dir_with_skip_substrings(exe_dir, &["symphonia"])
434                    .unwrap_or(0)
435            } else {
436                loader.load_plugins_from_dir(exe_dir).unwrap_or(0)
437            };
438
439            if loaded_count > 0 {
440                log::debug!("Loaded {loaded_count} plugin(s) from executable directory");
441
442                // When GUI is requested, unload guitools if it was loaded dynamically
443                // since we'll add it as a SystemPlugin instead
444                if with_gui {
445                    // Note: Currently we don't have unload functionality,
446                    // but the SystemPlugin version will take precedence
447                    log::debug!("GUI mode: guitools will be provided as SystemPlugin");
448                }
449            }
450        }
451
452        // If no plugins loaded from exe directory, try standard plugin directory
453        if loaded_count == 0
454            && let Err(e) = if let Some(loader) = ctx.get_plugin_loader_mut() {
455                if use_wasm_backend {
456                    loader.load_builtin_plugins_with_skip_substrings(&["symphonia"])
457                } else {
458                    loader.load_builtin_plugins()
459                }
460            } else {
461                Ok(())
462            }
463        {
464            log::debug!("No builtin dynamic plugins found: {e:?}");
465        }
466    }
467
468    ctx.add_system_plugin(mimium_scheduler::get_default_scheduler_plugin());
469
470    if use_wasm_backend {
471        ctx.add_system_plugin(mimium_symphonia::SamplerPlugin::default());
472    }
473
474    // Always add guitools as SystemPlugin so Slider/Probe macros are available
475    // on every backend. Use headless mode when GUI is disabled.
476    if with_gui {
477        ctx.add_system_plugin(mimium_guitools::GuiToolPlugin::default());
478    } else {
479        ctx.add_system_plugin(mimium_guitools::GuiToolPlugin::headless());
480    }
481
482    ctx
483}
484
485struct FileRunner {
486    pub tx_compiler: mpsc::Sender<CompileRequest>,
487    pub rx_compiler: mpsc::Receiver<Result<Response, Errors>>,
488    pub tx_prog: Option<mpsc::Sender<ProgramPayload>>,
489    pub fullpath: PathBuf,
490    /// When true, recompilation targets the WASM backend instead of the native VM.
491    pub use_wasm: bool,
492    /// Last successfully prepared WASM program metadata.
493    ///
494    /// This is used on the non-RT thread to build deterministic
495    /// state migration plans for the next hot-swap payload.
496    #[cfg(not(target_arch = "wasm32"))]
497    old_program: Mutex<Option<OldWasmProgram>>,
498    /// Channel receiving old engines retired by the audio thread.
499    ///
500    /// Drained on the file-watcher (non-RT) thread so engine destruction does
501    /// not block the real-time callback.
502    #[cfg(not(target_arch = "wasm32"))]
503    retired_engine_receiver: Option<mpsc::Receiver<mimium_lang::runtime::wasm::engine::WasmEngine>>,
504}
505
506#[cfg(not(target_arch = "wasm32"))]
507#[derive(Clone)]
508struct OldWasmProgram {
509    /// DSP state structure of the previously active program.
510    dsp_state_skeleton: Option<StateTreeSkeleton<StateType>>,
511    /// External function signatures required to instantiate/prewarm the next module.
512    ext_fns: Vec<ExtFunTypeInfo>,
513    /// Frozen WASM plugin host handlers (e.g. ProbeValue intercepts).
514    plugin_fns: Option<mimium_lang::runtime::wasm::WasmPluginFnMap>,
515}
516
517#[cfg(not(target_arch = "wasm32"))]
518struct PreparedWasmSwapData {
519    /// Global state snapshot taken after running `main` on non-RT thread.
520    prewarmed_global_state: Vec<u64>,
521    /// Fully loaded WASM engine prepared on non-RT thread.
522    prepared_engine: Box<mimium_lang::runtime::wasm::engine::WasmEngine>,
523}
524
525struct FileWatcher {
526    pub rx: mpsc::Receiver<notify::Result<Event>>,
527    pub watcher: notify::RecommendedWatcher,
528}
529
530#[cfg(target_os = "macos")]
531fn should_recompile_on_event(event: &Event) -> bool {
532    matches!(
533        event.kind,
534        EventKind::Access(AccessKind::Close(notify::event::AccessMode::Write))
535            | EventKind::Modify(ModifyKind::Data(_))
536            | EventKind::Modify(ModifyKind::Any)
537    )
538}
539
540#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
541fn should_recompile_on_event(event: &Event) -> bool {
542    matches!(
543        event.kind,
544        EventKind::Access(AccessKind::Close(notify::event::AccessMode::Write))
545            | EventKind::Modify(ModifyKind::Data(_))
546            | EventKind::Modify(ModifyKind::Any)
547    )
548}
549
550#[cfg(target_os = "windows")]
551fn should_recompile_on_event(_event: &Event) -> bool {
552    true
553}
554
555impl FileRunner {
556    pub fn new(
557        compiler: compiler::Context,
558        path: PathBuf,
559        prog_tx: Option<mpsc::Sender<ProgramPayload>>,
560        use_wasm: bool,
561        #[cfg(not(target_arch = "wasm32"))] old_program: Option<OldWasmProgram>,
562        #[cfg(not(target_arch = "wasm32"))] retired_engine_receiver: Option<
563            mpsc::Receiver<mimium_lang::runtime::wasm::engine::WasmEngine>,
564        >,
565    ) -> Self {
566        let client = async_compiler::start_async_compiler_service(compiler);
567        Self {
568            tx_compiler: client.tx,
569            rx_compiler: client.rx,
570            tx_prog: prog_tx,
571            fullpath: path,
572            use_wasm,
573            #[cfg(not(target_arch = "wasm32"))]
574            old_program: Mutex::new(old_program),
575            #[cfg(not(target_arch = "wasm32"))]
576            retired_engine_receiver,
577        }
578    }
579    fn try_new_watcher(&self) -> Result<FileWatcher, notify::Error> {
580        let (tx, rx) = mpsc::channel::<notify::Result<Event>>();
581        let mut watcher = notify::recommended_watcher(tx)?;
582        watcher.watch(Path::new(&self.fullpath), RecursiveMode::NonRecursive)?;
583        Ok(FileWatcher { rx, watcher })
584    }
585
586    #[cfg(not(target_arch = "wasm32"))]
587    fn try_compile_wasm_in_subprocess(&self) -> Result<Vec<u8>, String> {
588        let exe = env::current_exe().map_err(|e| format!("failed to resolve current exe: {e}"))?;
589        let output = Command::new(exe)
590            .arg(self.fullpath.as_os_str())
591            .arg("--backend=wasm")
592            .arg("--emit-wasm")
593            .output()
594            .map_err(|e| format!("failed to spawn compiler subprocess: {e}"))?;
595
596        if !output.status.success() {
597            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
598            return Err(format!(
599                "subprocess compile failed (status: {:?}): {}",
600                output.status.code(),
601                stderr
602            ));
603        }
604
605        if output.stdout.is_empty() {
606            return Err("subprocess compile succeeded but produced empty wasm stdout".to_string());
607        }
608
609        Ok(output.stdout)
610    }
611
612    #[cfg(not(target_arch = "wasm32"))]
613    fn try_prewarm_wasm_global_state(
614        wasm_bytes: &[u8],
615        ext_fns: &[ExtFunTypeInfo],
616        plugin_fns: Option<mimium_lang::runtime::wasm::WasmPluginFnMap>,
617    ) -> Result<PreparedWasmSwapData, String> {
618        use mimium_lang::runtime::wasm::engine::{WasmDspRuntime, WasmEngine};
619
620        let mut engine = WasmEngine::new(ext_fns, plugin_fns)
621            .map_err(|e| format!("failed to create prewarm wasm engine: {e}"))?;
622
623        engine
624            .load_module(wasm_bytes)
625            .map_err(|e| format!("failed to load module for prewarm: {e}"))?;
626
627        let mut runtime = WasmDspRuntime::new(engine, None, None);
628
629        runtime
630            .run_main()
631            .map_err(|e| format!("failed to run main for prewarm: {e}"))?;
632
633        let global_state = runtime
634            .engine_mut()
635            .get_global_state_data()
636            .map(|data| data.to_vec())
637            .ok_or_else(|| "missing global state after prewarm".to_string())?;
638
639        let prepared_engine = runtime.into_engine();
640
641        Ok(PreparedWasmSwapData {
642            prewarmed_global_state: global_state,
643            prepared_engine: Box::new(prepared_engine),
644        })
645    }
646
647    #[cfg(not(target_arch = "wasm32"))]
648    fn prepare_hot_swap_wasm_payload(
649        &self,
650        bytes: Vec<u8>,
651        dsp_state_skeleton: Option<StateTreeSkeleton<StateType>>,
652        ext_fns: Option<&[ExtFunTypeInfo]>,
653    ) -> Result<ProgramPayload, String> {
654        let old_program = self
655            .old_program
656            .lock()
657            .ok()
658            .and_then(|guard| (*guard).clone());
659        let previous_skeleton = old_program
660            .as_ref()
661            .and_then(|program| program.dsp_state_skeleton.clone());
662        let fallback_ext_fns: &[ExtFunTypeInfo] = old_program
663            .as_ref()
664            .map(|program| program.ext_fns.as_slice())
665            .unwrap_or(&[]);
666        let ext_fns = ext_fns.unwrap_or(fallback_ext_fns);
667        let plugin_fns = old_program
668            .as_ref()
669            .and_then(|program| program.plugin_fns.clone());
670
671        let prepared_swap_data =
672            Self::try_prewarm_wasm_global_state(&bytes, ext_fns, plugin_fns.clone())?;
673
674        let state_patch_plan = Self::build_required_state_patch_plan(
675            previous_skeleton,
676            dsp_state_skeleton.as_ref(),
677            prepared_swap_data.prewarmed_global_state.len(),
678        );
679        let payload = ProgramPayload::WasmModule {
680            bytes,
681            prepared_engine: prepared_swap_data.prepared_engine,
682            dsp_state_skeleton: dsp_state_skeleton.clone(),
683            state_patch_plan,
684            prewarmed_global_state: prepared_swap_data.prewarmed_global_state,
685        };
686        self.update_old_program(dsp_state_skeleton, ext_fns.to_vec(), plugin_fns);
687
688        Ok(payload)
689    }
690
691    #[cfg(not(target_arch = "wasm32"))]
692    fn build_required_state_patch_plan(
693        previous_skeleton: Option<StateTreeSkeleton<StateType>>,
694        new_skeleton: Option<&StateTreeSkeleton<StateType>>,
695        prewarmed_state_size: usize,
696    ) -> StateStoragePatchPlan {
697        if let (Some(old_skeleton), Some(new_skeleton)) = (previous_skeleton, new_skeleton.cloned())
698        {
699            let maybe_plan =
700                state_tree::build_state_storage_patch_plan(old_skeleton, new_skeleton.clone());
701            if let Some(plan) = maybe_plan {
702                return plan;
703            }
704            let total_size = new_skeleton.total_size() as usize;
705            return StateStoragePatchPlan {
706                total_size,
707                patches: vec![CopyFromPatch {
708                    src_addr: 0,
709                    dst_addr: 0,
710                    size: total_size,
711                }],
712            };
713        }
714
715        StateStoragePatchPlan {
716            total_size: prewarmed_state_size,
717            patches: vec![],
718        }
719    }
720
721    #[cfg(not(target_arch = "wasm32"))]
722    fn update_old_program(
723        &self,
724        dsp_state_skeleton: Option<StateTreeSkeleton<StateType>>,
725        ext_fns: Vec<ExtFunTypeInfo>,
726        plugin_fns: Option<mimium_lang::runtime::wasm::WasmPluginFnMap>,
727    ) {
728        if let Ok(mut guard) = self.old_program.lock() {
729            *guard = Some(OldWasmProgram {
730                dsp_state_skeleton,
731                ext_fns,
732                plugin_fns,
733            });
734        }
735    }
736
737    fn recompile_file_inprocess(&self, new_content: String) {
738        #[cfg(not(target_arch = "wasm32"))]
739        let mode = RunMode::EmitByteCode;
740
741        #[cfg(target_arch = "wasm32")]
742        let mode = {
743            let _ = self.use_wasm;
744            RunMode::EmitByteCode
745        };
746        let _ = self.tx_compiler.send(CompileRequest {
747            source: new_content.clone(),
748            path: self.fullpath.clone(),
749            option: RunOptions {
750                mode,
751                with_gui: true,
752                use_wasm: self.use_wasm,
753                audio_setting: AudioSetting::default(),
754                config: Config::default(),
755            },
756        });
757        let _ = self.rx_compiler.recv().map(|res| match res {
758            Ok(Response::Ast(_)) | Ok(Response::Mir(_)) => {
759                log::warn!("unexpected response: AST/MIR");
760            }
761            Ok(Response::ByteCode(prog)) => {
762                log::info!("compiled successfully.");
763                if let Some(tx) = &self.tx_prog {
764                    let _ = tx.send(ProgramPayload::VmProgram(prog));
765                }
766            }
767            #[cfg(not(target_arch = "wasm32"))]
768            Ok(Response::WasmModule(output)) => {
769                log::info!("WASM compiled successfully ({} bytes).", output.bytes.len());
770                if let Some(tx) = &self.tx_prog {
771                    match self.prepare_hot_swap_wasm_payload(
772                        output.bytes,
773                        output.dsp_state_skeleton,
774                        Some(&output.ext_fns),
775                    ) {
776                        Ok(payload) => {
777                            let _ = tx.send(payload);
778                        }
779                        Err(e) => {
780                            log::error!("WASM prepare_hot_swap failed; skip hot-swap by spec: {e}");
781                        }
782                    }
783                }
784            }
785            Err(errs) => {
786                let errs = errs
787                    .into_iter()
788                    .map(|e| Box::new(e) as Box<dyn ReportableError>)
789                    .collect::<Vec<_>>();
790                report(&new_content, self.fullpath.clone(), &errs);
791            }
792        });
793    }
794
795    fn recompile_file(&self) {
796        match fileloader::load(&self.fullpath.to_string_lossy()) {
797            Ok(new_content) => {
798                #[cfg(not(target_arch = "wasm32"))]
799                {
800                    if self.use_wasm {
801                        match self.try_compile_wasm_in_subprocess() {
802                            Ok(bytes) => {
803                                log::info!(
804                                    "WASM compiled in subprocess successfully ({} bytes).",
805                                    bytes.len()
806                                );
807                                if let Some(tx) = &self.tx_prog {
808                                    match self.prepare_hot_swap_wasm_payload(bytes, None, None) {
809                                        Ok(payload) => {
810                                            let _ = tx.send(payload);
811                                        }
812                                        Err(e) => {
813                                            log::error!(
814                                                "WASM prepare_hot_swap failed; skip hot-swap by spec: {e}"
815                                            );
816                                        }
817                                    }
818                                }
819                            }
820                            Err(e) => {
821                                log::error!("{e}");
822                            }
823                        }
824                    } else {
825                        self.recompile_file_inprocess(new_content);
826                    }
827                }
828
829                #[cfg(target_arch = "wasm32")]
830                {
831                    self.recompile_file_inprocess(new_content);
832                }
833            }
834            Err(e) => {
835                log::error!(
836                    "failed to reload the file {}: {}",
837                    self.fullpath.display(),
838                    e
839                );
840            }
841        }
842    }
843
844    #[cfg(not(target_arch = "wasm32"))]
845    fn drain_retired_engines(&self) {
846        if let Some(rx) = &self.retired_engine_receiver {
847            let mut dropped_count = 0usize;
848            while let Ok(_engine) = rx.try_recv() {
849                dropped_count += 1;
850            }
851            if dropped_count > 0 {
852                log::info!(
853                    "WASM deferred drop: released {} retired engine(s) on non-RT thread",
854                    dropped_count
855                );
856            }
857        }
858    }
859
860    //this api never returns
861    pub fn cli_loop(&self) {
862        //watcher instance lives only this context
863        let file_watcher = match self.try_new_watcher() {
864            Ok(watcher) => watcher,
865            Err(e) => {
866                log::error!("Failed to watch file: {e}");
867                return;
868            }
869        };
870
871        loop {
872            #[cfg(not(target_arch = "wasm32"))]
873            self.drain_retired_engines();
874
875            match file_watcher
876                .rx
877                .recv_timeout(std::time::Duration::from_millis(100))
878            {
879                Ok(Ok(event)) => {
880                    if should_recompile_on_event(&event) {
881                        log::info!("File event detected ({:?}), recompiling...", event.kind);
882                        self.recompile_file();
883                    } else {
884                        log::debug!("Ignored file event: {:?}", event.kind);
885                    }
886                }
887                Ok(Err(e)) => {
888                    log::error!("watch error event: {e}");
889                }
890                Err(mpsc::RecvTimeoutError::Timeout) => {
891                    continue;
892                }
893                Err(e) => {
894                    log::error!("receiver error: {e}");
895                }
896            }
897        }
898    }
899}
900
901/// Compile and run a single source file according to the provided options.
902pub fn run_file(
903    options: RunOptions,
904    content: &str,
905    fullpath: &Path,
906) -> Result<(), Vec<Box<dyn ReportableError>>> {
907    log::debug!("Filename: {}", fullpath.display());
908
909    let mut ctx = get_default_context(
910        Some(PathBuf::from(fullpath)),
911        options.with_gui,
912        options.use_wasm || matches!(options.mode, RunMode::EmitWasm { .. }),
913        options.config,
914    );
915
916    match options.mode {
917        RunMode::EmitCst => {
918            let tokens = cst_parser::tokenize(content);
919            let preparsed = cst_parser::preparse(&tokens);
920            let (green_id, arena, tokens, errors) = cst_parser::parse_cst(tokens, &preparsed);
921
922            // Report errors to stderr if any
923            if !errors.is_empty() {
924                let reportable_errors =
925                    parser_errors_to_reportable(content, fullpath.to_path_buf(), errors);
926                report(content, fullpath.to_path_buf(), &reportable_errors);
927            }
928
929            // Print CST tree to stdout
930            let tree_output = arena.print_tree(green_id, &tokens, content, 0);
931            println!("{tree_output}");
932            Ok(())
933        }
934        RunMode::EmitAst => {
935            let ast = emit_ast(content, Some(PathBuf::from(fullpath)))?;
936            println!("{}", ast.pretty_print());
937            Ok(())
938        }
939        RunMode::EmitMir => {
940            ctx.prepare_compiler();
941            let res = ctx.get_compiler().unwrap().emit_mir(content);
942            res.map(|r| {
943                println!("{r}");
944            })?;
945            Ok(())
946        }
947        RunMode::EmitByteCode => {
948            // need to prepare dummy audio plugin to link `now` and `samplerate`
949            let localdriver = LocalBufferDriver::new(0);
950            let plug = localdriver.get_as_plugin();
951            ctx.add_plugin(plug);
952            ctx.prepare_machine(content)?;
953            println!("{}", ctx.get_vm().unwrap().prog);
954            Ok(())
955        }
956        #[cfg(not(target_arch = "wasm32"))]
957        RunMode::EmitWasm { output } => {
958            use mimium_lang::utils::metadata::Location;
959            use std::io::Write;
960            use std::sync::Arc;
961
962            ctx.prepare_compiler();
963            let ext_fns = ctx.get_extfun_types();
964            let mir = ctx.get_compiler().unwrap().emit_mir(content)?;
965
966            // Generate WASM module
967            let mut generator = compiler::wasmgen::WasmGenerator::new(Arc::new(mir), &ext_fns);
968            let wasm_bytes = generator.generate().map_err(|e| {
969                vec![Box::new(mimium_lang::utils::error::SimpleError {
970                    message: e,
971                    span: Location::default(),
972                }) as Box<dyn ReportableError>]
973            })?;
974
975            if let Some(path) = output {
976                std::fs::write(&path, &wasm_bytes).map_err(|e| {
977                    vec![Box::new(mimium_lang::utils::error::SimpleError {
978                        message: e.to_string(),
979                        span: Location::default(),
980                    }) as Box<dyn ReportableError>]
981                })?;
982                println!("Written to: {}", path.display());
983            } else {
984                let mut stdout = std::io::stdout().lock();
985                stdout.write_all(&wasm_bytes).map_err(|e| {
986                    vec![Box::new(mimium_lang::utils::error::SimpleError {
987                        message: e.to_string(),
988                        span: Location::default(),
989                    }) as Box<dyn ReportableError>]
990                })?;
991                stdout.flush().map_err(|e| {
992                    vec![Box::new(mimium_lang::utils::error::SimpleError {
993                        message: e.to_string(),
994                        span: Location::default(),
995                    }) as Box<dyn ReportableError>]
996                })?;
997            }
998
999            Ok(())
1000        }
1001        #[cfg(not(target_arch = "wasm32"))]
1002        RunMode::WasmAudio => {
1003            use mimium_lang::compiler::wasmgen::WasmGenerator;
1004            use mimium_lang::runtime::wasm::engine::{WasmDspRuntime, WasmEngine};
1005            use mimium_lang::utils::metadata::Location;
1006            use std::sync::Arc;
1007
1008            ctx.prepare_compiler();
1009            let mut ext_fns = ctx.get_extfun_types();
1010            // Deduplicate ext_fns by name to avoid "defined twice" errors in WASM runtime
1011            // (can happen when same plugin is loaded both dynamically and as SystemPlugin)
1012            ext_fns.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
1013            ext_fns.dedup_by(|a, b| a.name == b.name);
1014
1015            let mir = ctx.get_compiler().unwrap().emit_mir(content)?;
1016
1017            let io_channels = mir.get_dsp_iochannels();
1018            let dsp_skeleton = mir.get_dsp_state_skeleton().cloned();
1019
1020            // Generate WASM module
1021            let mut generator = WasmGenerator::new(Arc::new(mir), &ext_fns);
1022            let wasm_bytes = generator.generate().map_err(|e| {
1023                vec![Box::new(mimium_lang::utils::error::SimpleError {
1024                    message: e,
1025                    span: Location::default(),
1026                }) as Box<dyn ReportableError>]
1027            })?;
1028
1029            log::info!("Generated WASM module ({} bytes)", wasm_bytes.len());
1030
1031            // Collect WASM plugin functions from all system plugins.
1032            // freeze_wasm_plugin_fns() must be called before generate_wasm_audioworkers()
1033            // so that both share the same underlying scheduler state.
1034            let plugin_fns = ctx.freeze_wasm_plugin_fns();
1035            let plugin_fns_for_hotswap = plugin_fns.clone();
1036
1037            // Collect per-sample audio workers from all plugins.
1038            let wasm_workers = ctx.generate_wasm_audioworkers();
1039
1040            let mut wasm_engine = WasmEngine::new(&ext_fns, plugin_fns).map_err(|e| {
1041                vec![Box::new(mimium_lang::utils::error::SimpleError {
1042                    message: format!("Failed to create WASM engine: {e}"),
1043                    span: Location::default(),
1044                }) as Box<dyn ReportableError>]
1045            })?;
1046
1047            wasm_engine.load_module(&wasm_bytes).map_err(|e| {
1048                vec![Box::new(mimium_lang::utils::error::SimpleError {
1049                    message: format!("Failed to load WASM module: {e}"),
1050                    span: Location::default(),
1051                }) as Box<dyn ReportableError>]
1052            })?;
1053
1054            // Create WasmDspRuntime and wrap in RuntimeData
1055            let mut wasm_runtime =
1056                WasmDspRuntime::new(wasm_engine, io_channels, dsp_skeleton.clone());
1057            wasm_runtime.set_wasm_audioworkers(wasm_workers);
1058            let (retire_tx, retire_rx) = mpsc::channel();
1059            wasm_runtime.set_engine_retire_sender(retire_tx);
1060            ctx.run_wasm_on_init(wasm_runtime.engine_mut());
1061            let _ = wasm_runtime.run_main();
1062            ctx.run_wasm_after_main(wasm_runtime.engine_mut());
1063
1064            let runtimedata = RuntimeData::new_from_runtime(Box::new(wasm_runtime));
1065
1066            // Use the standard audio driver infrastructure
1067            let mut driver = options.get_driver();
1068
1069            let with_gui = options.with_gui;
1070            let mainloop = ctx.try_get_main_loop().unwrap_or(Box::new(move || {
1071                if with_gui {
1072                    loop {
1073                        std::thread::sleep(std::time::Duration::from_millis(1000));
1074                    }
1075                }
1076            }));
1077
1078            driver.init(
1079                runtimedata,
1080                Some(SampleRate::from(
1081                    options.audio_setting.effective_sample_rate(),
1082                )),
1083            );
1084            driver.play();
1085
1086            // Set up file watcher for WASM hot-swap recompilation
1087            let compiler = ctx.take_compiler().unwrap();
1088            let frunner = FileRunner::new(
1089                compiler,
1090                fullpath.to_path_buf(),
1091                driver.get_program_channel(),
1092                true,
1093                Some(OldWasmProgram {
1094                    dsp_state_skeleton: dsp_skeleton,
1095                    ext_fns,
1096                    plugin_fns: plugin_fns_for_hotswap,
1097                }),
1098                Some(retire_rx),
1099            );
1100            if with_gui {
1101                std::thread::spawn(move || frunner.cli_loop());
1102            }
1103
1104            mainloop();
1105            Ok(())
1106        }
1107        #[cfg(not(target_arch = "wasm32"))]
1108        _ if options.use_wasm => {
1109            // WASM backend with standard audio driver (WriteCsv or NativeAudio).
1110            use mimium_lang::compiler::wasmgen::WasmGenerator;
1111            use mimium_lang::runtime::wasm::engine::{WasmDspRuntime, WasmEngine};
1112            use mimium_lang::utils::metadata::Location;
1113            use std::sync::Arc;
1114
1115            let mut driver = options.get_driver();
1116
1117            ctx.prepare_compiler();
1118            let mut ext_fns = ctx.get_extfun_types();
1119            // Deduplicate ext_fns by name to avoid "defined twice" errors in WASM runtime
1120            // (can happen when same plugin is loaded both dynamically and as SystemPlugin)
1121            ext_fns.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
1122            ext_fns.dedup_by(|a, b| a.name == b.name);
1123
1124            let mir = ctx.get_compiler().unwrap().emit_mir(content)?;
1125            let io_channels = mir.get_dsp_iochannels();
1126            let dsp_skeleton = mir.get_dsp_state_skeleton().cloned();
1127
1128            let mut generator = WasmGenerator::new(Arc::new(mir), &ext_fns);
1129            let wasm_bytes = generator.generate().map_err(|e| {
1130                vec![Box::new(mimium_lang::utils::error::SimpleError {
1131                    message: e,
1132                    span: Location::default(),
1133                }) as Box<dyn ReportableError>]
1134            })?;
1135
1136            log::info!("Generated WASM module ({} bytes)", wasm_bytes.len());
1137
1138            // Collect WASM plugin functions from all system plugins.
1139            // freeze_wasm_plugin_fns() must be called before generate_wasm_audioworkers()
1140            // so that both share the same underlying scheduler state.
1141            let plugin_fns = ctx.freeze_wasm_plugin_fns();
1142            let plugin_fns_for_hotswap = plugin_fns.clone();
1143
1144            // Collect per-sample audio workers from all plugins.
1145            let wasm_workers = ctx.generate_wasm_audioworkers();
1146
1147            let mut wasm_engine = WasmEngine::new(&ext_fns, plugin_fns).map_err(|e| {
1148                vec![Box::new(mimium_lang::utils::error::SimpleError {
1149                    message: format!("Failed to create WASM engine: {e}"),
1150                    span: Location::default(),
1151                }) as Box<dyn ReportableError>]
1152            })?;
1153
1154            wasm_engine.load_module(&wasm_bytes).map_err(|e| {
1155                vec![Box::new(mimium_lang::utils::error::SimpleError {
1156                    message: format!("Failed to load WASM module: {e}"),
1157                    span: Location::default(),
1158                }) as Box<dyn ReportableError>]
1159            })?;
1160
1161            let mut wasm_runtime =
1162                WasmDspRuntime::new(wasm_engine, io_channels, dsp_skeleton.clone());
1163            wasm_runtime.set_wasm_audioworkers(wasm_workers);
1164            let (retire_tx, retire_rx) = mpsc::channel();
1165            wasm_runtime.set_engine_retire_sender(retire_tx);
1166            ctx.run_wasm_on_init(wasm_runtime.engine_mut());
1167            let _ = wasm_runtime.run_main();
1168            ctx.run_wasm_after_main(wasm_runtime.engine_mut());
1169
1170            let runtimedata = RuntimeData::new_from_runtime(Box::new(wasm_runtime));
1171
1172            // Get main loop from system plugins (e.g., GUI)
1173            let with_gui = options.with_gui;
1174            let mainloop = ctx.try_get_main_loop().unwrap_or(Box::new(move || {
1175                if with_gui {
1176                    loop {
1177                        std::thread::sleep(std::time::Duration::from_millis(1000));
1178                    }
1179                }
1180            }));
1181
1182            driver.init(
1183                runtimedata,
1184                Some(SampleRate::from(
1185                    options.audio_setting.effective_sample_rate(),
1186                )),
1187            );
1188            driver.play();
1189
1190            // Set up file watcher for WASM hot-swap recompilation
1191            let compiler = ctx.take_compiler().unwrap();
1192            let frunner = FileRunner::new(
1193                compiler,
1194                fullpath.to_path_buf(),
1195                driver.get_program_channel(),
1196                true,
1197                Some(OldWasmProgram {
1198                    dsp_state_skeleton: dsp_skeleton,
1199                    ext_fns,
1200                    plugin_fns: plugin_fns_for_hotswap,
1201                }),
1202                Some(retire_rx),
1203            );
1204            if with_gui {
1205                std::thread::spawn(move || frunner.cli_loop());
1206            }
1207
1208            mainloop();
1209            Ok(())
1210        }
1211        _ => {
1212            let mut driver = options.get_driver();
1213            let audiodriver_plug = driver.get_as_plugin();
1214
1215            ctx.add_plugin(audiodriver_plug);
1216            ctx.prepare_machine(content)?;
1217            let _res = ctx.run_main();
1218
1219            let runtimedata = {
1220                let ctxmut: &mut ExecContext = &mut ctx;
1221                RuntimeData::try_from(ctxmut).unwrap()
1222            };
1223
1224            let mainloop = ctx.try_get_main_loop().unwrap_or(Box::new(move || {
1225                if options.with_gui {
1226                    loop {
1227                        std::thread::sleep(std::time::Duration::from_millis(1000));
1228                    }
1229                }
1230            }));
1231            //this takes ownership of ctx
1232            driver.init(
1233                runtimedata,
1234                Some(SampleRate::from(
1235                    options.audio_setting.effective_sample_rate(),
1236                )),
1237            );
1238            driver.play();
1239
1240            let compiler = ctx.take_compiler().unwrap();
1241
1242            let frunner = FileRunner::new(
1243                compiler,
1244                fullpath.to_path_buf(),
1245                driver.get_program_channel(),
1246                false,
1247                None,
1248                None,
1249            );
1250            if options.with_gui {
1251                std::thread::spawn(move || frunner.cli_loop());
1252            }
1253            mainloop();
1254            Ok(())
1255        }
1256    }
1257}
1258pub fn lib_main() -> Result<(), Box<dyn std::error::Error>> {
1259    if cfg!(debug_assertions) | cfg!(test) {
1260        colog::default_builder()
1261            .filter_level(log::LevelFilter::Trace)
1262            .init();
1263    } else {
1264        colog::default_builder().init();
1265    }
1266
1267    let args = Args::parse();
1268    let config_path = resolve_config_path(args.config.as_ref())?;
1269    let cli_config = load_or_create_cli_config(&config_path)?;
1270
1271    match &args.file {
1272        Some(file) => {
1273            let fullpath = fileloader::get_canonical_path(".", file)?;
1274            let content = fileloader::load(fullpath.to_str().unwrap())?;
1275            let options = RunOptions::from_args(&args, &cli_config.audio_setting);
1276            match run_file(options, &content, &fullpath) {
1277                Ok(()) => {}
1278                Err(e) => {
1279                    // Note: I was hoping to implement std::error::Error for a
1280                    // struct around ReportableError and directly return it,
1281                    // however, std::error::Error cannot be so color-rich as
1282                    // ariadne because it just uses std::fmt::Display.
1283                    report(&content, fullpath, &e);
1284                    return Err(format!("Failed to process {file}").into());
1285                }
1286            }
1287        }
1288        None => {
1289            // repl::run_repl();
1290        }
1291    }
1292    Ok(())
1293}
1294
1295#[cfg(test)]
1296mod tests {
1297    use super::get_default_context;
1298    use mimium_lang::Config;
1299    use std::path::PathBuf;
1300
1301    #[test]
1302    fn default_cli_context_compiles_lift_array_code_source() {
1303        let src = r#"
1304// @test {"times":1,"stereo":false,"expected":[31.0],"web":true}
1305
1306#stage(macro)
1307fn mk_functions(){
1308   let funcs = [
1309      `|x| x + 1.0,
1310      `|x| x * 2.0,
1311   ]
1312   funcs |> lift_array_code
1313}
1314
1315#stage(main)
1316fn dsp(){
1317  let funcs = mk_functions!()
1318  funcs[0](10.0) + funcs[1](10.0)
1319}
1320"#;
1321        let mut ctx = get_default_context(
1322            Some(PathBuf::from("tmp/lift_array_code_test.mmm")),
1323            false,
1324            false,
1325            Config::default(),
1326        );
1327        ctx.prepare_compiler();
1328        let result = ctx.get_compiler().unwrap().emit_mir(src);
1329        assert!(result.is_ok(), "emit_mir failed: {result:?}");
1330    }
1331}