Skip to main content

celox_napi/
lib.rs

1mod layout;
2
3use fxhash::FxHashMap as HashMap;
4#[cfg(not(target_arch = "wasm32"))]
5use std::sync::{Arc, Mutex};
6
7use napi::bindgen_prelude::*;
8use napi_derive::napi;
9use veryl_analyzer::{Analyzer, Context, attribute_table, ir::Ir, symbol_table};
10use veryl_metadata::Metadata;
11use veryl_parser::Parser;
12use veryl_path::PathSet;
13
14#[cfg(target_arch = "x86_64")]
15use celox::SimBackend;
16#[cfg(not(target_arch = "wasm32"))]
17use layout::{build_event_map, build_hierarchy_node, build_signal_layout};
18
19/// A segment of a hierarchical instance path.
20#[napi(object)]
21pub struct NapiInstanceSegment {
22    pub name: String,
23    pub index: u32,
24}
25
26/// A signal path consisting of an instance path and a variable path.
27#[napi(object)]
28pub struct NapiSignalPath {
29    pub instance_path: Vec<NapiInstanceSegment>,
30    pub var_path: Vec<String>,
31}
32
33/// A false-loop declaration (combinational loop to ignore).
34#[napi(object)]
35pub struct NapiFalseLoop {
36    pub from: NapiSignalPath,
37    pub to: NapiSignalPath,
38}
39
40/// A true-loop declaration with a convergence iteration limit.
41#[napi(object)]
42pub struct NapiTrueLoop {
43    pub from: NapiSignalPath,
44    pub to: NapiSignalPath,
45    pub max_iter: u32,
46}
47
48/// A source file with its content and path.
49#[napi(object)]
50pub struct NapiSourceFile {
51    pub content: String,
52    pub path: String,
53}
54
55/// A parameter override for a top-level module parameter.
56#[napi(object)]
57pub struct NapiParamOverride {
58    pub name: String,
59    pub value: i64,
60}
61
62/// Per-pass optimizer control. All fields default to true when omitted.
63#[napi(object)]
64pub struct NapiOptimizeOptions {
65    pub store_load_forwarding: Option<bool>,
66    pub hoist_common_branch_loads: Option<bool>,
67    pub bit_extract_peephole: Option<bool>,
68    pub optimize_blocks: Option<bool>,
69    pub split_wide_commits: Option<bool>,
70    pub commit_sinking: Option<bool>,
71    pub inline_commit_forwarding: Option<bool>,
72    pub eliminate_dead_working_stores: Option<bool>,
73    pub reschedule: Option<bool>,
74    pub coalesce_stores: Option<bool>,
75}
76
77/// Options for creating a simulator/simulation handle.
78#[napi(object)]
79pub struct NapiOptions {
80    pub four_state: Option<bool>,
81    pub vcd: Option<String>,
82    /// Optimization level preset: "O0", "O1", or "O2".
83    /// Takes precedence over `optimize` and `optimize_options`.
84    pub opt_level: Option<String>,
85    /// Per-pass overrides applied on top of opt_level.
86    /// Each entry: "+sir:<pass_name>" to enable, "-sir:<pass_name>" to disable.
87    pub pass_overrides: Option<Vec<String>>,
88    /// Shorthand to enable/disable all SIRT optimization passes.
89    /// `true` = all on, `false` = all off. Overridden by `opt_level` or `optimize_options`.
90    pub optimize: Option<bool>,
91    /// Per-pass optimizer flags (legacy). Overridden by `opt_level`/`pass_overrides`.
92    pub optimize_options: Option<NapiOptimizeOptions>,
93    /// Cranelift backend optimization level: "none", "speed", or "speed_and_size".
94    pub cranelift_opt_level: Option<String>,
95    /// Register allocator algorithm: "backtracking" or "single_pass".
96    pub regalloc_algorithm: Option<String>,
97    /// Enable alias analysis in the Cranelift egraph pass. Default: true.
98    pub enable_alias_analysis: Option<bool>,
99    /// Enable the Cranelift IR verifier. Default: true.
100    pub enable_verifier: Option<bool>,
101    pub false_loops: Option<Vec<NapiFalseLoop>>,
102    pub true_loops: Option<Vec<NapiTrueLoop>>,
103    /// Clock polarity: "posedge" or "negedge".
104    pub clock_type: Option<String>,
105    /// Reset type: "async_high", "async_low", "sync_high", or "sync_low".
106    pub reset_type: Option<String>,
107    /// Additional Veryl source to append to the main source code.
108    pub extra_source: Option<String>,
109    /// Parameter overrides for the top-level module.
110    pub parameters: Option<Vec<NapiParamOverride>>,
111    /// Dead store elimination policy: "off", "preserve_top_ports", or "preserve_all_ports".
112    pub dead_store_policy: Option<String>,
113}
114
115/// Parsed builder options from NapiOptions (common fields available on all targets).
116#[allow(dead_code)]
117struct ParsedOptionsCommon {
118    four_state: bool,
119    optimize_options: celox::OptimizeOptions,
120    vcd: Option<String>,
121    false_loops: Vec<(
122        (Vec<(String, usize)>, Vec<String>),
123        (Vec<(String, usize)>, Vec<String>),
124    )>,
125    true_loops: Vec<(
126        (Vec<(String, usize)>, Vec<String>),
127        (Vec<(String, usize)>, Vec<String>),
128        usize,
129    )>,
130    clock_type: Option<celox::ClockType>,
131    reset_type: Option<celox::ResetType>,
132    extra_source: Option<String>,
133    parameters: Vec<(String, u64)>,
134}
135
136/// Parsed builder options from NapiOptions (native-only, includes Cranelift/DSE options).
137#[cfg(not(target_arch = "wasm32"))]
138struct ParsedOptions {
139    common: ParsedOptionsCommon,
140    cranelift_options: celox::CraneliftOptions,
141    dead_store_policy: celox::DeadStorePolicy,
142}
143
144#[cfg(not(target_arch = "wasm32"))]
145impl std::ops::Deref for ParsedOptions {
146    type Target = ParsedOptionsCommon;
147    fn deref(&self) -> &Self::Target {
148        &self.common
149    }
150}
151
152/// Convert a NapiSignalPath to the Rust builder's tuple format.
153fn convert_signal_path(p: &NapiSignalPath) -> (Vec<(String, usize)>, Vec<String>) {
154    let inst: Vec<(String, usize)> = p
155        .instance_path
156        .iter()
157        .map(|seg| (seg.name.clone(), seg.index as usize))
158        .collect();
159    let var_path: Vec<String> = p.var_path.clone();
160    (inst, var_path)
161}
162
163/// Parse a clock type string into ClockType.
164fn parse_clock_type(s: &str) -> Result<celox::ClockType> {
165    match s {
166        "posedge" => Ok(celox::ClockType::PosEdge),
167        "negedge" => Ok(celox::ClockType::NegEdge),
168        _ => Err(Error::from_reason(format!(
169            "Invalid clock_type '{}'. Expected 'posedge' or 'negedge'.",
170            s
171        ))),
172    }
173}
174
175/// Parse a reset type string into ResetType.
176fn parse_reset_type(s: &str) -> Result<celox::ResetType> {
177    match s {
178        "async_high" => Ok(celox::ResetType::AsyncHigh),
179        "async_low" => Ok(celox::ResetType::AsyncLow),
180        "sync_high" => Ok(celox::ResetType::SyncHigh),
181        "sync_low" => Ok(celox::ResetType::SyncLow),
182        _ => Err(Error::from_reason(format!(
183            "Invalid reset_type '{}'. Expected 'async_high', 'async_low', 'sync_high', or 'sync_low'.",
184            s
185        ))),
186    }
187}
188
189/// Convert legacy NapiOptimizeOptions to celox::OptimizeOptions.
190/// Starts from O1 (all on) and disables any explicitly false fields.
191fn convert_optimize_options(napi: &NapiOptimizeOptions) -> celox::OptimizeOptions {
192    let mut opts = celox::OptimizeOptions::all();
193    let fields: &[(Option<bool>, celox::SirPass)] = &[
194        (
195            napi.store_load_forwarding,
196            celox::SirPass::StoreLoadForwarding,
197        ),
198        (
199            napi.hoist_common_branch_loads,
200            celox::SirPass::HoistCommonBranchLoads,
201        ),
202        (
203            napi.bit_extract_peephole,
204            celox::SirPass::BitExtractPeephole,
205        ),
206        (napi.optimize_blocks, celox::SirPass::OptimizeBlocks),
207        (napi.split_wide_commits, celox::SirPass::SplitWideCommits),
208        (napi.commit_sinking, celox::SirPass::CommitSinking),
209        (
210            napi.inline_commit_forwarding,
211            celox::SirPass::InlineCommitForwarding,
212        ),
213        (
214            napi.eliminate_dead_working_stores,
215            celox::SirPass::EliminateDeadWorkingStores,
216        ),
217        (napi.reschedule, celox::SirPass::Reschedule),
218        (napi.coalesce_stores, celox::SirPass::CoalesceStores),
219    ];
220    for &(val, pass) in fields {
221        if let Some(false) = val {
222            opts = opts.disable(pass);
223        }
224    }
225    opts
226}
227
228/// Parse pass override strings like "+sir:reschedule" or "-sir:coalesce_stores".
229fn apply_pass_overrides(
230    mut opts: celox::OptimizeOptions,
231    overrides: &[String],
232) -> Result<celox::OptimizeOptions> {
233    for s in overrides {
234        let (enable, rest) = if let Some(rest) = s.strip_prefix('+') {
235            (true, rest)
236        } else if let Some(rest) = s.strip_prefix('-') {
237            (false, rest)
238        } else {
239            return Err(Error::from_reason(format!(
240                "Invalid pass override '{}'. Must start with '+' or '-'.",
241                s
242            )));
243        };
244        let pass_name = rest.strip_prefix("sir:").unwrap_or(rest);
245        let pass = celox::SirPass::parse(pass_name).ok_or_else(|| {
246            Error::from_reason(format!(
247                "Unknown SIR pass '{}'. Valid passes: {}",
248                pass_name,
249                celox::SirPass::ALL
250                    .iter()
251                    .map(|p| p.as_str())
252                    .collect::<Vec<_>>()
253                    .join(", ")
254            ))
255        })?;
256        opts = if enable {
257            opts.enable(pass)
258        } else {
259            opts.disable(pass)
260        };
261    }
262    Ok(opts)
263}
264
265/// Parse a Cranelift optimization level string.
266#[cfg(not(target_arch = "wasm32"))]
267fn parse_cranelift_opt_level(s: &str) -> Result<celox::CraneliftOptLevel> {
268    match s {
269        "none" => Ok(celox::CraneliftOptLevel::None),
270        "speed" => Ok(celox::CraneliftOptLevel::Speed),
271        "speed_and_size" => Ok(celox::CraneliftOptLevel::SpeedAndSize),
272        _ => Err(Error::from_reason(format!(
273            "Invalid cranelift_opt_level '{}'. Expected 'none', 'speed', or 'speed_and_size'.",
274            s
275        ))),
276    }
277}
278
279/// Parse a register allocator algorithm string.
280#[cfg(not(target_arch = "wasm32"))]
281fn parse_regalloc_algorithm(s: &str) -> Result<celox::RegallocAlgorithm> {
282    match s {
283        "backtracking" => Ok(celox::RegallocAlgorithm::Backtracking),
284        "single_pass" => Ok(celox::RegallocAlgorithm::SinglePass),
285        _ => Err(Error::from_reason(format!(
286            "Invalid regalloc_algorithm '{}'. Expected 'backtracking' or 'single_pass'.",
287            s
288        ))),
289    }
290}
291
292/// Parse a dead store policy string into DeadStorePolicy.
293#[cfg(not(target_arch = "wasm32"))]
294fn parse_dead_store_policy(s: &str) -> Result<celox::DeadStorePolicy> {
295    match s {
296        "off" => Ok(celox::DeadStorePolicy::Off),
297        "preserve_top_ports" => Ok(celox::DeadStorePolicy::PreserveTopPorts),
298        "preserve_all_ports" => Ok(celox::DeadStorePolicy::PreserveAllPorts),
299        _ => Err(Error::from_reason(format!(
300            "Invalid dead_store_policy '{}'. Expected 'off', 'preserve_top_ports', or 'preserve_all_ports'.",
301            s
302        ))),
303    }
304}
305
306/// Helper to extract the common builder config from NapiOptions (available on all targets).
307fn parse_options_common(options: &Option<NapiOptions>) -> Result<ParsedOptionsCommon> {
308    match options.as_ref() {
309        Some(o) => {
310            let false_loops = o
311                .false_loops
312                .as_ref()
313                .map(|loops| {
314                    loops
315                        .iter()
316                        .map(|fl| (convert_signal_path(&fl.from), convert_signal_path(&fl.to)))
317                        .collect()
318                })
319                .unwrap_or_default();
320            let true_loops = o
321                .true_loops
322                .as_ref()
323                .map(|loops| {
324                    loops
325                        .iter()
326                        .map(|tl| {
327                            (
328                                convert_signal_path(&tl.from),
329                                convert_signal_path(&tl.to),
330                                tl.max_iter as usize,
331                            )
332                        })
333                        .collect()
334                })
335                .unwrap_or_default();
336            let clock_type = o.clock_type.as_deref().map(parse_clock_type).transpose()?;
337            let reset_type = o.reset_type.as_deref().map(parse_reset_type).transpose()?;
338            let parameters = o
339                .parameters
340                .as_ref()
341                .map(|params| {
342                    params
343                        .iter()
344                        .map(|p| (p.name.clone(), p.value as u64))
345                        .collect()
346                })
347                .unwrap_or_default();
348            // Resolve optimize_options with priority:
349            // 1. opt_level + pass_overrides (new API)
350            // 2. optimize_options (legacy per-pass bools)
351            // 3. optimize shorthand (legacy bool)
352            // 4. default (O1 = all on)
353            let optimize_options = if let Some(ref level_str) = o.opt_level {
354                let level = celox::OptLevel::parse(level_str).ok_or_else(|| {
355                    Error::from_reason(format!(
356                        "Invalid opt_level '{}'. Expected 'O0', 'O1', or 'O2'.",
357                        level_str
358                    ))
359                })?;
360                let opts = celox::OptimizeOptions::new(level);
361                if let Some(ref overrides) = o.pass_overrides {
362                    apply_pass_overrides(opts, overrides)?
363                } else {
364                    opts
365                }
366            } else if let Some(ref oo) = o.optimize_options {
367                let opts = convert_optimize_options(oo);
368                if let Some(ref overrides) = o.pass_overrides {
369                    apply_pass_overrides(opts, overrides)?
370                } else {
371                    opts
372                }
373            } else if let Some(false) = o.optimize {
374                celox::OptimizeOptions::none()
375            } else {
376                celox::OptimizeOptions::all()
377            };
378            Ok(ParsedOptionsCommon {
379                four_state: o.four_state.unwrap_or(false),
380                optimize_options,
381                vcd: o.vcd.clone(),
382                false_loops,
383                true_loops,
384                clock_type,
385                reset_type,
386                extra_source: o.extra_source.clone(),
387                parameters,
388            })
389        }
390        None => Ok(ParsedOptionsCommon {
391            four_state: false,
392            optimize_options: celox::OptimizeOptions::all(),
393            vcd: None,
394            false_loops: Vec::new(),
395            true_loops: Vec::new(),
396            clock_type: None,
397            reset_type: None,
398            extra_source: None,
399            parameters: Vec::new(),
400        }),
401    }
402}
403
404/// Helper to extract the full builder config from NapiOptions (native only).
405#[cfg(not(target_arch = "wasm32"))]
406fn parse_options(options: &Option<NapiOptions>) -> Result<ParsedOptions> {
407    let common = parse_options_common(options)?;
408    match options.as_ref() {
409        Some(o) => {
410            let dead_store_policy = o
411                .dead_store_policy
412                .as_deref()
413                .map(parse_dead_store_policy)
414                .transpose()?
415                .unwrap_or(celox::DeadStorePolicy::Off);
416            let cranelift_opt_level = o
417                .cranelift_opt_level
418                .as_deref()
419                .map(parse_cranelift_opt_level)
420                .transpose()?
421                .unwrap_or(celox::CraneliftOptLevel::Speed);
422            let regalloc_algorithm = o
423                .regalloc_algorithm
424                .as_deref()
425                .map(parse_regalloc_algorithm)
426                .transpose()?
427                .unwrap_or(celox::RegallocAlgorithm::Backtracking);
428            let cranelift_options = celox::CraneliftOptions {
429                opt_level: cranelift_opt_level,
430                regalloc_algorithm,
431                enable_alias_analysis: o.enable_alias_analysis.unwrap_or(true),
432                enable_verifier: o.enable_verifier.unwrap_or(true),
433                tail_call_split: true,
434                diagnostics: celox::CraneliftDiagnostics::default(),
435            };
436            Ok(ParsedOptions {
437                common,
438                cranelift_options,
439                dead_store_policy,
440            })
441        }
442        None => Ok(ParsedOptions {
443            common,
444            cranelift_options: celox::CraneliftOptions::default(),
445            dead_store_policy: celox::DeadStorePolicy::Off,
446        }),
447    }
448}
449
450/// Append extra source as a separate file entry if provided.
451fn append_extra_source(sources: &mut Vec<(String, std::path::PathBuf)>, extra: &Option<String>) {
452    if let Some(extra) = extra {
453        sources.push((extra.clone(), std::path::PathBuf::from("<extra>")));
454    }
455}
456
457/// Configuration loaded from an optional `celox.toml` in the project root.
458#[derive(serde::Deserialize, Default)]
459#[allow(dead_code)]
460struct CeloxConfig {
461    /// Glob patterns (relative to project root) for `.veryl` files to exclude
462    /// from compilation and type generation.
463    #[serde(default)]
464    exclude: Vec<String>,
465    #[serde(default)]
466    test: CeloxTestConfig,
467    #[serde(default)]
468    simulation: CeloxSimulationConfig,
469}
470
471#[derive(serde::Deserialize, Default)]
472struct CeloxTestConfig {
473    /// Additional source directories (relative to `celox.toml`) whose `.veryl`
474    /// files are included when running simulations and generating type stubs.
475    #[serde(default)]
476    sources: Vec<String>,
477}
478
479#[derive(serde::Deserialize, Default)]
480#[allow(dead_code)]
481struct CeloxSimulationConfig {
482    /// Default maximum steps for `waitUntil` / `waitForCycles`.
483    /// Overridden by the per-call `maxSteps` option.
484    max_steps: Option<u32>,
485}
486
487/// Load `celox.toml` from the given project root (same directory as `Veryl.toml`).
488/// Returns `None` if the file does not exist.
489fn load_celox_config(project_root: &std::path::Path) -> Result<CeloxConfig> {
490    let path = project_root.join("celox.toml");
491    if !path.exists() {
492        return Ok(CeloxConfig::default());
493    }
494    let content = std::fs::read_to_string(&path)
495        .map_err(|e| Error::from_reason(format!("Failed to read celox.toml: {e}")))?;
496    toml::from_str(&content)
497        .map_err(|e| Error::from_reason(format!("Failed to parse celox.toml: {e}")))
498}
499
500/// Build a `GlobSet` from the exclude patterns in the config.
501/// Returns `None` if there are no exclude patterns.
502fn build_exclude_set(config: &CeloxConfig) -> Result<Option<globset::GlobSet>> {
503    if config.exclude.is_empty() {
504        return Ok(None);
505    }
506    let mut builder = globset::GlobSetBuilder::new();
507    for pattern in &config.exclude {
508        let glob = globset::GlobBuilder::new(pattern)
509            .literal_separator(true)
510            .build()
511            .map_err(|e| Error::from_reason(format!("Invalid exclude pattern '{pattern}': {e}")))?;
512        builder.add(glob);
513    }
514    let set = builder
515        .build()
516        .map_err(|e| Error::from_reason(format!("Failed to build exclude set: {e}")))?;
517    Ok(Some(set))
518}
519
520/// Returns `true` if the path should be excluded based on the glob set.
521/// The path is matched relative to `project_root`.
522fn is_excluded(
523    path: &std::path::Path,
524    project_root: &std::path::Path,
525    exclude_set: &globset::GlobSet,
526) -> bool {
527    let relative = path.strip_prefix(project_root).unwrap_or(path);
528    // Normalize to forward slashes for consistent matching
529    let rel_str = relative.to_string_lossy().replace('\\', "/");
530    exclude_set.is_match(&rel_str)
531}
532
533/// Collect all `.veryl` files from the extra test source directories declared in
534/// `celox.toml` and add them as individual source entries.
535fn collect_test_sources(
536    sources: &mut Vec<(String, std::path::PathBuf)>,
537    project_root: &std::path::Path,
538    config: &CeloxConfig,
539) -> Result<()> {
540    for dir in &config.test.sources {
541        let dir_path = project_root.join(dir);
542        if !dir_path.exists() {
543            continue;
544        }
545        let entries = walkdir(&dir_path)?;
546        for entry in entries {
547            let content = std::fs::read_to_string(&entry)
548                .map_err(|e| Error::from_reason(format!("{}: {e}", entry.display())))?;
549            sources.push((content, entry));
550        }
551    }
552    Ok(())
553}
554
555/// Recursively collect `.veryl` files under `dir`, sorted for determinism.
556fn walkdir(dir: &std::path::Path) -> Result<Vec<std::path::PathBuf>> {
557    let mut files = Vec::new();
558    let read = std::fs::read_dir(dir)
559        .map_err(|e| Error::from_reason(format!("Cannot read directory {}: {e}", dir.display())))?;
560    for entry in read {
561        let entry = entry.map_err(|e| Error::from_reason(format!("Directory entry error: {e}")))?;
562        let path = entry.path();
563        if path.is_dir() {
564            files.extend(walkdir(&path)?);
565        } else if path.extension().is_some_and(|ext| ext == "veryl") {
566            files.push(path);
567        }
568    }
569    files.sort();
570    Ok(files)
571}
572
573/// Load a Veryl project's source files and metadata from a directory.
574///
575/// Searches upward from `project_path` for `Veryl.toml`, gathers all `.veryl`
576/// source files, and returns the per-file sources, project metadata, and
577/// the parsed `celox.toml` configuration.
578fn load_project_sources(
579    project_path: &str,
580) -> Result<(Vec<(String, std::path::PathBuf)>, Metadata, CeloxConfig)> {
581    let toml_path = Metadata::search_from(project_path)
582        .map_err(|e| Error::from_reason(format!("Could not find Veryl.toml: {e}")))?;
583    let mut metadata = Metadata::load(&toml_path)
584        .map_err(|e| Error::from_reason(format!("Failed to load Veryl.toml: {e}")))?;
585    let paths = metadata
586        .paths::<&str>(&[], false, false)
587        .map_err(|e| Error::from_reason(format!("Failed to gather sources: {e}")))?;
588    let mut sources = Vec::new();
589    for p in paths.iter().filter(|path| !path.example) {
590        let content = std::fs::read_to_string(&p.src)
591            .map_err(|e| Error::from_reason(format!("{}: {e}", p.src.display())))?;
592        sources.push((content, p.src.clone()));
593    }
594    let project_root = toml_path.parent().unwrap_or(&toml_path);
595    let celox_cfg = load_celox_config(project_root)?;
596    collect_test_sources(&mut sources, project_root, &celox_cfg)?;
597    if let Some(exclude_set) = build_exclude_set(&celox_cfg)? {
598        sources.retain(|(_, path)| !is_excluded(path, project_root, &exclude_set));
599    }
600    Ok((sources, metadata, celox_cfg))
601}
602
603/// Format compilation warnings as a JSON array of strings.
604///
605/// Uses `render_diagnostic` to include source location and span information,
606/// matching the format used for error messages.
607fn format_warnings_json(warnings: &[celox::CompilationWarning]) -> String {
608    let msgs: Vec<String> = warnings
609        .iter()
610        .map(|w| celox::render_diagnostic(w))
611        .collect();
612    serde_json::to_string(&msgs).unwrap_or_else(|_| "[]".to_string())
613}
614
615/// Apply parsed options to a SimulatorBuilder.
616#[cfg(not(target_arch = "wasm32"))]
617fn apply_options<'a, T>(
618    mut builder: celox::SimulatorBuilder<'a, T>,
619    opts: &ParsedOptions,
620) -> celox::SimulatorBuilder<'a, T> {
621    builder = builder.four_state(opts.four_state);
622    builder = builder.optimize_options(opts.optimize_options.clone());
623    builder = builder.cranelift_options(opts.cranelift_options);
624    // VCD is handled separately after build — not passed to SimulatorBuilder
625    for (from, to) in &opts.false_loops {
626        builder = builder.false_loop(from.clone(), to.clone());
627    }
628    for (from, to, max_iter) in &opts.true_loops {
629        builder = builder.true_loop(from.clone(), to.clone(), *max_iter);
630    }
631    if let Some(ct) = opts.clock_type {
632        builder = builder.clock_type(ct);
633    }
634    if let Some(rt) = opts.reset_type {
635        builder = builder.reset_type(rt);
636    }
637    for (name, value) in &opts.parameters {
638        builder = builder.param(name, *value);
639    }
640    builder = builder.dead_store_policy(opts.dead_store_policy);
641    builder
642}
643
644// ---------------------------------------------------------------------------
645//  Process-global JIT cache (native only)
646// ---------------------------------------------------------------------------
647
648#[cfg(target_arch = "x86_64")]
649type SharedCode = celox::SharedNativeCode;
650#[cfg(all(not(target_arch = "wasm32"), not(target_arch = "x86_64")))]
651type SharedCode = celox::SharedJitCode;
652
653#[cfg(not(target_arch = "wasm32"))]
654/// Cached compilation result shared across simulator instances.
655struct CachedBuild {
656    shared_code: Arc<SharedCode>,
657    runtime_errors: HashMap<i64, (String, Vec<String>)>,
658    layout_json: String,
659    events_json: String,
660    hierarchy_json: String,
661    warnings_json: String,
662    stable_size: u32,
663    total_size: u32,
664    /// Pre-computed VCD signal descriptors so VCD works on cache hits.
665    vcd_descs: Vec<celox::VcdSignalDesc>,
666}
667
668#[cfg(not(target_arch = "wasm32"))]
669/// Exact cache key — no hashing, no collisions.
670///
671/// Contains the full source content + paths + top module + all compilation-
672/// affecting options. Two builds produce the same `CacheKey` iff they would
673/// produce identical compiled code.
674#[derive(Clone, Debug, PartialEq, Eq, Hash)]
675struct CacheKey {
676    /// (path, content) sorted by path for determinism.
677    sources: Vec<(String, String)>,
678    top: String,
679    four_state: bool,
680    sir_optimization: SirOptimizationCacheKey,
681    cranelift_opt_level: u8,
682    regalloc_algorithm: u8,
683    enable_alias_analysis: bool,
684    enable_verifier: bool,
685    dead_store_policy: u8,
686    clock_type: Option<u8>,
687    reset_type: Option<u8>,
688    parameters: Vec<(String, u64)>,
689    false_loops: Vec<(
690        (Vec<(String, usize)>, Vec<String>),
691        (Vec<(String, usize)>, Vec<String>),
692    )>,
693    true_loops: Vec<(
694        (Vec<(String, usize)>, Vec<String>),
695        (Vec<(String, usize)>, Vec<String>),
696        usize,
697    )>,
698    /// Effective clock/reset from metadata (from_project path).
699    /// None when using the `new` constructor (no metadata).
700    metadata_clock_type: Option<u8>,
701    metadata_reset_type: Option<u8>,
702}
703
704/// Collision-free representation of every SIR code-generation option.
705#[cfg(not(target_arch = "wasm32"))]
706#[derive(Clone, Debug, PartialEq, Eq, Hash)]
707struct SirOptimizationCacheKey {
708    opt_level: celox::OptLevel,
709    enabled_passes: Box<[bool]>,
710    max_native_memory_width: usize,
711}
712
713#[cfg(not(target_arch = "wasm32"))]
714impl From<&celox::OptimizeOptions> for SirOptimizationCacheKey {
715    fn from(options: &celox::OptimizeOptions) -> Self {
716        Self {
717            opt_level: options.opt_level(),
718            enabled_passes: celox::SirPass::ALL
719                .iter()
720                .map(|&pass| options.is_enabled(pass))
721                .collect(),
722            max_native_memory_width: options.max_native_memory_width(),
723        }
724    }
725}
726
727#[cfg(not(target_arch = "wasm32"))]
728static JIT_CACHE: std::sync::LazyLock<Mutex<HashMap<CacheKey, Arc<CachedBuild>>>> =
729    std::sync::LazyLock::new(|| Mutex::new(HashMap::default()));
730
731#[cfg(not(target_arch = "wasm32"))]
732/// Build a collision-free cache key from source content, top module, and options.
733///
734/// When `metadata` is `Some`, the effective clock/reset settings from
735/// `Veryl.toml` are included in the key so that changing project config
736/// invalidates the cache.
737fn build_cache_key(
738    sources: &[(String, std::path::PathBuf)],
739    top: &str,
740    opts: &ParsedOptions,
741    metadata: Option<&Metadata>,
742) -> CacheKey {
743    let mut sorted_sources: Vec<(String, String)> = sources
744        .iter()
745        .map(|(content, path)| (path.to_string_lossy().into_owned(), content.clone()))
746        .collect();
747    sorted_sources.sort_by(|a, b| a.0.cmp(&b.0));
748
749    CacheKey {
750        sources: sorted_sources,
751        top: top.to_string(),
752        four_state: opts.four_state,
753        sir_optimization: SirOptimizationCacheKey::from(&opts.optimize_options),
754        cranelift_opt_level: opts.cranelift_options.opt_level as u8,
755        regalloc_algorithm: opts.cranelift_options.regalloc_algorithm as u8,
756        enable_alias_analysis: opts.cranelift_options.enable_alias_analysis,
757        enable_verifier: opts.cranelift_options.enable_verifier,
758        dead_store_policy: opts.dead_store_policy as u8,
759        clock_type: opts.clock_type.map(|ct| ct as u8),
760        reset_type: opts.reset_type.map(|rt| rt as u8),
761        parameters: opts.parameters.clone(),
762        false_loops: opts.false_loops.clone(),
763        true_loops: opts.true_loops.clone(),
764        metadata_clock_type: metadata.map(|m| m.build.clock_type as u8),
765        metadata_reset_type: metadata.map(|m| m.build.reset_type as u8),
766    }
767}
768
769#[cfg(not(target_arch = "wasm32"))]
770fn runtime_errors_by_name(program: &celox::RuntimeProgram) -> HashMap<i64, (String, Vec<String>)> {
771    program
772        .runtime_schema
773        .runtime_errors
774        .iter()
775        .map(|(&code, info)| {
776            (
777                code,
778                (
779                    info.message.clone(),
780                    info.signals
781                        .iter()
782                        .map(|addr| program.get_path(addr))
783                        .collect(),
784                ),
785            )
786        })
787        .collect()
788}
789
790#[cfg(not(target_arch = "wasm32"))]
791fn napi_runtime_error(
792    runtime_errors: &HashMap<i64, (String, Vec<String>)>,
793    err: celox::RuntimeErrorCode,
794) -> Error {
795    match err {
796        celox::RuntimeErrorCode::DetectedTrueLoopCode(code) => {
797            if let Some((message, signals)) = runtime_errors.get(&code) {
798                if message == "Detected True Loop" {
799                    Error::from_reason(format!(
800                        "{}",
801                        celox::RuntimeErrorCode::DetectedTrueLoopAt {
802                            signals: signals.clone(),
803                        }
804                    ))
805                } else {
806                    Error::from_reason(format!(
807                        "{}",
808                        celox::RuntimeErrorCode::Runtime {
809                            message: message.clone(),
810                            signals: signals.clone(),
811                        }
812                    ))
813                }
814            } else {
815                Error::from_reason(format!("{}", celox::RuntimeErrorCode::DetectedTrueLoop))
816            }
817        }
818        other => Error::from_reason(format!("{}", other)),
819    }
820}
821
822/// Low-level handle wrapping a JIT backend and optional VCD writer.
823///
824/// JS holds this as an opaque class; all operations go through methods.
825#[cfg(not(target_arch = "wasm32"))]
826#[napi]
827pub struct NativeSimulatorHandle {
828    backend: Option<celox::DefaultBackend>,
829    runtime_errors: HashMap<i64, (String, Vec<String>)>,
830    vcd_writer: Option<celox::VcdWriter>,
831    layout_json: String,
832    events_json: String,
833    hierarchy_json: String,
834    warnings_json: String,
835    stable_size: u32,
836    total_size: u32,
837}
838
839#[cfg(not(target_arch = "wasm32"))]
840impl NativeSimulatorHandle {
841    /// Wrap a simulator built by an external frontend in the standard Celox
842    /// N-API handle.
843    ///
844    /// This is a Rust-only adapter API. A frontend binding can accept its own
845    /// artifact type in a `#[napi]` function, lower it with
846    /// `celox-frontend-sdk`, build a [`celox::Simulator`], and pass the value
847    /// here without serializing an artifact to JSON. Signal metadata is always
848    /// derived from the simulator's actual memory layout.
849    pub fn from_simulator(simulator: celox::Simulator, vcd_path: Option<&str>) -> Result<Self> {
850        Self::build_and_cache(simulator, vcd_path, None)
851    }
852
853    /// Build an N-API handle directly from an in-memory frontend artifact.
854    ///
855    /// Frontend bindings normally expose an artifact-specific function such
856    /// as `from_my_artifact` and use this as their final adapter step.
857    pub fn from_frontend(
858        artifact: celox::FrontendArtifact,
859        options: Option<NapiOptions>,
860    ) -> Result<Self> {
861        let opts = parse_options(&options)?;
862        let builder = apply_options(celox::Simulator::from_frontend(artifact), &opts);
863        let simulator = builder
864            .build()
865            .map_err(|error| Error::from_reason(error.to_string()))?;
866        Self::from_simulator(simulator, opts.vcd.as_deref())
867    }
868}
869
870#[cfg(not(target_arch = "wasm32"))]
871#[napi]
872impl NativeSimulatorHandle {
873    /// Build a full simulator, extract metadata, cache the compiled code,
874    /// and return the handle with a JitBackend (and optional VcdWriter).
875    fn build_and_cache(
876        sim: celox::Simulator,
877        vcd_path: Option<&str>,
878        cache_key: Option<CacheKey>,
879    ) -> Result<Self> {
880        let four_state = sim.layout().four_state;
881        let warnings_json = format_warnings_json(sim.warnings());
882        let signals = sim.named_signals();
883        let events = sim.named_events();
884        let hierarchy = sim.named_hierarchy();
885        let (_, total_size) = sim.memory_as_ptr();
886        let stable_size = sim.stable_region_size();
887        let vcd_descs = sim.build_vcd_descs(four_state);
888        let runtime_errors = runtime_errors_by_name(sim.program());
889
890        let layout_map = build_signal_layout(&signals, four_state);
891        let event_map = build_event_map(&events);
892        let hierarchy_node = build_hierarchy_node(&hierarchy, four_state);
893
894        let layout_json = serde_json::to_string(&layout_map)
895            .map_err(|e| Error::from_reason(format!("Failed to serialize layout: {}", e)))?;
896        let events_json = serde_json::to_string(&event_map)
897            .map_err(|e| Error::from_reason(format!("Failed to serialize events: {}", e)))?;
898        let hierarchy_json = serde_json::to_string(&hierarchy_node)
899            .map_err(|e| Error::from_reason(format!("Failed to serialize hierarchy: {}", e)))?;
900
901        // Cache the compiled code + metadata for future instances
902        if let Some(key) = cache_key {
903            let cached = Arc::new(CachedBuild {
904                shared_code: sim.shared_code(),
905                runtime_errors: runtime_errors.clone(),
906                layout_json: layout_json.clone(),
907                events_json: events_json.clone(),
908                hierarchy_json: hierarchy_json.clone(),
909                warnings_json: warnings_json.clone(),
910                stable_size: stable_size as u32,
911                total_size: total_size as u32,
912                vcd_descs: vcd_descs.clone(),
913            });
914            let mut cache = JIT_CACHE.lock().unwrap_or_else(|e| e.into_inner());
915            cache.insert(key, cached);
916        }
917
918        // Create VcdWriter if requested
919        let vcd_writer = if let Some(path) = vcd_path {
920            Some(
921                celox::VcdWriter::new(path, &vcd_descs)
922                    .map_err(|e| Error::from_reason(format!("Failed to create VCD: {}", e)))?,
923            )
924        } else {
925            None
926        };
927
928        // Extract JitBackend from Simulator (drops runtime metadata which is no longer needed)
929        let backend = sim.into_backend();
930
931        Ok(Self {
932            backend: Some(backend),
933            runtime_errors,
934            vcd_writer,
935            layout_json,
936            events_json,
937            hierarchy_json,
938            warnings_json,
939            stable_size: stable_size as u32,
940            total_size: total_size as u32,
941        })
942    }
943
944    /// Create a handle from a cached build (shared compiled code + fresh memory).
945    fn from_cached(cached: &CachedBuild, vcd_path: Option<&str>) -> Result<Self> {
946        #[cfg(target_arch = "x86_64")]
947        let backend = celox::NativeBackend::from_shared(Arc::clone(&cached.shared_code));
948        #[cfg(not(target_arch = "x86_64"))]
949        let backend = celox::JitBackend::from_shared(Arc::clone(&cached.shared_code));
950        let vcd_writer = if let Some(path) = vcd_path {
951            Some(
952                celox::VcdWriter::new(path, &cached.vcd_descs)
953                    .map_err(|e| Error::from_reason(format!("Failed to create VCD: {}", e)))?,
954            )
955        } else {
956            None
957        };
958        Ok(Self {
959            backend: Some(backend),
960            runtime_errors: cached.runtime_errors.clone(),
961            vcd_writer,
962            layout_json: cached.layout_json.clone(),
963            events_json: cached.events_json.clone(),
964            hierarchy_json: cached.hierarchy_json.clone(),
965            warnings_json: cached.warnings_json.clone(),
966            stable_size: cached.stable_size,
967            total_size: cached.total_size,
968        })
969    }
970
971    /// Create a new simulator from Veryl source code.
972    #[napi(constructor)]
973    pub fn new(
974        sources: Vec<NapiSourceFile>,
975        top: String,
976        options: Option<NapiOptions>,
977    ) -> Result<Self> {
978        let opts = parse_options(&options)?;
979        let mut src_pairs: Vec<(String, std::path::PathBuf)> = sources
980            .into_iter()
981            .map(|s| (s.content, std::path::PathBuf::from(s.path)))
982            .collect();
983        append_extra_source(&mut src_pairs, &opts.extra_source);
984
985        let cache_key = build_cache_key(&src_pairs, &top, &opts, None);
986
987        {
988            let cache = JIT_CACHE.lock().unwrap_or_else(|e| e.into_inner());
989            if let Some(cached) = cache.get(&cache_key) {
990                return Self::from_cached(cached, opts.vcd.as_deref());
991            }
992        }
993
994        let source_refs: Vec<(&str, &std::path::Path)> = src_pairs
995            .iter()
996            .map(|(s, p)| (s.as_str(), p.as_path()))
997            .collect();
998        let builder = apply_options(celox::Simulator::from_sources(source_refs, &top), &opts);
999        let sim = builder
1000            .build()
1001            .map_err(|e| Error::from_reason(format!("{}", e)))?;
1002
1003        Self::build_and_cache(sim, opts.vcd.as_deref(), Some(cache_key))
1004    }
1005
1006    /// Create a simulator from a versioned external-frontend artifact.
1007    #[napi(factory)]
1008    pub fn from_frontend_artifact(
1009        artifact_json: String,
1010        options: Option<NapiOptions>,
1011    ) -> Result<Self> {
1012        let artifact = celox::FrontendArtifact::from_json(&artifact_json)
1013            .map_err(|error| Error::from_reason(error.to_string()))?;
1014        Self::from_frontend(artifact, options)
1015    }
1016
1017    /// Create a new simulator from a Veryl project directory.
1018    ///
1019    /// Searches upward from `project_path` for `Veryl.toml`, gathers all
1020    /// `.veryl` source files, and builds the simulator using the project's
1021    /// clock/reset settings.
1022    #[napi(factory)]
1023    pub fn from_project(
1024        project_path: String,
1025        top: String,
1026        options: Option<NapiOptions>,
1027    ) -> Result<Self> {
1028        let opts = parse_options(&options)?;
1029        let (mut sources, metadata, _celox_cfg) = load_project_sources(&project_path)?;
1030        append_extra_source(&mut sources, &opts.extra_source);
1031
1032        let cache_key = build_cache_key(&sources, &top, &opts, Some(&metadata));
1033
1034        {
1035            let cache = JIT_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1036            if let Some(cached) = cache.get(&cache_key) {
1037                return Self::from_cached(cached, opts.vcd.as_deref());
1038            }
1039        }
1040
1041        let source_refs: Vec<(&str, &std::path::Path)> = sources
1042            .iter()
1043            .map(|(s, p)| (s.as_str(), p.as_path()))
1044            .collect();
1045
1046        let builder = apply_options(
1047            celox::Simulator::from_sources(source_refs, &top).with_metadata(metadata),
1048            &opts,
1049        );
1050        let sim = builder
1051            .build()
1052            .map_err(|e| Error::from_reason(format!("{}", e)))?;
1053
1054        Self::build_and_cache(sim, opts.vcd.as_deref(), Some(cache_key))
1055    }
1056
1057    /// Returns the signal layout as a JSON string.
1058    #[napi(getter)]
1059    pub fn layout_json(&self) -> String {
1060        self.layout_json.clone()
1061    }
1062
1063    /// Returns the event map as a JSON string.
1064    #[napi(getter)]
1065    pub fn events_json(&self) -> String {
1066        self.events_json.clone()
1067    }
1068
1069    /// Returns the instance hierarchy as a JSON string.
1070    #[napi(getter)]
1071    pub fn hierarchy_json(&self) -> String {
1072        self.hierarchy_json.clone()
1073    }
1074
1075    /// Returns compilation warnings as a JSON array of strings.
1076    #[napi(getter)]
1077    pub fn warnings_json(&self) -> String {
1078        self.warnings_json.clone()
1079    }
1080
1081    /// Returns the stable region size in bytes.
1082    #[napi(getter)]
1083    pub fn stable_size(&self) -> u32 {
1084        self.stable_size
1085    }
1086
1087    /// Returns the total memory size in bytes.
1088    #[napi(getter)]
1089    pub fn total_size(&self) -> u32 {
1090        self.total_size
1091    }
1092
1093    /// Trigger a clock/event by its numeric ID.
1094    #[napi]
1095    pub fn tick(&mut self, event_id: u32) -> Result<()> {
1096        let runtime_errors = self.runtime_errors.clone();
1097        let b = self
1098            .backend
1099            .as_mut()
1100            .ok_or_else(|| Error::from_reason("Simulator has been disposed"))?;
1101        let event = b.id_to_event_slice()[event_id as usize];
1102        b.eval_comb()
1103            .map_err(|e| napi_runtime_error(&runtime_errors, e))?;
1104        b.eval_apply_ff_at(event)
1105            .map_err(|e| napi_runtime_error(&runtime_errors, e))?;
1106        b.eval_comb()
1107            .map_err(|e| napi_runtime_error(&runtime_errors, e))
1108    }
1109
1110    /// Trigger a clock/event N times in a single NAPI call.
1111    #[napi]
1112    pub fn tick_n(&mut self, event_id: u32, count: u32) -> Result<()> {
1113        let runtime_errors = self.runtime_errors.clone();
1114        let b = self
1115            .backend
1116            .as_mut()
1117            .ok_or_else(|| Error::from_reason("Simulator has been disposed"))?;
1118        let event = b.id_to_event_slice()[event_id as usize];
1119        for _ in 0..count {
1120            b.eval_comb()
1121                .map_err(|e| napi_runtime_error(&runtime_errors, e))?;
1122            b.eval_apply_ff_at(event)
1123                .map_err(|e| napi_runtime_error(&runtime_errors, e))?;
1124            b.eval_comb()
1125                .map_err(|e| napi_runtime_error(&runtime_errors, e))?;
1126        }
1127        Ok(())
1128    }
1129
1130    /// Evaluate combinational logic.
1131    #[napi]
1132    pub fn eval_comb(&mut self) -> Result<()> {
1133        let runtime_errors = self.runtime_errors.clone();
1134        let b = self
1135            .backend
1136            .as_mut()
1137            .ok_or_else(|| Error::from_reason("Simulator has been disposed"))?;
1138        b.eval_comb()
1139            .map_err(|e| napi_runtime_error(&runtime_errors, e))
1140    }
1141
1142    /// Write VCD dump at the given timestamp.
1143    #[napi]
1144    pub fn dump(&mut self, timestamp: f64) -> Result<()> {
1145        let b = self
1146            .backend
1147            .as_ref()
1148            .ok_or_else(|| Error::from_reason("Simulator has been disposed"))?;
1149        if let Some(ref mut writer) = self.vcd_writer {
1150            let (ptr, size) = b.memory_as_ptr();
1151            let memory = unsafe { std::slice::from_raw_parts(ptr, size) };
1152            writer
1153                .dump(timestamp as u64, memory)
1154                .map_err(|e| Error::from_reason(format!("VCD write error: {}", e)))?;
1155        }
1156        Ok(())
1157    }
1158
1159    /// Return the simulator's stable memory region as a zero-copy `Uint8Array`.
1160    /// JS can access `.buffer` to get the underlying `ArrayBuffer`.
1161    #[napi]
1162    pub fn shared_memory(&mut self) -> Result<Uint8Array> {
1163        let b = self
1164            .backend
1165            .as_mut()
1166            .ok_or_else(|| Error::from_reason("Simulator has been disposed"))?;
1167        let (ptr, _) = b.memory_as_mut_ptr();
1168        let stable_size = b.stable_region_size();
1169        Ok(unsafe { Uint8Array::with_external_data(ptr, stable_size, |_, _| {}) })
1170    }
1171
1172    /// Invalidate this handle (no-op on the Rust side; drop happens via GC).
1173    #[napi]
1174    pub fn dispose(&mut self) {
1175        self.backend = None;
1176        self.vcd_writer = None;
1177    }
1178}
1179
1180/// Low-level handle wrapping a `celox::Simulation`.
1181#[cfg(not(target_arch = "wasm32"))]
1182#[napi]
1183pub struct NativeSimulationHandle {
1184    sim: Option<celox::Simulation>,
1185    layout_json: String,
1186    events_json: String,
1187    hierarchy_json: String,
1188    warnings_json: String,
1189    stable_size: u32,
1190    total_size: u32,
1191    /// Default `maxSteps` for `waitUntil` / `waitForCycles`, sourced from
1192    /// `[simulation] max_steps` in `celox.toml`. `None` when not set.
1193    default_max_steps: Option<u32>,
1194}
1195
1196#[cfg(not(target_arch = "wasm32"))]
1197#[napi]
1198impl NativeSimulationHandle {
1199    /// Create a new timed simulation from Veryl source code.
1200    #[napi(constructor)]
1201    pub fn new(
1202        sources: Vec<NapiSourceFile>,
1203        top: String,
1204        options: Option<NapiOptions>,
1205    ) -> Result<Self> {
1206        let opts = parse_options(&options)?;
1207        let mut src_pairs: Vec<(String, std::path::PathBuf)> = sources
1208            .into_iter()
1209            .map(|s| (s.content, std::path::PathBuf::from(s.path)))
1210            .collect();
1211        append_extra_source(&mut src_pairs, &opts.extra_source);
1212        let source_refs: Vec<(&str, &std::path::Path)> = src_pairs
1213            .iter()
1214            .map(|(s, p)| (s.as_str(), p.as_path()))
1215            .collect();
1216        let mut builder = apply_options(celox::Simulation::from_sources(source_refs, &top), &opts);
1217        if let Some(path) = &opts.vcd {
1218            builder = builder.vcd(path);
1219        }
1220        let sim = builder
1221            .build()
1222            .map_err(|e| Error::from_reason(format!("{}", e)))?;
1223
1224        let warnings_json = format_warnings_json(sim.warnings());
1225        let signals = sim.named_signals();
1226        let events = sim.named_events();
1227        let hierarchy = sim.named_hierarchy();
1228        let (_, total_size) = sim.memory_as_ptr();
1229        let stable_size = sim.stable_region_size();
1230
1231        let layout_map = build_signal_layout(&signals, opts.four_state);
1232        let event_map = build_event_map(&events);
1233        let hierarchy_node = build_hierarchy_node(&hierarchy, opts.four_state);
1234
1235        let layout_json = serde_json::to_string(&layout_map)
1236            .map_err(|e| Error::from_reason(format!("Failed to serialize layout: {}", e)))?;
1237        let events_json = serde_json::to_string(&event_map)
1238            .map_err(|e| Error::from_reason(format!("Failed to serialize events: {}", e)))?;
1239        let hierarchy_json = serde_json::to_string(&hierarchy_node)
1240            .map_err(|e| Error::from_reason(format!("Failed to serialize hierarchy: {}", e)))?;
1241
1242        Ok(Self {
1243            sim: Some(sim),
1244            layout_json,
1245            events_json,
1246            hierarchy_json,
1247            warnings_json,
1248            stable_size: stable_size as u32,
1249            total_size: total_size as u32,
1250            default_max_steps: None,
1251        })
1252    }
1253
1254    /// Create a timed simulation from a versioned external-frontend artifact.
1255    #[napi(factory)]
1256    pub fn from_frontend_artifact(
1257        artifact_json: String,
1258        options: Option<NapiOptions>,
1259    ) -> Result<Self> {
1260        let opts = parse_options(&options)?;
1261        let artifact = celox::FrontendArtifact::from_json(&artifact_json)
1262            .map_err(|error| Error::from_reason(error.to_string()))?;
1263        let mut builder = apply_options(celox::Simulation::from_frontend(artifact), &opts);
1264        if let Some(path) = &opts.vcd {
1265            builder = builder.vcd(path);
1266        }
1267        let sim = builder
1268            .build()
1269            .map_err(|error| Error::from_reason(error.to_string()))?;
1270
1271        let warnings_json = format_warnings_json(sim.warnings());
1272        let signals = sim.named_signals();
1273        let events = sim.named_events();
1274        let hierarchy = sim.named_hierarchy();
1275        let (_, total_size) = sim.memory_as_ptr();
1276        let stable_size = sim.stable_region_size();
1277        let layout_map = build_signal_layout(&signals, opts.four_state);
1278        let event_map = build_event_map(&events);
1279        let hierarchy_node = build_hierarchy_node(&hierarchy, opts.four_state);
1280        let layout_json = serde_json::to_string(&layout_map)
1281            .map_err(|error| Error::from_reason(format!("Failed to serialize layout: {error}")))?;
1282        let events_json = serde_json::to_string(&event_map)
1283            .map_err(|error| Error::from_reason(format!("Failed to serialize events: {error}")))?;
1284        let hierarchy_json = serde_json::to_string(&hierarchy_node).map_err(|error| {
1285            Error::from_reason(format!("Failed to serialize hierarchy: {error}"))
1286        })?;
1287
1288        Ok(Self {
1289            sim: Some(sim),
1290            layout_json,
1291            events_json,
1292            hierarchy_json,
1293            warnings_json,
1294            stable_size: stable_size as u32,
1295            total_size: total_size as u32,
1296            default_max_steps: None,
1297        })
1298    }
1299
1300    /// Create a new timed simulation from a Veryl project directory.
1301    #[napi(factory)]
1302    pub fn from_project(
1303        project_path: String,
1304        top: String,
1305        options: Option<NapiOptions>,
1306    ) -> Result<Self> {
1307        let opts = parse_options(&options)?;
1308        let (mut sources, metadata, celox_cfg) = load_project_sources(&project_path)?;
1309        append_extra_source(&mut sources, &opts.extra_source);
1310        let source_refs: Vec<(&str, &std::path::Path)> = sources
1311            .iter()
1312            .map(|(s, p)| (s.as_str(), p.as_path()))
1313            .collect();
1314
1315        let mut builder = apply_options(
1316            celox::Simulation::from_sources(source_refs, &top).with_metadata(metadata),
1317            &opts,
1318        );
1319        if let Some(path) = &opts.vcd {
1320            builder = builder.vcd(path);
1321        }
1322        let sim = builder
1323            .build()
1324            .map_err(|e| Error::from_reason(format!("{}", e)))?;
1325
1326        let warnings_json = format_warnings_json(sim.warnings());
1327        let signals = sim.named_signals();
1328        let events = sim.named_events();
1329        let hierarchy = sim.named_hierarchy();
1330        let (_, total_size) = sim.memory_as_ptr();
1331        let stable_size = sim.stable_region_size();
1332
1333        let layout_map = build_signal_layout(&signals, opts.four_state);
1334        let event_map = build_event_map(&events);
1335        let hierarchy_node = build_hierarchy_node(&hierarchy, opts.four_state);
1336
1337        let layout_json = serde_json::to_string(&layout_map)
1338            .map_err(|e| Error::from_reason(format!("Failed to serialize layout: {}", e)))?;
1339        let events_json = serde_json::to_string(&event_map)
1340            .map_err(|e| Error::from_reason(format!("Failed to serialize events: {}", e)))?;
1341        let hierarchy_json = serde_json::to_string(&hierarchy_node)
1342            .map_err(|e| Error::from_reason(format!("Failed to serialize hierarchy: {}", e)))?;
1343
1344        Ok(Self {
1345            sim: Some(sim),
1346            layout_json,
1347            events_json,
1348            hierarchy_json,
1349            warnings_json,
1350            stable_size: stable_size as u32,
1351            total_size: total_size as u32,
1352            default_max_steps: celox_cfg.simulation.max_steps,
1353        })
1354    }
1355
1356    /// Returns the signal layout as a JSON string.
1357    #[napi(getter)]
1358    pub fn layout_json(&self) -> String {
1359        self.layout_json.clone()
1360    }
1361
1362    /// Returns the event map as a JSON string.
1363    #[napi(getter)]
1364    pub fn events_json(&self) -> String {
1365        self.events_json.clone()
1366    }
1367
1368    /// Returns the instance hierarchy as a JSON string.
1369    #[napi(getter)]
1370    pub fn hierarchy_json(&self) -> String {
1371        self.hierarchy_json.clone()
1372    }
1373
1374    /// Returns compilation warnings as a JSON array of strings.
1375    #[napi(getter)]
1376    pub fn warnings_json(&self) -> String {
1377        self.warnings_json.clone()
1378    }
1379
1380    /// Returns the stable region size in bytes.
1381    #[napi(getter)]
1382    pub fn stable_size(&self) -> u32 {
1383        self.stable_size
1384    }
1385
1386    /// Returns the total memory size in bytes.
1387    #[napi(getter)]
1388    pub fn total_size(&self) -> u32 {
1389        self.total_size
1390    }
1391
1392    /// Returns the default `maxSteps` from `[simulation] max_steps` in `celox.toml`,
1393    /// or `null` if not configured.
1394    #[napi(getter)]
1395    pub fn default_max_steps(&self) -> Option<u32> {
1396        self.default_max_steps
1397    }
1398
1399    /// Register a clock by event ID.
1400    #[napi]
1401    pub fn add_clock(&mut self, event_id: u32, period: f64, initial_delay: f64) -> Result<()> {
1402        let sim = self
1403            .sim
1404            .as_mut()
1405            .ok_or_else(|| Error::from_reason("Simulation has been disposed"))?;
1406        sim.add_clock_by_id(event_id, period as u64, initial_delay as u64);
1407        Ok(())
1408    }
1409
1410    /// Schedule a one-shot event by event ID.
1411    #[napi]
1412    pub fn schedule(&mut self, event_id: u32, time: f64, value: f64) -> Result<()> {
1413        let sim = self
1414            .sim
1415            .as_mut()
1416            .ok_or_else(|| Error::from_reason("Simulation has been disposed"))?;
1417        sim.schedule_by_id(event_id, time as u64, value as u64)
1418            .map_err(|e| Error::from_reason(format!("{}", e)))
1419    }
1420
1421    /// Advance simulation until `end_time`.
1422    #[napi]
1423    pub fn run_until(&mut self, end_time: f64) -> Result<()> {
1424        let sim = self
1425            .sim
1426            .as_mut()
1427            .ok_or_else(|| Error::from_reason("Simulation has been disposed"))?;
1428        sim.run_until(end_time as u64)
1429            .map_err(|e| Error::from_reason(format!("{}", e)))
1430    }
1431
1432    /// Advance to the next event. Returns the new time, or null if no events.
1433    #[napi]
1434    pub fn step(&mut self) -> Result<Option<f64>> {
1435        let sim = self
1436            .sim
1437            .as_mut()
1438            .ok_or_else(|| Error::from_reason("Simulation has been disposed"))?;
1439        sim.step()
1440            .map(|opt| opt.map(|t| t as f64))
1441            .map_err(|e| Error::from_reason(format!("{}", e)))
1442    }
1443
1444    /// Returns the current simulation time.
1445    #[napi]
1446    pub fn time(&self) -> Result<f64> {
1447        let sim = self
1448            .sim
1449            .as_ref()
1450            .ok_or_else(|| Error::from_reason("Simulation has been disposed"))?;
1451        Ok(sim.time() as f64)
1452    }
1453
1454    /// Returns the time of the next scheduled event, or null if none.
1455    #[napi]
1456    pub fn next_event_time(&self) -> Result<Option<f64>> {
1457        let sim = self
1458            .sim
1459            .as_ref()
1460            .ok_or_else(|| Error::from_reason("Simulation has been disposed"))?;
1461        Ok(sim.next_event_time().map(|t| t as f64))
1462    }
1463
1464    /// Evaluate combinational logic.
1465    #[napi]
1466    pub fn eval_comb(&mut self) -> Result<()> {
1467        let sim = self
1468            .sim
1469            .as_mut()
1470            .ok_or_else(|| Error::from_reason("Simulation has been disposed"))?;
1471        sim.eval_comb()
1472            .map_err(|e| Error::from_reason(format!("{}", e)))
1473    }
1474
1475    /// Write VCD dump at the given timestamp.
1476    #[napi]
1477    pub fn dump(&mut self, timestamp: f64) -> Result<()> {
1478        let sim = self
1479            .sim
1480            .as_mut()
1481            .ok_or_else(|| Error::from_reason("Simulation has been disposed"))?;
1482        sim.dump(timestamp as u64);
1483        Ok(())
1484    }
1485
1486    /// Return the simulation's stable memory region as a zero-copy `Uint8Array`.
1487    /// JS can access `.buffer` to get the underlying `ArrayBuffer`.
1488    #[napi]
1489    pub fn shared_memory(&mut self) -> Result<Uint8Array> {
1490        let sim = self
1491            .sim
1492            .as_mut()
1493            .ok_or_else(|| Error::from_reason("Simulation has been disposed"))?;
1494        let (ptr, _) = sim.memory_as_mut_ptr();
1495        let stable_size = sim.stable_region_size();
1496        Ok(unsafe { Uint8Array::with_external_data(ptr, stable_size, |_, _| {}) })
1497    }
1498
1499    /// Invalidate this handle.
1500    #[napi]
1501    pub fn dispose(&mut self) {
1502        self.sim = None;
1503    }
1504}
1505
1506// ---------------------------------------------------------------------------
1507//  WASM32 NativeSimulatorHandle — compiles Veryl to WASM bytecode
1508// ---------------------------------------------------------------------------
1509
1510#[cfg(target_arch = "wasm32")]
1511#[napi]
1512pub struct NativeSimulatorHandle {
1513    program: celox::LaidOutProgram,
1514    four_state: bool,
1515    layout_json: String,
1516    events_json: String,
1517    hierarchy_json: String,
1518    warnings_json: String,
1519    stable_size: u32,
1520    total_size: u32,
1521}
1522
1523#[cfg(target_arch = "wasm32")]
1524impl NativeSimulatorHandle {
1525    /// Build an N-API/WASI handle directly from an in-memory frontend
1526    /// artifact.
1527    ///
1528    /// Frontend bindings can accept their own artifact type, lower it with
1529    /// `celox-frontend-sdk`, and call this Rust-only adapter API without a JSON
1530    /// serialization boundary.
1531    pub fn from_frontend(
1532        artifact: celox::FrontendArtifact,
1533        options: Option<NapiOptions>,
1534    ) -> Result<Self> {
1535        let opts = parse_options_common(&options)?;
1536        let trace_opts = celox::TraceOptions::default();
1537        let (program, warnings) = celox::compile_frontend_to_sir(
1538            &artifact,
1539            &opts.false_loops,
1540            &opts.true_loops,
1541            opts.four_state,
1542            &trace_opts,
1543            None,
1544            &opts.optimize_options,
1545        )
1546        .map_err(|error| Error::from_reason(error.to_string()))?;
1547
1548        let laid_out = program.into_laid_out(opts.four_state);
1549        let layout = laid_out.layout();
1550        let layout_json = Self::build_layout_json(&laid_out, layout, opts.four_state);
1551        let events_json = Self::build_events_json(&laid_out);
1552        let warnings_json = format_warnings_json(&warnings);
1553        let stable_size = layout.total_size as u32;
1554        let total_size = layout.merged_total_size as u32;
1555
1556        Ok(Self {
1557            program: laid_out,
1558            four_state: opts.four_state,
1559            layout_json,
1560            events_json,
1561            hierarchy_json: "{}".to_string(),
1562            warnings_json,
1563            stable_size,
1564            total_size,
1565        })
1566    }
1567}
1568
1569#[cfg(target_arch = "wasm32")]
1570#[napi]
1571impl NativeSimulatorHandle {
1572    /// Compile Veryl source code and produce a WASM-oriented handle.
1573    ///
1574    /// Unlike the native (JIT) variant, this handle does NOT execute
1575    /// simulation directly. Instead it exposes `combWasmBytes()` and
1576    /// `eventWasmBytes(name)` for the TS runtime to instantiate in the
1577    /// browser via WebAssembly.
1578    #[napi(constructor)]
1579    pub fn new(
1580        sources: Vec<NapiSourceFile>,
1581        top: String,
1582        options: Option<NapiOptions>,
1583    ) -> Result<Self> {
1584        let opts = parse_options_common(&options)?;
1585        let mut src_pairs: Vec<(String, std::path::PathBuf)> = sources
1586            .into_iter()
1587            .map(|s| (s.content, std::path::PathBuf::from(s.path)))
1588            .collect();
1589        append_extra_source(&mut src_pairs, &opts.extra_source);
1590
1591        let source_refs: Vec<(&str, &std::path::Path)> = src_pairs
1592            .iter()
1593            .map(|(s, p)| (s.as_str(), p.as_path()))
1594            .collect();
1595
1596        let trace_opts = celox::TraceOptions::default();
1597        let (program, warnings) = celox::compile_to_sir(
1598            &source_refs,
1599            &top,
1600            &opts
1601                .false_loops
1602                .iter()
1603                .map(|(f, t)| (f.clone(), t.clone()))
1604                .collect::<Vec<_>>(),
1605            &opts
1606                .true_loops
1607                .iter()
1608                .map(|(f, t, m)| (f.clone(), t.clone(), *m))
1609                .collect::<Vec<_>>(),
1610            opts.four_state,
1611            &trace_opts,
1612            None,
1613            None,
1614            opts.clock_type,
1615            opts.reset_type,
1616            &opts.parameters,
1617            &opts.optimize_options,
1618        )
1619        .map_err(|e| Error::from_reason(format!("{}", e)))?;
1620
1621        let laid_out = program.into_laid_out(opts.four_state);
1622        let layout = laid_out.layout();
1623
1624        let layout_json = Self::build_layout_json(&laid_out, layout, opts.four_state);
1625        let events_json = Self::build_events_json(&laid_out);
1626        let hierarchy_json = "{}".to_string(); // Hierarchy not available on wasm32
1627        let warnings_json = format_warnings_json(&warnings);
1628
1629        let stable_size = layout.total_size as u32;
1630        let total_size = layout.merged_total_size as u32;
1631        Ok(Self {
1632            program: laid_out,
1633            four_state: opts.four_state,
1634            layout_json,
1635            events_json,
1636            hierarchy_json,
1637            warnings_json,
1638            stable_size,
1639            total_size,
1640        })
1641    }
1642
1643    /// Create a WASM-oriented handle from a versioned external-frontend artifact.
1644    #[napi(factory)]
1645    pub fn from_frontend_artifact(
1646        artifact_json: String,
1647        options: Option<NapiOptions>,
1648    ) -> Result<Self> {
1649        let artifact = celox::FrontendArtifact::from_json(&artifact_json)
1650            .map_err(|error| Error::from_reason(error.to_string()))?;
1651        Self::from_frontend(artifact, options)
1652    }
1653
1654    /// Create a new simulator from a Veryl project directory.
1655    #[napi(factory)]
1656    pub fn from_project(
1657        project_path: String,
1658        top: String,
1659        options: Option<NapiOptions>,
1660    ) -> Result<Self> {
1661        let opts = parse_options_common(&options)?;
1662        let (mut sources, metadata, _celox_cfg) = load_project_sources(&project_path)?;
1663        append_extra_source(&mut sources, &opts.extra_source);
1664
1665        let source_refs: Vec<(&str, &std::path::Path)> = sources
1666            .iter()
1667            .map(|(s, p)| (s.as_str(), p.as_path()))
1668            .collect();
1669
1670        let trace_opts = celox::TraceOptions::default();
1671        let (program, warnings) = celox::compile_to_sir(
1672            &source_refs,
1673            &top,
1674            &opts
1675                .false_loops
1676                .iter()
1677                .map(|(f, t)| (f.clone(), t.clone()))
1678                .collect::<Vec<_>>(),
1679            &opts
1680                .true_loops
1681                .iter()
1682                .map(|(f, t, m)| (f.clone(), t.clone(), *m))
1683                .collect::<Vec<_>>(),
1684            opts.four_state,
1685            &trace_opts,
1686            None,
1687            Some(metadata),
1688            opts.clock_type,
1689            opts.reset_type,
1690            &opts.parameters,
1691            &opts.optimize_options,
1692        )
1693        .map_err(|e| Error::from_reason(format!("{}", e)))?;
1694
1695        let laid_out = program.into_laid_out(opts.four_state);
1696        let layout = laid_out.layout();
1697
1698        let layout_json = Self::build_layout_json(&laid_out, layout, opts.four_state);
1699        let events_json = Self::build_events_json(&laid_out);
1700        let hierarchy_json = "{}".to_string();
1701        let warnings_json = format_warnings_json(&warnings);
1702
1703        let stable_size = layout.total_size as u32;
1704        let total_size = layout.merged_total_size as u32;
1705        Ok(Self {
1706            program: laid_out,
1707            four_state: opts.four_state,
1708            layout_json,
1709            events_json,
1710            hierarchy_json,
1711            warnings_json,
1712            stable_size,
1713            total_size,
1714        })
1715    }
1716
1717    /// Returns the signal layout as a JSON string.
1718    #[napi(getter)]
1719    pub fn layout_json(&self) -> String {
1720        self.layout_json.clone()
1721    }
1722
1723    /// Returns the event map as a JSON string.
1724    #[napi(getter)]
1725    pub fn events_json(&self) -> String {
1726        self.events_json.clone()
1727    }
1728
1729    /// Byte ranges whose value and mask planes must start as unknown (X).
1730    #[napi(getter)]
1731    pub fn four_state_init_regions_json(&self) -> String {
1732        Self::build_four_state_init_regions_json(
1733            &self.program,
1734            self.program.layout(),
1735            self.four_state,
1736        )
1737    }
1738
1739    /// Return a complete initialized memory image for the TypeScript WASM bridge.
1740    #[napi]
1741    pub fn initial_memory_bytes(&self) -> Vec<u8> {
1742        Self::build_initial_memory_bytes(&self.program, self.program.layout(), self.four_state)
1743    }
1744
1745    /// Returns the instance hierarchy as a JSON string.
1746    #[napi(getter)]
1747    pub fn hierarchy_json(&self) -> String {
1748        self.hierarchy_json.clone()
1749    }
1750
1751    /// Returns compilation warnings as a JSON array of strings.
1752    #[napi(getter)]
1753    pub fn warnings_json(&self) -> String {
1754        self.warnings_json.clone()
1755    }
1756
1757    /// Returns the stable region size in bytes.
1758    #[napi(getter)]
1759    pub fn stable_size(&self) -> u32 {
1760        self.stable_size
1761    }
1762
1763    /// Returns the total memory size in bytes.
1764    #[napi(getter)]
1765    pub fn total_size(&self) -> u32 {
1766        self.total_size
1767    }
1768
1769    /// Returns the WASM module bytes for eval_comb (combinational logic evaluation).
1770    #[napi]
1771    pub fn comb_wasm_bytes(&self) -> Vec<u8> {
1772        let wasm = celox::wasm_codegen::compile_units(
1773            &self.program.sir.eval_comb,
1774            self.program.layout(),
1775            self.four_state,
1776            false,
1777        );
1778        wasm.bytes
1779    }
1780
1781    /// Returns the WASM module bytes for a specific clock/reset event.
1782    ///
1783    /// `event_name` should match a clock or reset port name (e.g. "clk", "rst").
1784    #[napi]
1785    pub fn event_wasm_bytes(&self, event_name: String) -> Result<Vec<u8>> {
1786        for (addr, units) in &self.program.sir.eval_apply_ffs {
1787            let event_path = self.program.get_path(addr);
1788            if event_path == event_name {
1789                let wasm = celox::wasm_codegen::compile_units(
1790                    units,
1791                    self.program.layout(),
1792                    self.four_state,
1793                    false,
1794                );
1795                return Ok(wasm.bytes);
1796            }
1797        }
1798
1799        Err(Error::from_reason(format!(
1800            "Event '{}' not found. Available events: {}",
1801            event_name,
1802            self.program
1803                .sir
1804                .eval_apply_ffs
1805                .keys()
1806                .map(|addr| self.program.get_path(addr))
1807                .collect::<Vec<_>>()
1808                .join(", ")
1809        )))
1810    }
1811
1812    /// No-op on wasm32 (no native resources to release).
1813    #[napi]
1814    pub fn dispose(&mut self) {}
1815}
1816
1817#[cfg(target_arch = "wasm32")]
1818impl NativeSimulatorHandle {
1819    fn write_memory_bit(memory: &mut [u8], byte: usize, bit: usize, value: bool) {
1820        let mask = 1u8 << bit;
1821        if value {
1822            memory[byte] |= mask;
1823        } else {
1824            memory[byte] &= !mask;
1825        }
1826    }
1827
1828    fn byte_bit(bytes: &[u8], bit: usize) -> bool {
1829        bytes
1830            .get(bit / 8)
1831            .is_some_and(|byte| byte & (1u8 << (bit % 8)) != 0)
1832    }
1833
1834    fn build_initial_memory_bytes(
1835        program: &celox::LaidOutProgram,
1836        layout: &celox::MemoryLayout,
1837        four_state: bool,
1838    ) -> Vec<u8> {
1839        let mut memory = vec![0u8; layout.merged_total_size];
1840
1841        if four_state {
1842            for (address, &offset) in &layout.offsets {
1843                if layout.is_4states.get(address).copied().unwrap_or(false) {
1844                    let plane_size = layout.plane_size(address);
1845                    memory[offset..offset + plane_size * 2].fill(0xff);
1846                }
1847            }
1848            for (address, &relative_offset) in &layout.working_offsets {
1849                if layout.is_4states.get(address).copied().unwrap_or(false) {
1850                    let offset = layout.working_base_offset + relative_offset;
1851                    let plane_size = layout.plane_size(address);
1852                    memory[offset..offset + plane_size * 2].fill(0xff);
1853                }
1854            }
1855        }
1856
1857        for initial in &program.design.initial_state {
1858            let Some(&offset) = layout.offsets.get(&initial.address) else {
1859                continue;
1860            };
1861            let width = layout.widths[&initial.address];
1862            let plane_size = layout.plane_size(&initial.address);
1863            let write_mask = four_state
1864                && layout
1865                    .is_4states
1866                    .get(&initial.address)
1867                    .copied()
1868                    .unwrap_or(false);
1869
1870            match &initial.data {
1871                celox_design::InitialStateData::Packed {
1872                    value,
1873                    mask,
1874                    written_mask,
1875                } => {
1876                    let value = value.to_bytes_le();
1877                    let mask = mask.to_bytes_le();
1878                    let written_mask = written_mask.to_bytes_le();
1879                    for bit in 0..width {
1880                        if !Self::byte_bit(&written_mask, bit) {
1881                            continue;
1882                        }
1883                        let (byte, intra) = layout.map_static_bit_offset(&initial.address, bit);
1884                        let is_unknown = Self::byte_bit(&mask, bit);
1885                        Self::write_memory_bit(
1886                            &mut memory,
1887                            offset + byte,
1888                            intra,
1889                            Self::byte_bit(&value, bit) && (write_mask || !is_unknown),
1890                        );
1891                        if write_mask {
1892                            Self::write_memory_bit(
1893                                &mut memory,
1894                                offset + plane_size + byte,
1895                                intra,
1896                                is_unknown,
1897                            );
1898                        }
1899                    }
1900                }
1901                celox_design::InitialStateData::Writes(runs) => {
1902                    for run in runs {
1903                        for relative_bit in 0..run.bit_width {
1904                            let bit = run.bit_offset + relative_bit;
1905                            if bit >= width {
1906                                break;
1907                            }
1908                            let (byte, intra) = layout.map_static_bit_offset(&initial.address, bit);
1909                            Self::write_memory_bit(
1910                                &mut memory,
1911                                offset + byte,
1912                                intra,
1913                                Self::byte_bit(&run.value_bytes, relative_bit),
1914                            );
1915                            if write_mask {
1916                                Self::write_memory_bit(
1917                                    &mut memory,
1918                                    offset + plane_size + byte,
1919                                    intra,
1920                                    Self::byte_bit(&run.mask_bytes, relative_bit),
1921                                );
1922                            }
1923                        }
1924                    }
1925                }
1926            }
1927        }
1928
1929        memory
1930    }
1931
1932    fn build_four_state_init_regions_json(
1933        program: &celox::LaidOutProgram,
1934        layout: &celox::MemoryLayout,
1935        four_state: bool,
1936    ) -> String {
1937        if !four_state {
1938            return "[]".to_string();
1939        }
1940
1941        let mut regions = Vec::new();
1942        for (addr, &offset) in &layout.offsets {
1943            if program
1944                .design
1945                .state_objects
1946                .get(addr)
1947                .is_some_and(|metadata| metadata.is_4state)
1948            {
1949                regions.push((offset, layout.plane_size(addr)));
1950            }
1951        }
1952        for (addr, &relative_offset) in &layout.working_offsets {
1953            if program
1954                .design
1955                .state_objects
1956                .get(addr)
1957                .is_some_and(|metadata| metadata.is_4state)
1958            {
1959                regions.push((
1960                    layout.working_base_offset + relative_offset,
1961                    layout.plane_size(addr),
1962                ));
1963            }
1964        }
1965        regions.sort_unstable();
1966
1967        serde_json::to_string(&regions).unwrap_or_else(|_| "[]".to_string())
1968    }
1969
1970    /// Build signal layout JSON from finalized SIR and MemoryLayout.
1971    /// Mirrors the layout format from celox-wasm.
1972    fn build_layout_json(
1973        program: &celox::LaidOutProgram,
1974        layout: &celox::MemoryLayout,
1975        four_state: bool,
1976    ) -> String {
1977        use std::collections::BTreeMap;
1978
1979        let mut layout_map: BTreeMap<String, serde_json::Value> = BTreeMap::new();
1980
1981        for addr in program.design.state_objects.keys() {
1982            let Some(source) = program.frontend.source_address(addr) else {
1983                continue;
1984            };
1985            let module_id = program.frontend.instance_module[&source.instance_id];
1986            let variables = &program.frontend.module_variables[&module_id];
1987            let Some(info) = variables.get(&source.var_id) else {
1988                continue;
1989            };
1990            if program.frontend.module_var_path_index[&module_id].get(&info.path) == Some(&None) {
1991                continue;
1992            }
1993            let Some(&offset) = layout.offsets.get(addr) else {
1994                continue;
1995            };
1996            let name = program.get_path(addr);
1997            let total_width = layout.widths.get(addr).copied().unwrap_or(0);
1998            let (width, array_dims) = if info.array_dims.is_empty() {
1999                (total_width, None)
2000            } else {
2001                let element_count = info.array_dims.iter().product::<usize>();
2002                (total_width / element_count, Some(&info.array_dims))
2003            };
2004            let byte_size = celox::get_byte_size(width);
2005            let mut entry = serde_json::json!({
2006                "offset": offset,
2007                "width": width,
2008                "byte_size": byte_size,
2009                "is_4state": four_state && info.is_4state,
2010                "direction": layout::direction_str(info.var_kind),
2011                "type_kind": layout::type_kind_str(info.type_kind),
2012            });
2013            if let Some(array_dims) = array_dims {
2014                entry["array_dims"] = serde_json::json!(array_dims);
2015            }
2016            layout_map.insert(name, entry);
2017        }
2018
2019        serde_json::to_string(&layout_map).unwrap_or_else(|_| "{}".to_string())
2020    }
2021
2022    /// Build events JSON from finalized SIR.
2023    fn build_events_json(program: &celox::LaidOutProgram) -> String {
2024        use std::collections::BTreeMap;
2025
2026        let mut events: BTreeMap<String, usize> = BTreeMap::new();
2027
2028        for (next_id, addr) in program.sir.eval_apply_ffs.keys().enumerate() {
2029            let name = program.get_path(addr);
2030            events.insert(name, next_id);
2031        }
2032
2033        serde_json::to_string(&events).unwrap_or_else(|_| "{}".to_string())
2034    }
2035}
2036
2037/// Convert an `AnalyzerError` to structured `JsonDiagnostic`s.
2038/// `all_sources` maps path → source content for offset → line:col conversion.
2039fn analyzer_error_to_diagnostics(
2040    err: &veryl_analyzer::AnalyzerError,
2041    all_sources: &[(String, String)],
2042    is_error: bool,
2043) -> Vec<celox_ts_gen::JsonDiagnostic> {
2044    use miette::Diagnostic as _;
2045
2046    let severity = if is_error {
2047        celox_ts_gen::DiagnosticSeverity::Error
2048    } else {
2049        celox_ts_gen::DiagnosticSeverity::Warning
2050    };
2051
2052    let message = format!("{err}");
2053    let help = err.help().map(|h| h.to_string());
2054    let url = err.url().map(|u| u.to_string());
2055
2056    let labels: Vec<_> = err.labels().map(|l| l.collect()).unwrap_or_default();
2057
2058    if labels.is_empty() {
2059        return vec![celox_ts_gen::JsonDiagnostic {
2060            severity,
2061            message,
2062            file: String::new(),
2063            line: 1,
2064            column: 1,
2065            end_line: None,
2066            end_column: None,
2067            help,
2068            url,
2069        }];
2070    }
2071
2072    labels
2073        .into_iter()
2074        .map(|label| {
2075            let offset = label.offset();
2076            let len = label.len();
2077
2078            // Find which source file this offset belongs to
2079            // AnalyzerError uses global offsets across concatenated sources,
2080            // but typically the error_location is within a single file.
2081            // Try each source to find line:col.
2082            let mut file = String::new();
2083            let mut line = 1usize;
2084            let mut col = 1usize;
2085            let mut end_line = None;
2086            let mut end_col = None;
2087
2088            for (path, content) in all_sources {
2089                // miette offsets are per-file when Parser::parse is given the source
2090                let mut cur_line = 1;
2091                let mut cur_col = 1;
2092                let mut found = false;
2093
2094                for (i, ch) in content.char_indices() {
2095                    if i == offset {
2096                        file = path.clone();
2097                        line = cur_line;
2098                        col = cur_col;
2099                        found = true;
2100                    }
2101                    if found && i == offset + len {
2102                        end_line = Some(cur_line);
2103                        end_col = Some(cur_col);
2104                        break;
2105                    }
2106                    if ch == '\n' {
2107                        cur_line += 1;
2108                        cur_col = 1;
2109                    } else {
2110                        cur_col += 1;
2111                    }
2112                }
2113
2114                if found {
2115                    if end_line.is_none() {
2116                        end_line = Some(cur_line);
2117                        end_col = Some(cur_col);
2118                    }
2119                    break;
2120                }
2121            }
2122
2123            celox_ts_gen::JsonDiagnostic {
2124                severity: severity.clone(),
2125                message: label.label().unwrap_or(&message).to_string(),
2126                file,
2127                line,
2128                column: col,
2129                end_line,
2130                end_column: end_col,
2131                help: help.clone(),
2132                url: url.clone(),
2133            }
2134        })
2135        .collect()
2136}
2137
2138/// Format analyzer errors with accumulated warnings for gen_ts error messages.
2139fn format_errors_with_warnings(
2140    pass_label: &str,
2141    errors: &[&veryl_analyzer::AnalyzerError],
2142    warnings: &[veryl_analyzer::AnalyzerError],
2143) -> String {
2144    let error_msgs: Vec<String> = errors
2145        .iter()
2146        .map(|e| celox::render_diagnostic(*e))
2147        .collect();
2148    let mut msg = format!("Errors in {pass_label}: {}", error_msgs.join("; "));
2149    if !warnings.is_empty() {
2150        let warning_msgs: Vec<String> = warnings
2151            .iter()
2152            .map(|w| celox::render_diagnostic(w))
2153            .collect();
2154        msg.push_str("\n\n--- warnings ---\n\n");
2155        msg.push_str(&warning_msgs.join("\n"));
2156    }
2157    msg
2158}
2159
2160/// Clear the process-global JIT compilation cache.
2161///
2162/// Call this when source files have changed and cached compiled code may be stale.
2163#[cfg(not(target_arch = "wasm32"))]
2164#[napi]
2165pub fn clear_jit_cache() {
2166    let mut cache = JIT_CACHE.lock().unwrap_or_else(|e| e.into_inner());
2167    cache.clear();
2168}
2169
2170/// Stub for wasm32: no JIT cache to clear.
2171#[cfg(target_arch = "wasm32")]
2172#[napi]
2173pub fn clear_jit_cache() {}
2174
2175// ---------------------------------------------------------------------------
2176//  Native testbench execution
2177// ---------------------------------------------------------------------------
2178
2179/// Result of a single `$assert` evaluation in a native testbench.
2180#[cfg(not(target_arch = "wasm32"))]
2181#[napi(object)]
2182pub struct NapiAssertionResult {
2183    pub passed: bool,
2184    pub message: Option<String>,
2185    pub file: Option<String>,
2186    pub line: Option<u32>,
2187    pub column: Option<u32>,
2188}
2189
2190/// Detailed result of running a native testbench.
2191#[cfg(not(target_arch = "wasm32"))]
2192#[napi(object)]
2193pub struct NapiTestResult {
2194    pub passed: bool,
2195    pub assertions: Vec<NapiAssertionResult>,
2196    pub error: Option<String>,
2197}
2198
2199#[cfg(not(target_arch = "wasm32"))]
2200fn convert_test_result(r: celox::TestResultDetailed) -> NapiTestResult {
2201    NapiTestResult {
2202        passed: r.passed,
2203        error: r.error,
2204        assertions: r
2205            .assertions
2206            .into_iter()
2207            .map(|a| {
2208                let (file, line, column) = match a.location {
2209                    Some(loc) => (Some(loc.file), Some(loc.line), Some(loc.column)),
2210                    None => (None, None, None),
2211                };
2212                NapiAssertionResult {
2213                    passed: a.passed,
2214                    message: a.message,
2215                    file,
2216                    line,
2217                    column,
2218                }
2219            })
2220            .collect(),
2221    }
2222}
2223
2224#[cfg(not(target_arch = "wasm32"))]
2225#[napi(object)]
2226pub struct NapiInjectedValue {
2227    pub name: Option<String>,
2228    pub bits: Option<BigInt>,
2229    pub mask_xz: Option<BigInt>,
2230    pub width: Option<u32>,
2231    pub string_value: Option<String>,
2232}
2233
2234#[cfg(not(target_arch = "wasm32"))]
2235#[napi(object)]
2236pub struct NapiInjectedCall {
2237    pub instance: String,
2238    pub phase: String,
2239    pub method: Option<String>,
2240    pub inputs: Vec<NapiInjectedValue>,
2241    pub params: Vec<NapiInjectedValue>,
2242    pub ports: Vec<NapiInjectedPort>,
2243    pub args: Vec<NapiInjectedValue>,
2244    pub cycle: BigInt,
2245    pub time: BigInt,
2246    pub seed: BigInt,
2247    pub fired_clock: Option<String>,
2248    pub four_state: bool,
2249}
2250
2251#[cfg(not(target_arch = "wasm32"))]
2252#[napi(object)]
2253pub struct NapiInjectedPort {
2254    pub name: String,
2255    pub direction: String,
2256    pub role: Option<String>,
2257    pub width: u32,
2258}
2259
2260#[cfg(not(target_arch = "wasm32"))]
2261#[napi(object)]
2262pub struct NapiInjectedResult {
2263    pub outputs: Option<Vec<NapiInjectedValue>>,
2264    pub return_value: Option<NapiInjectedValue>,
2265    pub failures: Option<Vec<String>>,
2266    pub logs: Option<Vec<String>>,
2267    pub finish: Option<bool>,
2268}
2269
2270#[cfg(not(target_arch = "wasm32"))]
2271#[napi(object, object_to_js = false)]
2272pub struct NapiInjectedComponent {
2273    pub name: String,
2274    pub manifest: String,
2275    pub handler: FunctionRef<NapiInjectedCall, NapiInjectedResult>,
2276}
2277
2278#[cfg(not(target_arch = "wasm32"))]
2279struct NapiInjectedHandler {
2280    env: Env,
2281    handler: FunctionRef<NapiInjectedCall, NapiInjectedResult>,
2282}
2283
2284// Injected callbacks are only accepted by the synchronous runTest APIs and
2285// are invoked on the JS thread which supplied this Env. The core trait is
2286// Send + Sync because compiled component hooks may otherwise be movable.
2287#[cfg(not(target_arch = "wasm32"))]
2288unsafe impl Send for NapiInjectedHandler {}
2289#[cfg(not(target_arch = "wasm32"))]
2290unsafe impl Sync for NapiInjectedHandler {}
2291
2292#[cfg(not(target_arch = "wasm32"))]
2293fn napi_bigint(value: u64) -> BigInt {
2294    BigInt {
2295        sign_bit: false,
2296        words: vec![value],
2297    }
2298}
2299
2300#[cfg(not(target_arch = "wasm32"))]
2301fn to_napi_injected_value(name: Option<String>, value: celox::InjectedValue) -> NapiInjectedValue {
2302    match value {
2303        celox::InjectedValue::Bits {
2304            words,
2305            mask_xz,
2306            width,
2307        } => NapiInjectedValue {
2308            name,
2309            bits: Some(BigInt {
2310                sign_bit: false,
2311                words,
2312            }),
2313            mask_xz: Some(BigInt {
2314                sign_bit: false,
2315                words: mask_xz,
2316            }),
2317            width: Some(width),
2318            string_value: None,
2319        },
2320        celox::InjectedValue::String(value) => NapiInjectedValue {
2321            name,
2322            bits: None,
2323            mask_xz: None,
2324            width: None,
2325            string_value: Some(value),
2326        },
2327        celox::InjectedValue::Unit => NapiInjectedValue {
2328            name,
2329            bits: None,
2330            mask_xz: None,
2331            width: None,
2332            string_value: None,
2333        },
2334    }
2335}
2336
2337#[cfg(not(target_arch = "wasm32"))]
2338fn from_napi_injected_value(
2339    value: NapiInjectedValue,
2340) -> std::result::Result<celox::InjectedValue, String> {
2341    if let Some(bits) = value.bits {
2342        if bits.sign_bit || value.mask_xz.as_ref().is_some_and(|mask| mask.sign_bit) {
2343            return Err("component callback values cannot be negative".into());
2344        }
2345        let width = value
2346            .width
2347            .ok_or_else(|| "component callback bit value has no width".to_string())?;
2348        return Ok(celox::InjectedValue::Bits {
2349            words: bits.words,
2350            mask_xz: value.mask_xz.map(|mask| mask.words).unwrap_or_default(),
2351            width,
2352        });
2353    }
2354    Ok(match value.string_value {
2355        Some(value) => celox::InjectedValue::String(value),
2356        None => celox::InjectedValue::Unit,
2357    })
2358}
2359
2360#[cfg(not(target_arch = "wasm32"))]
2361impl celox::InjectedComponentHandler for NapiInjectedHandler {
2362    fn call(
2363        &self,
2364        call: celox::InjectedCall,
2365    ) -> std::result::Result<celox::InjectedResult, String> {
2366        let (phase, method, args) = match call.hook {
2367            celox::InjectedHook::Create => ("create", None, Vec::new()),
2368            celox::InjectedHook::Init => ("init", None, Vec::new()),
2369            celox::InjectedHook::Reset => ("reset", None, Vec::new()),
2370            celox::InjectedHook::Clock => ("clock", None, Vec::new()),
2371            celox::InjectedHook::Finish => ("finish", None, Vec::new()),
2372            celox::InjectedHook::Method { name, args } => ("method", Some(name), args),
2373        };
2374        let request = NapiInjectedCall {
2375            instance: call.instance,
2376            phase: phase.into(),
2377            method,
2378            inputs: call
2379                .inputs
2380                .into_iter()
2381                .map(|value| to_napi_injected_value(Some(value.name), value.value))
2382                .collect(),
2383            params: call
2384                .params
2385                .into_iter()
2386                .map(|value| to_napi_injected_value(Some(value.name), value.value))
2387                .collect(),
2388            ports: call
2389                .ports
2390                .into_iter()
2391                .map(|port| NapiInjectedPort {
2392                    name: port.name,
2393                    direction: port.direction,
2394                    role: port.role,
2395                    width: port.width,
2396                })
2397                .collect(),
2398            args: args
2399                .into_iter()
2400                .map(|value| to_napi_injected_value(None, value))
2401                .collect(),
2402            cycle: napi_bigint(call.cycle),
2403            time: napi_bigint(call.time),
2404            seed: napi_bigint(call.seed),
2405            fired_clock: call.fired_clock,
2406            four_state: call.four_state,
2407        };
2408        let result = self
2409            .handler
2410            .borrow_back(&self.env)
2411            .and_then(|handler| handler.call(request))
2412            .map_err(|error| error.to_string())?;
2413        let outputs = result
2414            .outputs
2415            .unwrap_or_default()
2416            .into_iter()
2417            .map(|value| {
2418                let name = value
2419                    .name
2420                    .clone()
2421                    .ok_or_else(|| "component callback output has no name".to_string())?;
2422                Ok(celox::InjectedNamedValue {
2423                    name,
2424                    value: from_napi_injected_value(value)?,
2425                })
2426            })
2427            .collect::<std::result::Result<Vec<_>, String>>()?;
2428        Ok(celox::InjectedResult {
2429            outputs,
2430            return_value: result
2431                .return_value
2432                .map(from_napi_injected_value)
2433                .transpose()?,
2434            failures: result.failures.unwrap_or_default(),
2435            logs: result.logs.unwrap_or_default(),
2436            finish: result.finish.unwrap_or(false),
2437        })
2438    }
2439}
2440
2441#[cfg(not(target_arch = "wasm32"))]
2442fn injected_components(
2443    env: Env,
2444    definitions: Option<Vec<NapiInjectedComponent>>,
2445) -> Result<celox::InjectedComponents> {
2446    let mut components = celox::InjectedComponents::new();
2447    for definition in definitions.unwrap_or_default() {
2448        components
2449            .insert(
2450                definition.name,
2451                &definition.manifest,
2452                Arc::new(NapiInjectedHandler {
2453                    env,
2454                    handler: definition.handler,
2455                }),
2456            )
2457            .map_err(Error::from_reason)?;
2458    }
2459    Ok(components)
2460}
2461
2462/// Run a native testbench from Veryl source code.
2463///
2464/// Compiles the given sources and runs the `#[test]` module specified by `top`,
2465/// returning assertion results observed before that test finishes or stops on a
2466/// fatal failure.
2467#[cfg(not(target_arch = "wasm32"))]
2468#[napi]
2469pub fn run_test(
2470    env: Env,
2471    sources: Vec<NapiSourceFile>,
2472    top: String,
2473    options: Option<NapiOptions>,
2474    components: Option<Vec<NapiInjectedComponent>>,
2475) -> Result<NapiTestResult> {
2476    let opts = parse_options(&options)?;
2477    let mut src_pairs: Vec<(String, std::path::PathBuf)> = sources
2478        .into_iter()
2479        .map(|s| (s.content, std::path::PathBuf::from(s.path)))
2480        .collect();
2481    append_extra_source(&mut src_pairs, &opts.extra_source);
2482
2483    let source_refs: Vec<(&str, &std::path::Path)> = src_pairs
2484        .iter()
2485        .map(|(s, p)| (s.as_str(), p.as_path()))
2486        .collect();
2487    let builder = apply_options(celox::Simulator::from_sources(source_refs, &top), &opts)
2488        .with_injected_components(injected_components(env, components)?);
2489    let result = builder
2490        .run_test_detailed()
2491        .map_err(|e| Error::from_reason(format!("{e}")))?;
2492    Ok(convert_test_result(result))
2493}
2494
2495/// Run a Veryl native testbench against an external frontend artifact.
2496#[cfg(not(target_arch = "wasm32"))]
2497#[napi]
2498pub fn run_test_with_frontend_artifact(
2499    env: Env,
2500    artifact_json: String,
2501    sources: Vec<NapiSourceFile>,
2502    top: String,
2503    options: Option<NapiOptions>,
2504    components: Option<Vec<NapiInjectedComponent>>,
2505) -> Result<NapiTestResult> {
2506    let opts = parse_options(&options)?;
2507    let artifact = celox::FrontendArtifact::from_json(&artifact_json)
2508        .map_err(|error| Error::from_reason(error.to_string()))?;
2509    let mut src_pairs: Vec<(String, std::path::PathBuf)> = sources
2510        .into_iter()
2511        .map(|source| (source.content, std::path::PathBuf::from(source.path)))
2512        .collect();
2513    append_extra_source(&mut src_pairs, &opts.extra_source);
2514    let source_refs: Vec<(&str, &std::path::Path)> = src_pairs
2515        .iter()
2516        .map(|(source, path)| (source.as_str(), path.as_path()))
2517        .collect();
2518    let builder = apply_options(
2519        celox::Simulator::from_frontend_with_testbench(artifact, source_refs, &top),
2520        &opts,
2521    )
2522    .with_injected_components(injected_components(env, components)?);
2523    let result = builder
2524        .run_test_detailed()
2525        .map_err(|error| Error::from_reason(error.to_string()))?;
2526    Ok(convert_test_result(result))
2527}
2528
2529/// Run a native testbench from a Veryl project directory.
2530///
2531/// Searches upward from `project_path` for `Veryl.toml`, gathers all
2532/// `.veryl` source files, and runs the `#[test]` module specified by `top`.
2533#[cfg(not(target_arch = "wasm32"))]
2534#[napi]
2535pub fn run_test_from_project(
2536    env: Env,
2537    project_path: String,
2538    top: String,
2539    options: Option<NapiOptions>,
2540    components: Option<Vec<NapiInjectedComponent>>,
2541) -> Result<NapiTestResult> {
2542    let opts = parse_options(&options)?;
2543    let (mut sources, metadata, _celox_cfg) = load_project_sources(&project_path)?;
2544    append_extra_source(&mut sources, &opts.extra_source);
2545
2546    let source_refs: Vec<(&str, &std::path::Path)> = sources
2547        .iter()
2548        .map(|(s, p)| (s.as_str(), p.as_path()))
2549        .collect();
2550    let builder = apply_options(
2551        celox::Simulator::from_sources(source_refs, &top).with_metadata(metadata),
2552        &opts,
2553    )
2554    .with_injected_components(injected_components(env, components)?);
2555    let result = builder
2556        .run_test_detailed()
2557        .map_err(|e| Error::from_reason(format!("{e}")))?;
2558    Ok(convert_test_result(result))
2559}
2560
2561// ---------------------------------------------------------------------------
2562//  TypeScript type generation
2563// ---------------------------------------------------------------------------
2564
2565#[napi(object)]
2566pub struct NapiInjectedManifest {
2567    pub name: String,
2568    pub manifest: String,
2569}
2570
2571fn inject_analyzer_components(definitions: Option<Vec<NapiInjectedManifest>>) -> Result<()> {
2572    let definitions = definitions.unwrap_or_default();
2573    let names: Vec<_> = definitions
2574        .iter()
2575        .map(|definition| definition.name.as_str())
2576        .collect();
2577    veryl_analyzer::tb_component::insert_external_components(&names);
2578    for definition in definitions {
2579        let manifest =
2580            veryl_metadata::ComponentManifest::parse(&definition.manifest).ok_or_else(|| {
2581                Error::from_reason(format!(
2582                    "manifest of injected component `{}` cannot be parsed",
2583                    definition.name
2584                ))
2585            })?;
2586        veryl_analyzer::component_manifest_table::insert(
2587            veryl_parser::resource_table::insert_str(&definition.name),
2588            manifest,
2589        );
2590    }
2591    Ok(())
2592}
2593
2594/// Generate TypeScript type information as JSON for a Veryl project.
2595///
2596/// Equivalent to running `celox-gen-ts --json` from the given project directory.
2597#[napi]
2598pub fn gen_ts(
2599    project_path: String,
2600    components: Option<Vec<NapiInjectedManifest>>,
2601) -> Result<String> {
2602    use celox_ts_gen::{JsonModuleEntry, JsonOutput, generate_all};
2603
2604    let toml_path = Metadata::search_from(&project_path)
2605        .map_err(|e| Error::from_reason(format!("Could not find Veryl.toml: {e}")))?;
2606    let mut metadata = Metadata::load(&toml_path)
2607        .map_err(|e| Error::from_reason(format!("Failed to load Veryl.toml: {e}")))?;
2608
2609    let base_path = toml_path
2610        .parent()
2611        .unwrap_or(&toml_path)
2612        .to_string_lossy()
2613        .to_string();
2614
2615    let mut paths = metadata
2616        .paths::<std::path::PathBuf>(&[], true, true)
2617        .map_err(|e| Error::from_reason(format!("Failed to gather sources: {e}")))?;
2618    paths.retain(|path| !path.example);
2619
2620    // Append test-only sources declared in celox.toml
2621    let project_root = toml_path.parent().unwrap_or(&toml_path).to_path_buf();
2622    let celox_cfg = load_celox_config(&project_root)?;
2623    let prj_name = metadata.project.name.clone();
2624    for dir in &celox_cfg.test.sources {
2625        let dir_path = project_root.join(dir);
2626        if !dir_path.exists() {
2627            continue;
2628        }
2629        for src in walkdir(&dir_path)? {
2630            paths.push(PathSet {
2631                prj: prj_name.clone(),
2632                src: src.clone(),
2633                dst: src.with_extension("sv"),
2634                map: src.with_extension("map"),
2635                example: false,
2636            });
2637        }
2638    }
2639
2640    if let Some(exclude_set) = build_exclude_set(&celox_cfg)? {
2641        paths.retain(|p| !is_excluded(&p.src, &project_root, &exclude_set));
2642    }
2643
2644    if paths.is_empty() {
2645        return Err(Error::from_reason("No Veryl source files found"));
2646    }
2647
2648    // Parse and analyze pass 1
2649    symbol_table::clear();
2650    attribute_table::clear();
2651
2652    let analyzer = Analyzer::new(&metadata);
2653    inject_analyzer_components(components)?;
2654    let mut parsers = Vec::new();
2655    let mut all_warnings = Vec::new();
2656
2657    for path in &paths {
2658        let input = std::fs::read_to_string(&path.src)
2659            .map_err(|e| Error::from_reason(format!("{}: {e}", path.src.display())))?;
2660        let parser = Parser::parse(&input, &path.src)
2661            .map_err(|e| Error::from_reason(format!("Parse error: {e}")))?;
2662
2663        let results = analyzer.analyze_pass1(&path.prj, &parser.veryl);
2664        let real_errors: Vec<_> = results.iter().filter(|e| e.is_error()).collect();
2665        if !real_errors.is_empty() {
2666            return Err(Error::from_reason(format_errors_with_warnings(
2667                "analysis pass 1",
2668                &real_errors,
2669                &all_warnings,
2670            )));
2671        }
2672        all_warnings.extend(results.into_iter().filter(|e| !e.is_error()));
2673
2674        parsers.push((path.clone(), parser));
2675    }
2676
2677    let results = Analyzer::analyze_post_pass1();
2678    let real_errors: Vec<_> = results.iter().filter(|e| e.is_error()).collect();
2679    if !real_errors.is_empty() {
2680        return Err(Error::from_reason(format_errors_with_warnings(
2681            "post-pass 1 analysis",
2682            &real_errors,
2683            &all_warnings,
2684        )));
2685    }
2686    all_warnings.extend(results.into_iter().filter(|e| !e.is_error()));
2687
2688    // Pass 2: per-file IR → generate
2689
2690    // Compute all source file relative paths for embedding in generated JS.
2691    let base_normalized = base_path.replace('\\', "/");
2692    let all_source_files: Vec<String> = parsers
2693        .iter()
2694        .map(|(path, _)| {
2695            let src_normalized = path
2696                .src
2697                .to_string_lossy()
2698                .replace(r"\\?\", "")
2699                .replace('\\', "/");
2700            src_normalized
2701                .strip_prefix(&base_normalized)
2702                .unwrap_or(&src_normalized)
2703                .trim_start_matches('/')
2704                .to_string()
2705        })
2706        .collect();
2707    let source_file_refs: Vec<&str> = all_source_files.iter().map(|s| s.as_str()).collect();
2708
2709    let mut all_modules = Vec::new();
2710    let mut file_modules: HashMap<String, Vec<String>> = HashMap::default();
2711    let mut post_pass_ir = Ir::default();
2712
2713    for (i, (_path, parser)) in parsers.iter().enumerate() {
2714        let mut analyzer_context = Context::default();
2715        let mut ir = Ir::default();
2716        let results = analyzer.analyze_pass2(&parser.veryl, &mut analyzer_context, Some(&mut ir));
2717        let real_errors: Vec<_> = results.iter().filter(|e| e.is_error()).collect();
2718        if !real_errors.is_empty() {
2719            return Err(Error::from_reason(format_errors_with_warnings(
2720                "analysis pass 2",
2721                &real_errors,
2722                &all_warnings,
2723            )));
2724        }
2725        all_warnings.extend(results.into_iter().filter(|e| !e.is_error()));
2726
2727        let modules = generate_all(&ir, &source_file_refs);
2728        post_pass_ir.append(&mut ir);
2729        let source_file = all_source_files[i].clone();
2730
2731        let module_names: Vec<String> = modules.iter().map(|m| m.module_name.clone()).collect();
2732        if !module_names.is_empty() {
2733            file_modules.insert(source_file.clone(), module_names);
2734        }
2735
2736        for m in modules {
2737            all_modules.push(JsonModuleEntry {
2738                module_name: m.module_name,
2739                source_file: source_file.clone(),
2740                dts_content: m.dts_content,
2741                md_content: m.md_content,
2742                ports: m.ports,
2743                events: m.events,
2744                instances: m.instances,
2745                is_test: m.is_test,
2746            });
2747        }
2748    }
2749
2750    let results = Analyzer::analyze_post_pass2(&post_pass_ir);
2751    let real_errors: Vec<_> = results.iter().filter(|e| e.is_error()).collect();
2752    if !real_errors.is_empty() {
2753        return Err(Error::from_reason(format_errors_with_warnings(
2754            "post-pass 2 analysis",
2755            &real_errors,
2756            &all_warnings,
2757        )));
2758    }
2759    all_warnings.extend(results.into_iter().filter(|e| !e.is_error()));
2760
2761    // Sort for deterministic output
2762    all_modules.sort_by(|a, b| a.module_name.cmp(&b.module_name));
2763
2764    let warning_msgs: Vec<String> = all_warnings
2765        .iter()
2766        .map(|w| celox::render_diagnostic(w))
2767        .collect();
2768
2769    let all_sources: Vec<(String, String)> = parsers
2770        .iter()
2771        .map(|(p, _)| {
2772            let path_str = p.src.to_string_lossy().to_string();
2773            let content = std::fs::read_to_string(&p.src).unwrap_or_default();
2774            (path_str, content)
2775        })
2776        .collect();
2777
2778    let diagnostics: Vec<celox_ts_gen::JsonDiagnostic> = all_warnings
2779        .iter()
2780        .flat_map(|w| analyzer_error_to_diagnostics(w, &all_sources, false))
2781        .collect();
2782
2783    let output = JsonOutput {
2784        project_path: base_path,
2785        modules: all_modules,
2786        file_modules,
2787        warnings: warning_msgs,
2788        diagnostics,
2789    };
2790
2791    serde_json::to_string(&output)
2792        .map_err(|e| Error::from_reason(format!("Failed to serialize JSON: {e}")))
2793}
2794
2795/// Generate TypeScript type information from in-memory Veryl sources.
2796///
2797/// Like `gen_ts()` but does not require a Veryl.toml or filesystem access.
2798/// Works on both native and wasm32 targets.
2799#[napi]
2800pub fn gen_ts_from_source(
2801    sources: Vec<NapiSourceFile>,
2802    components: Option<Vec<NapiInjectedManifest>>,
2803) -> Result<String> {
2804    use celox_ts_gen::{JsonModuleEntry, JsonOutput, generate_all};
2805
2806    if sources.is_empty() {
2807        return Err(Error::from_reason("No source files provided"));
2808    }
2809
2810    let metadata = Metadata::create_default("playground")
2811        .map_err(|e| Error::from_reason(format!("Failed to create default metadata: {e}")))?;
2812
2813    // Parse and analyze pass 1
2814    symbol_table::clear();
2815    attribute_table::clear();
2816
2817    let analyzer = Analyzer::new(&metadata);
2818    inject_analyzer_components(components)?;
2819    let mut parsers = Vec::new();
2820    let mut all_warnings = Vec::new();
2821
2822    for src in &sources {
2823        let path = std::path::PathBuf::from(&src.path);
2824        let parser = Parser::parse(&src.content, &path)
2825            .map_err(|e| Error::from_reason(format!("Parse error: {e}")))?;
2826
2827        let prj = "playground".to_string();
2828        let results = analyzer.analyze_pass1(&prj, &parser.veryl);
2829        let real_errors: Vec<_> = results.iter().filter(|e| e.is_error()).collect();
2830        if !real_errors.is_empty() {
2831            return Err(Error::from_reason(format_errors_with_warnings(
2832                "analysis pass 1",
2833                &real_errors,
2834                &all_warnings,
2835            )));
2836        }
2837        all_warnings.extend(results.into_iter().filter(|e| !e.is_error()));
2838
2839        parsers.push((prj, path, parser));
2840    }
2841
2842    let results = Analyzer::analyze_post_pass1();
2843    let real_errors: Vec<_> = results.iter().filter(|e| e.is_error()).collect();
2844    if !real_errors.is_empty() {
2845        return Err(Error::from_reason(format_errors_with_warnings(
2846            "post-pass 1 analysis",
2847            &real_errors,
2848            &all_warnings,
2849        )));
2850    }
2851    all_warnings.extend(results.into_iter().filter(|e| !e.is_error()));
2852
2853    // Pass 2: per-file IR → generate
2854    let all_source_files: Vec<String> = parsers
2855        .iter()
2856        .map(|(_, p, _)| p.to_string_lossy().replace('\\', "/"))
2857        .collect();
2858    let source_file_refs: Vec<&str> = all_source_files.iter().map(|s| s.as_str()).collect();
2859
2860    let mut all_modules = Vec::new();
2861    let mut file_modules: HashMap<String, Vec<String>> = HashMap::default();
2862    let mut post_pass_ir = Ir::default();
2863
2864    for (i, (_prj, _path, parser)) in parsers.iter().enumerate() {
2865        let mut analyzer_context = Context::default();
2866        let mut ir = Ir::default();
2867        let results = analyzer.analyze_pass2(&parser.veryl, &mut analyzer_context, Some(&mut ir));
2868        let real_errors: Vec<_> = results.iter().filter(|e| e.is_error()).collect();
2869        if !real_errors.is_empty() {
2870            return Err(Error::from_reason(format_errors_with_warnings(
2871                "analysis pass 2",
2872                &real_errors,
2873                &all_warnings,
2874            )));
2875        }
2876        all_warnings.extend(results.into_iter().filter(|e| !e.is_error()));
2877
2878        let modules = generate_all(&ir, &source_file_refs);
2879        post_pass_ir.append(&mut ir);
2880        let source_file = all_source_files[i].clone();
2881
2882        let module_names: Vec<String> = modules.iter().map(|m| m.module_name.clone()).collect();
2883        if !module_names.is_empty() {
2884            file_modules.insert(source_file.clone(), module_names);
2885        }
2886
2887        for m in modules {
2888            all_modules.push(JsonModuleEntry {
2889                module_name: m.module_name,
2890                source_file: source_file.clone(),
2891                dts_content: m.dts_content,
2892                md_content: m.md_content,
2893                ports: m.ports,
2894                events: m.events,
2895                instances: m.instances,
2896                is_test: m.is_test,
2897            });
2898        }
2899    }
2900
2901    let results = Analyzer::analyze_post_pass2(&post_pass_ir);
2902    let real_errors: Vec<_> = results.iter().filter(|e| e.is_error()).collect();
2903    if !real_errors.is_empty() {
2904        return Err(Error::from_reason(format_errors_with_warnings(
2905            "post-pass 2 analysis",
2906            &real_errors,
2907            &all_warnings,
2908        )));
2909    }
2910    all_warnings.extend(results.into_iter().filter(|e| !e.is_error()));
2911
2912    // Sort for deterministic output
2913    all_modules.sort_by(|a, b| a.module_name.cmp(&b.module_name));
2914
2915    let warning_msgs: Vec<String> = all_warnings
2916        .iter()
2917        .map(|w| celox::render_diagnostic(w))
2918        .collect();
2919
2920    let all_sources: Vec<(String, String)> = sources
2921        .iter()
2922        .map(|s| (s.path.clone(), s.content.clone()))
2923        .collect();
2924
2925    let diagnostics: Vec<celox_ts_gen::JsonDiagnostic> = all_warnings
2926        .iter()
2927        .flat_map(|w| analyzer_error_to_diagnostics(w, &all_sources, false))
2928        .collect();
2929
2930    let output = JsonOutput {
2931        project_path: String::new(),
2932        modules: all_modules,
2933        file_modules,
2934        warnings: warning_msgs,
2935        diagnostics,
2936    };
2937
2938    serde_json::to_string(&output)
2939        .map_err(|e| Error::from_reason(format!("Failed to serialize JSON: {e}")))
2940}
2941
2942#[cfg(all(test, not(target_arch = "wasm32")))]
2943mod tests {
2944    use super::*;
2945
2946    fn default_opts() -> ParsedOptions {
2947        ParsedOptions {
2948            common: ParsedOptionsCommon {
2949                four_state: false,
2950                optimize_options: celox::OptimizeOptions::all(),
2951                vcd: None,
2952                false_loops: vec![],
2953                true_loops: vec![],
2954                clock_type: None,
2955                reset_type: None,
2956                extra_source: None,
2957                parameters: vec![],
2958            },
2959            cranelift_options: celox::CraneliftOptions::default(),
2960            dead_store_policy: celox::DeadStorePolicy::Off,
2961        }
2962    }
2963
2964    fn make_sources(pairs: &[(&str, &str)]) -> Vec<(String, std::path::PathBuf)> {
2965        pairs
2966            .iter()
2967            .map(|(content, path)| (content.to_string(), std::path::PathBuf::from(path)))
2968            .collect()
2969    }
2970
2971    #[test]
2972    fn simulator_handle_derives_four_state_metadata_from_layout() {
2973        let source = "module Top (a: input logic<1>) {}";
2974        let path = std::path::Path::new("top.veryl");
2975
2976        for four_state in [false, true] {
2977            let simulator = celox::Simulator::from_sources(vec![(source, path)], "Top")
2978                .four_state(four_state)
2979                .build()
2980                .unwrap();
2981            let handle = NativeSimulatorHandle::from_simulator(simulator, None).unwrap();
2982            let layout: serde_json::Value = serde_json::from_str(&handle.layout_json()).unwrap();
2983
2984            assert_eq!(layout["a"]["is_4state"].as_bool(), Some(four_state));
2985        }
2986    }
2987
2988    #[test]
2989    fn normal_project_loading_excludes_example_sources() {
2990        let project = tempfile::tempdir().unwrap();
2991        std::fs::create_dir(project.path().join("src")).unwrap();
2992        std::fs::create_dir(project.path().join("examples")).unwrap();
2993        std::fs::write(
2994            project.path().join("Veryl.toml"),
2995            "[project]\nname = \"example_filter\"\nversion = \"0.1.0\"\n",
2996        )
2997        .unwrap();
2998        std::fs::write(project.path().join("src/top.veryl"), "module Top () {}\n").unwrap();
2999        std::fs::write(
3000            project.path().join("examples/demo.veryl"),
3001            "module Demo () {}\n",
3002        )
3003        .unwrap();
3004
3005        let (sources, _, _) = load_project_sources(project.path().to_str().unwrap()).unwrap();
3006        let source_names = sources
3007            .iter()
3008            .map(|(_, path)| path.file_name().unwrap().to_str().unwrap())
3009            .collect::<Vec<_>>();
3010        assert_eq!(source_names, ["top.veryl"]);
3011    }
3012
3013    #[test]
3014    fn same_inputs_produce_same_key() {
3015        let src = make_sources(&[("module Top {}", "a.veryl")]);
3016        let opts = default_opts();
3017        let k1 = build_cache_key(&src, "Top", &opts, None);
3018        let k2 = build_cache_key(&src, "Top", &opts, None);
3019        assert_eq!(k1, k2);
3020    }
3021
3022    #[test]
3023    fn high_index_sir_pass_changes_cache_key_without_bit_packing() {
3024        let src = make_sources(&[("module Top {}", "a.veryl")]);
3025        let enabled = default_opts();
3026        let mut disabled = default_opts();
3027        disabled.common.optimize_options = disabled
3028            .common
3029            .optimize_options
3030            .clone()
3031            .disable(celox::SirPass::IdentityStoreBypass);
3032
3033        assert_ne!(
3034            build_cache_key(&src, "Top", &enabled, None),
3035            build_cache_key(&src, "Top", &disabled, None)
3036        );
3037    }
3038
3039    #[test]
3040    fn native_memory_width_changes_cache_key() {
3041        let src = make_sources(&[("module Top {}", "a.veryl")]);
3042        let mut narrow = default_opts();
3043        narrow.common.optimize_options = narrow
3044            .common
3045            .optimize_options
3046            .clone()
3047            .with_max_native_memory_width(64);
3048        let mut wide = default_opts();
3049        wide.common.optimize_options = wide
3050            .common
3051            .optimize_options
3052            .clone()
3053            .with_max_native_memory_width(128);
3054
3055        assert_ne!(
3056            build_cache_key(&src, "Top", &narrow, None),
3057            build_cache_key(&src, "Top", &wide, None)
3058        );
3059    }
3060
3061    #[test]
3062    fn different_source_content_different_key() {
3063        let s1 = make_sources(&[("module A {}", "a.veryl")]);
3064        let s2 = make_sources(&[("module B {}", "a.veryl")]);
3065        let opts = default_opts();
3066        assert_ne!(
3067            build_cache_key(&s1, "Top", &opts, None),
3068            build_cache_key(&s2, "Top", &opts, None),
3069        );
3070    }
3071
3072    #[test]
3073    fn different_source_path_different_key() {
3074        let s1 = make_sources(&[("module A {}", "a.veryl")]);
3075        let s2 = make_sources(&[("module A {}", "b.veryl")]);
3076        let opts = default_opts();
3077        assert_ne!(
3078            build_cache_key(&s1, "Top", &opts, None),
3079            build_cache_key(&s2, "Top", &opts, None),
3080        );
3081    }
3082
3083    #[test]
3084    fn different_top_different_key() {
3085        let src = make_sources(&[("module Top {}", "a.veryl")]);
3086        let opts = default_opts();
3087        assert_ne!(
3088            build_cache_key(&src, "Top", &opts, None),
3089            build_cache_key(&src, "Other", &opts, None),
3090        );
3091    }
3092
3093    #[test]
3094    fn four_state_differs() {
3095        let src = make_sources(&[("module Top {}", "a.veryl")]);
3096        let mut o1 = default_opts();
3097        let mut o2 = default_opts();
3098        o1.common.four_state = false;
3099        o2.common.four_state = true;
3100        assert_ne!(
3101            build_cache_key(&src, "Top", &o1, None),
3102            build_cache_key(&src, "Top", &o2, None),
3103        );
3104    }
3105
3106    #[test]
3107    fn optimize_options_differs() {
3108        let src = make_sources(&[("module Top {}", "a.veryl")]);
3109        let mut o1 = default_opts();
3110        let mut o2 = default_opts();
3111        o1.common.optimize_options = celox::OptimizeOptions::all();
3112        o2.common.optimize_options = celox::OptimizeOptions::none();
3113        assert_ne!(
3114            build_cache_key(&src, "Top", &o1, None),
3115            build_cache_key(&src, "Top", &o2, None),
3116        );
3117    }
3118
3119    #[test]
3120    fn cranelift_opt_level_differs() {
3121        let src = make_sources(&[("module Top {}", "a.veryl")]);
3122        let mut o1 = default_opts();
3123        let mut o2 = default_opts();
3124        o1.cranelift_options.opt_level = celox::CraneliftOptLevel::Speed;
3125        o2.cranelift_options.opt_level = celox::CraneliftOptLevel::None;
3126        assert_ne!(
3127            build_cache_key(&src, "Top", &o1, None),
3128            build_cache_key(&src, "Top", &o2, None),
3129        );
3130    }
3131
3132    #[test]
3133    fn regalloc_algorithm_differs() {
3134        let src = make_sources(&[("module Top {}", "a.veryl")]);
3135        let mut o1 = default_opts();
3136        let mut o2 = default_opts();
3137        o1.cranelift_options.regalloc_algorithm = celox::RegallocAlgorithm::Backtracking;
3138        o2.cranelift_options.regalloc_algorithm = celox::RegallocAlgorithm::SinglePass;
3139        assert_ne!(
3140            build_cache_key(&src, "Top", &o1, None),
3141            build_cache_key(&src, "Top", &o2, None),
3142        );
3143    }
3144
3145    #[test]
3146    fn dead_store_policy_differs() {
3147        let src = make_sources(&[("module Top {}", "a.veryl")]);
3148        let mut o1 = default_opts();
3149        let mut o2 = default_opts();
3150        o1.dead_store_policy = celox::DeadStorePolicy::Off;
3151        o2.dead_store_policy = celox::DeadStorePolicy::PreserveTopPorts;
3152        assert_ne!(
3153            build_cache_key(&src, "Top", &o1, None),
3154            build_cache_key(&src, "Top", &o2, None),
3155        );
3156    }
3157
3158    #[test]
3159    fn clock_type_differs() {
3160        let src = make_sources(&[("module Top {}", "a.veryl")]);
3161        let mut o1 = default_opts();
3162        let mut o2 = default_opts();
3163        o1.common.clock_type = None;
3164        o2.common.clock_type = Some(celox::ClockType::NegEdge);
3165        assert_ne!(
3166            build_cache_key(&src, "Top", &o1, None),
3167            build_cache_key(&src, "Top", &o2, None),
3168        );
3169    }
3170
3171    #[test]
3172    fn reset_type_differs() {
3173        let src = make_sources(&[("module Top {}", "a.veryl")]);
3174        let mut o1 = default_opts();
3175        let mut o2 = default_opts();
3176        o1.common.reset_type = None;
3177        o2.common.reset_type = Some(celox::ResetType::SyncHigh);
3178        assert_ne!(
3179            build_cache_key(&src, "Top", &o1, None),
3180            build_cache_key(&src, "Top", &o2, None),
3181        );
3182    }
3183
3184    #[test]
3185    fn parameters_differ() {
3186        let src = make_sources(&[("module Top {}", "a.veryl")]);
3187        let mut o1 = default_opts();
3188        let mut o2 = default_opts();
3189        o1.common.parameters = vec![("WIDTH".into(), 8)];
3190        o2.common.parameters = vec![("WIDTH".into(), 16)];
3191        assert_ne!(
3192            build_cache_key(&src, "Top", &o1, None),
3193            build_cache_key(&src, "Top", &o2, None),
3194        );
3195    }
3196
3197    #[test]
3198    fn source_order_independent() {
3199        let s1 = make_sources(&[("aaa", "a.veryl"), ("bbb", "b.veryl")]);
3200        let s2 = make_sources(&[("bbb", "b.veryl"), ("aaa", "a.veryl")]);
3201        let opts = default_opts();
3202        assert_eq!(
3203            build_cache_key(&s1, "Top", &opts, None),
3204            build_cache_key(&s2, "Top", &opts, None),
3205        );
3206    }
3207
3208    #[test]
3209    fn non_compilation_options_ignored() {
3210        let src = make_sources(&[("module Top {}", "a.veryl")]);
3211        let mut o1 = default_opts();
3212        let mut o2 = default_opts();
3213        // VCD path doesn't affect compilation
3214        o1.common.vcd = None;
3215        o2.common.vcd = Some("/tmp/dump.vcd".into());
3216        assert_eq!(
3217            build_cache_key(&src, "Top", &o1, None),
3218            build_cache_key(&src, "Top", &o2, None),
3219        );
3220    }
3221
3222    #[test]
3223    fn false_loops_differ() {
3224        let src = make_sources(&[("module Top {}", "a.veryl")]);
3225        let mut o1 = default_opts();
3226        let mut o2 = default_opts();
3227        o1.common.false_loops = vec![];
3228        o2.common.false_loops = vec![((vec![], vec!["a".into()]), (vec![], vec!["b".into()]))];
3229        assert_ne!(
3230            build_cache_key(&src, "Top", &o1, None),
3231            build_cache_key(&src, "Top", &o2, None),
3232        );
3233    }
3234
3235    #[test]
3236    fn true_loops_differ() {
3237        let src = make_sources(&[("module Top {}", "a.veryl")]);
3238        let mut o1 = default_opts();
3239        let mut o2 = default_opts();
3240        o1.common.true_loops = vec![];
3241        o2.common.true_loops = vec![((vec![], vec!["x".into()]), (vec![], vec!["y".into()]), 4)];
3242        assert_ne!(
3243            build_cache_key(&src, "Top", &o1, None),
3244            build_cache_key(&src, "Top", &o2, None),
3245        );
3246    }
3247
3248    #[test]
3249    fn metadata_clock_reset_differs() {
3250        let src = make_sources(&[("module Top {}", "a.veryl")]);
3251        let opts = default_opts();
3252
3253        let mut m1 = Metadata::create_default("prj").unwrap();
3254        let mut m2 = Metadata::create_default("prj").unwrap();
3255        m1.build.clock_type = celox::ClockType::PosEdge;
3256        m2.build.clock_type = celox::ClockType::NegEdge;
3257        assert_ne!(
3258            build_cache_key(&src, "Top", &opts, Some(&m1)),
3259            build_cache_key(&src, "Top", &opts, Some(&m2)),
3260        );
3261
3262        let mut m3 = Metadata::create_default("prj").unwrap();
3263        let mut m4 = Metadata::create_default("prj").unwrap();
3264        m3.build.reset_type = celox::ResetType::AsyncLow;
3265        m4.build.reset_type = celox::ResetType::SyncHigh;
3266        assert_ne!(
3267            build_cache_key(&src, "Top", &opts, Some(&m3)),
3268            build_cache_key(&src, "Top", &opts, Some(&m4)),
3269        );
3270    }
3271
3272    #[test]
3273    fn no_metadata_vs_metadata_differs() {
3274        let src = make_sources(&[("module Top {}", "a.veryl")]);
3275        let opts = default_opts();
3276        let m = Metadata::create_default("prj").unwrap();
3277        // No metadata vs with metadata should differ (metadata adds clock/reset info)
3278        assert_ne!(
3279            build_cache_key(&src, "Top", &opts, None),
3280            build_cache_key(&src, "Top", &opts, Some(&m)),
3281        );
3282    }
3283}