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