wavepeek 0.4.0

Command-line tool for RTL waveform inspection with deterministic machine-friendly output.
Documentation
use serde_json::Value;
use wavepeek::expr::{EventEvalFrame, event_matches_at};

mod common;
use common::expr_cases::{
    ManifestEntrypoint, NegativeCase, PositiveCase, assert_negative_diagnostic,
    load_negative_manifest, load_positive_manifest, run_negative_case,
};
use common::expr_runtime::{
    InMemoryExprHost, SignalFixture, SignalSample, TypeFixture, bind_event_expr,
    collect_bound_event_matches, collect_event_matches,
};
use common::{fixture_path, wavepeek_cmd};

fn load_positive_cases() -> Vec<common::expr_cases::EventEvalCase> {
    load_positive_manifest("event_runtime_positive_manifest.json")
        .cases
        .into_iter()
        .map(|case| match case {
            PositiveCase::EventEval(case) => case,
            other => panic!("event runtime suite only supports event_eval cases, got {other:?}"),
        })
        .collect()
}

fn load_negative_cases() -> Vec<NegativeCase> {
    load_negative_manifest("event_runtime_negative_manifest.json").cases
}

fn bit_signal(name: &str, width: u32, samples: &[(u64, &str)]) -> SignalFixture {
    SignalFixture {
        name: name.to_string(),
        ty: TypeFixture {
            kind: "bit_vector".to_string(),
            integer_like_kind: None,
            storage: if width > 1 {
                "packed_vector".to_string()
            } else {
                "scalar".to_string()
            },
            width,
            is_four_state: true,
            is_signed: false,
            enum_type_id: None,
            enum_labels: None,
        },
        samples: samples
            .iter()
            .map(|(timestamp, bits)| SignalSample {
                timestamp: *timestamp,
                bits: Some((*bits).to_string()),
                label: None,
                real: None,
                string: None,
            })
            .collect(),
        event_timestamps: vec![],
    }
}

#[test]
fn event_runtime_positive_manifest_matches() {
    for case in load_positive_cases() {
        let host = InMemoryExprHost::from_fixtures(case.signals.as_slice());
        let matches = collect_event_matches(
            case.source.as_str(),
            &host,
            case.tracked_signals.as_slice(),
            case.probes.as_slice(),
        )
        .unwrap_or_else(|error| panic!("{} should evaluate: {error:?}", case.name));
        assert_eq!(matches, case.matches, "case '{}'", case.name);
    }
}

#[test]
fn wildcard_tracked_set_binding_comes_from_host_context() {
    let host = InMemoryExprHost::from_fixtures(
        [
            bit_signal("clk", 1, &[(0, "0"), (5, "1"), (10, "1"), (15, "0")]),
            bit_signal("data", 1, &[(0, "0"), (5, "0"), (10, "1"), (15, "1")]),
        ]
        .as_slice(),
    );
    let expr = bind_event_expr("*", &host).expect("wildcard should bind once");
    let probes = [0_u64, 5, 10, 15];

    let clk_only = host.tracked_handles(&["clk".to_string()]);
    let clk_matches = collect_bound_event_matches(&expr, &host, clk_only.as_slice(), &probes)
        .expect("wildcard should evaluate for clk-only tracked set");
    assert_eq!(
        clk_matches,
        vec![5, 15],
        "clk-only tracked set should see clk changes"
    );

    let data_only = host.tracked_handles(&["data".to_string()]);
    let data_matches = collect_bound_event_matches(&expr, &host, data_only.as_slice(), &probes)
        .expect("wildcard should evaluate for data-only tracked set");
    assert_eq!(
        data_matches,
        vec![10],
        "data-only tracked set should see data changes"
    );

    let both = host.tracked_handles(&["clk".to_string(), "data".to_string()]);
    let both_matches = collect_bound_event_matches(&expr, &host, both.as_slice(), &probes)
        .expect("wildcard should evaluate for combined tracked set");
    assert_eq!(
        both_matches,
        vec![5, 10, 15],
        "combined tracked set should see both changes"
    );
}

#[test]
fn event_runtime_negative_manifest_matches_snapshots() {
    for case in load_negative_cases() {
        if matches!(case.entrypoint, ManifestEntrypoint::Logical) {
            panic!("event runtime negative suite does not support logical entrypoints");
        };
        let diagnostic = run_negative_case(&case);

        assert_negative_diagnostic("event_runtime_negative_manifest.json", &case, &diagnostic);
    }
}

#[test]
fn event_runtime_short_circuit_holds() {
    let mut host = InMemoryExprHost::from_fixtures(
        [
            bit_signal("clk", 1, &[(0, "0"), (5, "1")]),
            bit_signal("rhs_sig", 1, &[(0, "1")]),
            bit_signal("x_sig", 1, &[(0, "x")]),
        ]
        .as_slice(),
    );
    host.enable_sample_trap("rhs_sig");
    let tracked = host.tracked_handles(&["clk".to_string()]);
    let frame = EventEvalFrame {
        timestamp: 5,
        previous_timestamp: Some(0),
        tracked_signals: tracked.as_slice(),
    };

    let before_and = host.sample_count("rhs_sig");
    let short_and =
        bind_event_expr("posedge clk iff (0 && rhs_sig)", &host).expect("0 && rhs_sig should bind");
    let matched =
        event_matches_at(&short_and, &host, &frame).expect("0 && rhs_sig should evaluate");
    let after_and = host.sample_count("rhs_sig");
    assert!(!matched, "0 && rhs_sig must gate event to false");
    assert_eq!(
        after_and, before_and,
        "rhs_sig must not be sampled for 0 && rhs_sig"
    );

    let before_or = host.sample_count("rhs_sig");
    let short_or =
        bind_event_expr("posedge clk iff (1 || rhs_sig)", &host).expect("1 || rhs_sig should bind");
    let matched = event_matches_at(&short_or, &host, &frame).expect("1 || rhs_sig should evaluate");
    let after_or = host.sample_count("rhs_sig");
    assert!(matched, "1 || rhs_sig must gate event to true");
    assert_eq!(
        after_or, before_or,
        "rhs_sig must not be sampled for 1 || rhs_sig"
    );

    let x_and_zero =
        bind_event_expr("posedge clk iff (x_sig && 0)", &host).expect("x_sig && 0 should bind");
    let matched = event_matches_at(&x_and_zero, &host, &frame).expect("x_sig && 0 should evaluate");
    assert!(!matched, "x && 0 must evaluate to 0 and suppress event");

    let x_or_one =
        bind_event_expr("posedge clk iff (x_sig || 1)", &host).expect("x_sig || 1 should bind");
    let matched = event_matches_at(&x_or_one, &host, &frame).expect("x_sig || 1 should evaluate");
    assert!(matched, "x || 1 must evaluate to 1 and allow event");

    let x_and_one =
        bind_event_expr("posedge clk iff (x_sig && 1)", &host).expect("x_sig && 1 should bind");
    let matched = event_matches_at(&x_and_one, &host, &frame).expect("x_sig && 1 should evaluate");
    assert!(!matched, "x && 1 must evaluate to x and suppress event");
}

#[test]
fn event_runtime_shadow_parity_matches_change_cli_for_non_iff_surface() {
    let fixture = fixture_path("change_edge_cases.vcd");
    let fixture = fixture.to_string_lossy().into_owned();

    let host = InMemoryExprHost::from_fixtures(
        [
            bit_signal(
                "clk",
                1,
                &[
                    (0, "0"),
                    (5, "1"),
                    (10, "0"),
                    (15, "1"),
                    (20, "x"),
                    (25, "0"),
                    (30, "1"),
                ],
            ),
            bit_signal("clk1", 1, &[(0, "0"), (10, "1"), (20, "0"), (30, "1")]),
            bit_signal("clk2", 1, &[(0, "0"), (15, "1"), (25, "0"), (30, "1")]),
        ]
        .as_slice(),
    );

    let probes = [0_u64, 5, 10, 15, 20, 25, 30];
    for (source, cli_signals, tracked) in [
        (
            "*",
            "clk1,clk2",
            vec!["clk1".to_string(), "clk2".to_string()],
        ),
        ("clk", "clk", vec!["clk".to_string()]),
        ("posedge clk", "clk", vec!["clk".to_string()]),
        ("negedge clk", "clk", vec!["clk".to_string()]),
        ("edge clk", "clk", vec!["clk".to_string()]),
        (
            "posedge clk1, posedge clk2",
            "clk1,clk2",
            vec!["clk1".to_string(), "clk2".to_string()],
        ),
        (
            "posedge clk1 or posedge clk2",
            "clk1,clk2",
            vec!["clk1".to_string(), "clk2".to_string()],
        ),
    ] {
        let typed = bind_event_expr(source, &host).expect("typed source should bind");
        let tracked_handles = host.tracked_handles(tracked.as_slice());
        let typed_matches =
            collect_bound_event_matches(&typed, &host, tracked_handles.as_slice(), &probes)
                .expect("typed evaluation should succeed");

        let cli_matches = change_cli_matches(&fixture, source, cli_signals);
        assert_eq!(typed_matches, cli_matches, "source '{source}'");
    }
}

fn change_cli_matches(fixture: &str, source: &str, signals: &str) -> Vec<u64> {
    let output = wavepeek_cmd()
        .args([
            "change",
            "--waves",
            fixture,
            "--from",
            "0ns",
            "--to",
            "30ns",
            "--scope",
            "top",
            "--signals",
            signals,
            "--on",
            source,
            "--max",
            "unlimited",
            "--json",
        ])
        .output()
        .expect("change command should execute");

    assert!(
        output.status.success(),
        "change command must succeed for source '{source}'"
    );

    let payload: Value = serde_json::from_slice(output.stdout.as_slice())
        .expect("change output should be valid JSON");
    payload["data"]
        .as_array()
        .expect("change data must be an array")
        .iter()
        .map(|row| {
            let token = row["time"]
                .as_str()
                .expect("change row time must be string");
            token
                .strip_suffix("ns")
                .unwrap_or(token)
                .parse::<u64>()
                .expect("change row time must be integer dump ticks")
        })
        .collect()
}