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