diff --git a/vw-cli/src/main.rs b/vw-cli/src/main.rs index ce4386b..c6df31d 100644 --- a/vw-cli/src/main.rs +++ b/vw-cli/src/main.rs @@ -2,12 +2,13 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. -use camino::Utf8PathBuf; +use camino::{Utf8Path, Utf8PathBuf}; use clap::{Parser, Subcommand, ValueEnum}; use colored::*; use std::collections::HashSet; use std::fmt; use std::process; +use vw_lib::resolve_test_selection; use vw_lib::{ add_dependency_with_token, clear_cache, extract_hostname_from_repo_url, @@ -93,12 +94,21 @@ enum Commands { DepsToTcl, #[command(about = "Run testbench using NVC")] Test { - #[arg(help = "Name of the testbench entity to run")] + #[arg(help = "Name of the testbench entity to run", group = "target")] testbench: Option, #[arg(long, help = "VHDL standard", default_value_t = CliVhdlStandard::Vhdl2019)] std: CliVhdlStandard, #[arg(long, help = "List all available testbenches")] list: bool, + #[arg(long, help = "List test groups")] + list_groups: bool, + #[arg( + long = "group", + value_name = "NAME", + help = "Run all testbenches in the named group (repeatable)", + group = "target" + )] + groups: Vec, #[arg( long, help = "Enable recursive search when looking for testbenches" @@ -114,24 +124,64 @@ enum Commands { long, value_delimiter = ',', help = "Runtime flags to pass to NVC (comma-separated or use multiple times)", - requires = "testbench" + requires = "target" )] runtime_flags: Vec, #[arg( long, help = "Build Rust library for testbench before running", - requires = "testbench" + requires = "target" )] build_rust: bool, #[arg( long, help = "Generate/regenerate mixed-signal scaffolding from mist.toml", - requires = "testbench" + requires = "target" )] scaffold: bool, }, } +async fn run_single_testbench( + cwd: &Utf8Path, + testbench_name: String, + std: VhdlStandard, + recurse: bool, + runtime_flags: &[String], + build_rust: bool, + scaffold: bool, +) -> vw_lib::Result<()> { + println!("Running testbench: {}", testbench_name.cyan()); + run_testbench( + cwd, + testbench_name.clone(), + std, + recurse, + runtime_flags, + build_rust, + scaffold, + ) + .await?; + if scaffold { + println!( + "{} Scaffolding generated for '{}'", + "✓".bright_green(), + testbench_name + ); + } else { + println!( + "{} Testbench '{}' completed successfully!", + "✓".bright_green(), + testbench_name + ); + println!( + "Waveform saved to: {}", + format!("{testbench_name}.fst").cyan() + ); + } + Ok(()) +} + /// Helper function to get access credentials for a repository URL from netrc if available async fn get_access_credentials_for_repo( repo_url: &str, @@ -352,12 +402,22 @@ async fn main() { testbench, std, list, + list_groups, + groups, recurse, ignore, runtime_flags, build_rust, scaffold, } => { + let config = match load_workspace_config(&cwd) { + Ok(c) => c, + Err(e) => { + eprintln!("{} {e}", "error:".bright_red()); + process::exit(1); + } + }; + if list { let bench_dir = cwd.join("bench"); if !bench_dir.exists() { @@ -407,11 +467,63 @@ async fn main() { } } } + } else if list_groups { + if config.test_groups.is_empty() { + println!("No test groups defined in vw.toml"); + } else { + println!("Test groups:"); + let mut sorted: Vec<_> = + config.test_groups.iter().collect(); + sorted.sort_by_key(|(name, _)| name.as_str()); + for (name, entries) in sorted { + println!(" {}", name.cyan()); + for entry in entries { + println!(" {}", entry.bright_black()); + } + } + } + } else if !groups.is_empty() { + let ignore_set: HashSet = ignore.into_iter().collect(); + let selectors: Vec = + groups.iter().map(|g| format!("group:{g}")).collect(); + let test_names = match resolve_test_selection( + &selectors, + &cwd, + &config.test_groups, + &ignore_set, + ) { + Ok(names) => names, + Err(e) => { + eprintln!("{} {e}", "error:".bright_red()); + process::exit(1); + } + }; + let mut sorted: Vec = test_names.into_iter().collect(); + sorted.sort(); + println!("Resolved {} testbench(es):", sorted.len()); + for name in &sorted { + println!(" {}", name.cyan()); + } + for test in sorted { + if let Err(e) = run_single_testbench( + &cwd, + test, + std.into(), + true, + &runtime_flags, + build_rust, + scaffold, + ) + .await + { + eprintln!("{} {e}", "error:".bright_red()); + process::exit(1); + } + } } else if let Some(testbench_name) = testbench { - println!("Running testbench: {}", testbench_name.cyan()); - match run_testbench( + if let Err(e) = run_single_testbench( &cwd, - testbench_name.clone(), + testbench_name, std.into(), recurse, &runtime_flags, @@ -420,29 +532,8 @@ async fn main() { ) .await { - Ok(()) => { - if scaffold { - println!( - "{} Scaffolding generated for '{}'", - "✓".bright_green(), - testbench_name - ); - } else { - println!( - "{} Testbench '{}' completed successfully!", - "✓".bright_green(), - testbench_name - ); - println!( - "Waveform saved to: {}", - format!("{testbench_name}.fst").cyan() - ); - } - } - Err(e) => { - eprintln!("{} {e}", "error:".bright_red()); - process::exit(1); - } + eprintln!("{} {e}", "error:".bright_red()); + process::exit(1); } } else { eprintln!( diff --git a/vw-lib/src/lib.rs b/vw-lib/src/lib.rs index 7e3ab38..18efcd7 100644 --- a/vw-lib/src/lib.rs +++ b/vw-lib/src/lib.rs @@ -32,6 +32,8 @@ use std::collections::{hash_map::Entry, HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use std::{fmt, fs}; +use glob::Pattern; + use camino::{Utf8Path, Utf8PathBuf}; use serde::{Deserialize, Serialize}; use vhdl_lang::{VHDLParser, VHDLStandard}; @@ -43,6 +45,7 @@ use petgraph::{ use crate::mapping::{FileData, SymbolKind, VwSymbol, VwSymbolFinder}; use crate::nvc_helpers::{run_nvc_analysis, run_nvc_elab, run_nvc_sim}; +use crate::sim::find_mist_configs; use crate::visitor::walk_design_file; pub mod mapping; @@ -63,6 +66,7 @@ pub enum VwError { Git { message: String }, FileSystem { message: String }, Testbench { message: String }, + TestGroup { message: String }, NvcSimulation { command: String }, NvcElab { command: String }, NvcAnalysis { library: String, command: String }, @@ -143,6 +147,9 @@ impl fmt::Display for VwError { VwError::Testbench { message } => { write!(f, "Testbench error: {message}") } + VwError::TestGroup { message } => { + write!(f, "Test group error: {message}") + } VwError::Io(e) => write!(f, "IO error: {e}"), VwError::Serialization(e) => write!(f, "Serialization error: {e}"), VwError::Deserialization(e) => { @@ -192,6 +199,8 @@ pub struct WorkspaceConfig { pub dependencies: HashMap, #[serde(default)] pub tools: Option, + #[serde(default, rename = "test-groups")] + pub test_groups: HashMap>, } #[derive(Debug, Deserialize, Serialize)] @@ -453,6 +462,7 @@ pub fn init_workspace(workspace_dir: &Utf8Path, name: String) -> Result<()> { }, dependencies: HashMap::new(), tools: None, + test_groups: HashMap::new(), }; save_workspace_config(workspace_dir, &config)?; @@ -646,6 +656,7 @@ pub async fn add_dependency_with_token( }, dependencies: HashMap::new(), tools: None, + test_groups: HashMap::new(), } }); @@ -1861,7 +1872,10 @@ fn get_cached_entities<'a>( fn make_path_portable(path: PathBuf) -> PathBuf { if let Some(home_dir) = dirs::home_dir() { if let Ok(relative_path) = path.strip_prefix(&home_dir) { - return PathBuf::from("$HOME").join(relative_path); + let joined = PathBuf::from("$HOME").join(relative_path); + // Normalize to forward slashes so files written on Windows + // remain readable on Linux (and vice versa). + return PathBuf::from(joined.to_string_lossy().replace('\\', "/")); } } path @@ -1953,6 +1967,134 @@ pub fn deps_directory() -> Result { Ok(deps_dir) } +/// Find all the tests that match the selectors and return a set of test names. +/// +/// Selectors are config-grammar strings: `name:`, `path:`, or +/// `group:`. `group:` references are resolved recursively against +/// `test_groups`. Each top-level selector must match at least one test or +/// expand to at least one tb name; otherwise an error is returned. +pub fn resolve_test_selection( + selectors: &[String], + workspace_dir: &Utf8Path, + test_groups: &HashMap>, + ignore: &HashSet, +) -> Result> { + let bench_dir = workspace_dir.join("bench"); + let mut all_tests: Vec = + list_testbenches(&bench_dir, ignore, true)?; + let mixed_sig_tbs = + find_mist_configs(&bench_dir)?.into_iter().map(|(name, _)| { + TestbenchInfo { + path: bench_dir.join(&name).into(), + name, + } + }); + all_tests.extend(mixed_sig_tbs); + + let mut selected: HashSet = HashSet::new(); + let mut visiting: HashSet = HashSet::new(); + for selector in selectors { + resolve_one( + selector, + workspace_dir, + test_groups, + &all_tests, + &mut visiting, + &mut selected, + )?; + } + Ok(selected) +} + +fn resolve_one( + selector: &str, + workspace_dir: &Utf8Path, + test_groups: &HashMap>, + tests: &[TestbenchInfo], + visiting: &mut HashSet, + selected: &mut HashSet, +) -> Result<()> { + let (kind, rest) = + selector.split_once(':').ok_or_else(|| VwError::TestGroup { + message: format!( + "selector '{selector}' must start with `name:`, `path:`, or `group:`" + ), + })?; + + match kind { + "group" => { + if !visiting.insert(rest.to_string()) { + return Err(VwError::TestGroup { + message: format!( + "circular reference to test group '{rest}'" + ), + }); + } + let entries = + test_groups.get(rest).ok_or_else(|| VwError::TestGroup { + message: format!( + "test group '{rest}' not found in vw.toml" + ), + })?; + for entry in entries { + resolve_one( + entry, + workspace_dir, + test_groups, + tests, + visiting, + selected, + )?; + } + visiting.remove(rest); + Ok(()) + } + "name" => match_pattern(selector, rest, tests, selected, |t, p| { + p.matches(&t.name) + }), + "path" => { + let workspace_std = workspace_dir.as_std_path(); + match_pattern(selector, rest, tests, selected, |t, p| { + let rel = t + .path + .strip_prefix(workspace_std) + .unwrap_or(&t.path) + .to_string_lossy() + .replace('\\', "/"); + p.matches(&rel) + }) + } + _ => Err(VwError::TestGroup { + message: format!("unknown selector kind '{kind}:' in '{selector}'"), + }), + } +} + +fn match_pattern( + selector: &str, + glob_str: &str, + tests: &[TestbenchInfo], + selected: &mut HashSet, + is_match: impl Fn(&TestbenchInfo, &Pattern) -> bool, +) -> Result<()> { + let pattern = Pattern::new(glob_str).map_err(|e| VwError::TestGroup { + message: format!("invalid glob pattern '{glob_str}': {e}"), + })?; + let mut matched = false; + for test in tests { + if is_match(test, &pattern) { + selected.insert(test.name.clone()); + matched = true; + } + } + if !matched { + return Err(VwError::TestGroup { + message: format!("selector '{selector}' matched no tests"), + }); + } + Ok(()) +} + /// Resolve a path stored in `vw.lock` against the local dependency cache. /// /// Lock-file dep paths are stored as `-` (relative to the