diff --git a/analysis_options.yaml b/analysis_options.yaml index 2b2098177..14663dfa1 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -8,6 +8,8 @@ analyzer: strict-raw-types: true exclude: - doc/tutorials/chapter_9/rohd_vf_example + - packages/rohd_hierarchy + - packages/rohd_waveform - rohd_devtools_extension # keep up to date, matching https://dart.dev/tools/linter-rules/all diff --git a/doc/tutorials/chapter_6/answers/exercise_2_n_bit_subtractor.dart b/doc/tutorials/chapter_6/answers/exercise_2_n_bit_subtractor.dart index 5765f08bf..18efb5a2d 100644 --- a/doc/tutorials/chapter_6/answers/exercise_2_n_bit_subtractor.dart +++ b/doc/tutorials/chapter_6/answers/exercise_2_n_bit_subtractor.dart @@ -7,7 +7,6 @@ import '../../chapter_3/answers/helper.dart'; import '../../chapter_5/answers/full_subtractor.dart'; class FullSubtractorComb extends FullSubtractor { - @override FullSubtractorComb(super.a, super.b, super.borrowIn) { // Declare input and output final a = input('a'); diff --git a/packages/rohd_hierarchy/README.md b/packages/rohd_hierarchy/README.md new file mode 100644 index 000000000..4b03c519f --- /dev/null +++ b/packages/rohd_hierarchy/README.md @@ -0,0 +1,243 @@ +# rohd_hierarchy + +An incremental design dictionary for hardware module hierarchies. + +## Motivation + +A remote agent — a debugger, a waveform viewer, a schematic renderer, an +AI assistant — needs to understand the structure of a hardware design in order +to ask useful questions about it. Transferring the full design every time is +wasteful. What both sides of a link really need is a shared **dictionary** of +the design: the modules, occurrences, and signals that make it up, plus +a compact way to refer to any object by address. + +Once both sides share the same dictionary, communication becomes cheap: +either side can request data about a specific object by its address alone, +without re-transmitting structural context. + +### What is a design dictionary? + +A design dictionary captures the **hierarchy and connectivity** of a +hardware design: + +- **Occurrences** — unfolded instances of module definitions in the + hierarchy tree. Each has a `name`, an optional `definition` (the + module type), child occurrences, and signals. +- **Signals** — named wires within an occurrence. Each has a `name`, + `width`, optional `direction` (input/output/inout), and optional + `value`. + +The full "unfolded" view of a design is its **address space**: every +occurrence and every signal reachable by walking the hierarchy tree. + +### Compact, canonical addressing + +`rohd_hierarchy` assigns each object a **canonical address** — a short +sequence of child indices (e.g. `0.2.4`) that uniquely identifies it within +the tree. + +Addresses are **relative within each occurrence**: an occurrence's address +table maps local indices to its children and signals without relying on any +global namespace. This locality property is what makes the dictionary +**incrementally expandable** — a remote agent can: + +1. Request the top-level dictionary table (the root occurrence's children + and signals). +2. Drill into any child by requesting that child's dictionary table. +3. Continue expanding only the parts of the hierarchy it actually needs. + +At each step, both sides agree on the addresses, so subsequent data +requests (waveform samples, signal values, schematic fragments) carry +only the compact address, not the full path or structural description. + +## Package overview + +`rohd_hierarchy` is a source-agnostic Dart package that implements this +dictionary model. It provides data models, search utilities, and adapter +interfaces that work independently of any particular HDL toolchain or +transport layer. + +### Data models + +- **`HierarchyOccurrence`** — An occurrence of a module definition in the + unfolded hierarchy tree, with children, signals, name, an optional + `definition` (module type), and a primitive flag. Call `buildAddresses()` + to assign a canonical `OccurrenceAddress` to every occurrence and signal + in O(n). Use `signalCount` and `computedSignalCount` for efficient + subtree counts. +- **`OccurrenceAddress`** — An immutable, index-based path through the + tree (e.g. `[0, 2, 4]`). Supports conversion to/from dot-separated + strings. Works as an O(1) cache key. +- **`SignalOccurrence`** — Signal metadata: name, width, optional + direction, and optional value. Signals with a `direction` serve as + ports (input, output, inout). + +### Services & adapters + +- **`HierarchyService`** — A mixin providing tree-walking search and + navigation: `searchSignals()`, `searchOccurrences()`, + `autocompletePaths()`, regex/glob search (`searchSignalsRegex()`, + `searchOccurrencesRegex()`), and address↔pathname conversion. +- **`BaseHierarchyAdapter`** — An abstract class wrapping a + `HierarchyOccurrence` tree with `HierarchyService`. Use + `BaseHierarchyAdapter.fromTree()` to wrap an existing tree. +- **`NetlistHierarchyAdapter`** — A concrete adapter that parses netlist + JSON into a `HierarchyOccurrence` tree. + +### Search queries + +- **`HierarchyQuery`** — Abstract base class for pluggable search + strategies. The matching logic is decoupled from tree traversal. +- **`PrefixQuery`** — Prefix-substring matching. Segments split on `/` + or `.` are matched case-insensitively via `startsWith` (signals) or + `contains` (occurrences). Created via `HierarchyQuery.prefix()`. +- **`RegexQuery`** — Regex/glob matching. Each segment is compiled as a + regex. Supports `*` (any chars), `?` (one char), `**` (zero or more + hierarchy levels), character classes (`[0-9]`), alternation + (`(clk|reset)`), and quantifiers. Created via `HierarchyQuery.regex()`. + +### Search controller + +- **`HierarchySearchController`** — A pure-Dart controller for + keyboard-navigable search result lists, with `updateQuery()`, + `selectNext()` / `selectPrevious()`, `tabComplete()`, and scroll-offset + helpers. Factories `forSignals()` and `forOccurrences()` cover the + common cases. + +## Usage + +### Building a dictionary from a netlist + +```dart +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; + +final dict = NetlistHierarchyAdapter.fromJson(netlistJsonString); +final root = dict.root; // the top-level dictionary table +``` + +### Wrapping an existing tree + +When you already have a `HierarchyOccurrence` tree (e.g. from a VCD +parser, a ROHD simulation, or any other source), wrap it to gain search +and address resolution: + +```dart +final dict = BaseHierarchyAdapter.fromTree(rootNode); +``` + +### Incremental expansion by a remote agent + +A remote agent does not need the full tree up front. It can expand the +dictionary one level at a time: + +```dart +// Agent receives the root table +final root = dict.root; + +// Agent picks a child to expand (e.g. child 2) +final child = root.children[2]; + +// The child's own children and signals are its local dictionary table. +// The agent now knows addresses 2.0, 2.1, ... for that subtree. +``` + +### Compact address-based communication + +Once both sides share the dictionary, data requests use addresses only: + +```dart +// Resolve a human-readable pathname to a canonical address +final addr = dict.pathnameToAddress('Counter/clk'); + +// Send the compact address over the wire: "0.1" +final wire = addr!.toDotString(); + +// The other side resolves it back +final resolved = dict.occurrenceByAddress(OccurrenceAddress.fromDotString(wire)); +final pathname = dict.addressToPathname(addr!); +``` + +### Searching the dictionary + +#### Prefix search (default) + +Segments are split on `/` or `.` and matched as case-insensitive +substrings: + +```dart +// Find all signals whose path contains 'cpu' then 'clk' +final signals = dict.searchSignals('cpu/clk'); + +// Find occurrences containing 'counter' +final modules = dict.searchOccurrences('counter'); + +// Tab-completion for partial paths +final completions = dict.autocompletePaths('Top/CPU/'); +``` + +#### Regex / glob search + +Each segment is a regex anchored to the full name. Glob wildcards `*` +and `?` are auto-converted. Use `**` to match across hierarchy levels: + +```dart +// All 'clk' signals anywhere in the design +final clocks = dict.searchSignalsRegex('Top/**/clk'); + +// Signals named d0–d15 in any regfile +final data = dict.searchSignalsRegex('Top/**/regfile/d[0-9]+'); + +// Either 'clk' or 'reset' anywhere +final resets = dict.searchSignalsRegex('Top/**/(clk|reset)'); + +// All cache channels ch0–ch2 +final channels = dict.searchOccurrencesRegex('Top/mem_ctrl/ch[0-2]'); + +// Signals containing 'mux' in their name +final muxed = dict.searchSignalsRegex('Top/**/.*mux.*'); + +// All signals in a specific module +final all = dict.searchSignalsRegex('Top/CPU/ALU/*'); +``` + +### Constructing occurrences manually + +```dart +final root = HierarchyOccurrence( + name: 'Counter', + definition: 'Counter', + signals: [ + SignalOccurrence(name: 'clk', width: 1, direction: 'input'), + SignalOccurrence(name: 'count', width: 8, direction: 'output'), + ], + children: [ + HierarchyOccurrence( + name: 'adder', + definition: 'Adder', + signals: [ + SignalOccurrence(name: 'a', width: 8), + SignalOccurrence(name: 'b', width: 8), + SignalOccurrence(name: 'sum', width: 8), + ], + ), + ], +); + +// Assign canonical addresses +root.buildAddresses(); + +// Now every occurrence and signal has an address +print(root.children.first.path()); // 'Counter/adder' +print(root.signals.first.path()); // 'Counter/clk' +``` + +## Design principles + +| Principle | How it is achieved | +|---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Source-agnostic** | The data model is independent of any HDL toolchain. `NetlistHierarchyAdapter` handles netlist JSON; `BaseHierarchyAdapter.fromTree()` wraps any tree. | +| **Incremental** | Addresses are relative within each occurrence. A remote agent expands only the subtrees it needs, one dictionary table at a time. | +| **Compact** | `OccurrenceAddress` is a short index path (e.g. `0.2.4`), not a full dotted pathname. Both sides resolve it locally. | +| **Canonical** | `buildAddresses()` assigns deterministic indices in tree order. The same design always produces the same addresses. | +| **No global namespace** | Each occurrence's address table is self-contained. Adding or removing a sibling subtree does not invalidate addresses in unrelated parts of the tree. | +| **Transport-independent** | The package defines the dictionary model, not the wire protocol. Any transport (VM service, JSON-RPC, gRPC, WebSocket) can carry the compact addresses. | diff --git a/packages/rohd_hierarchy/analysis_options.yaml b/packages/rohd_hierarchy/analysis_options.yaml new file mode 100644 index 000000000..f04c6cf0f --- /dev/null +++ b/packages/rohd_hierarchy/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/rohd_hierarchy/lib/rohd_hierarchy.dart b/packages/rohd_hierarchy/lib/rohd_hierarchy.dart new file mode 100644 index 000000000..86ed7c336 --- /dev/null +++ b/packages/rohd_hierarchy/lib/rohd_hierarchy.dart @@ -0,0 +1,50 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// rohd_hierarchy.dart +// Main library export for rohd_hierarchy package. +// +// 2026 January +// Author: Desmond Kirkpatrick + +/// Generic hierarchy data models for hardware module navigation. +/// +/// This library provides source-agnostic data models for representing +/// hardware module hierarchies: +/// +/// ## Core Data Models +/// - `OccurrenceAddress` - Efficient index-based addressing for tree navigation +/// - `HierarchyOccurrence` - An occurrence of a module definition in the tree +/// - `SignalOccurrence` - A signal in the hierarchy +/// +/// ## Search & Navigation +/// - `SignalSearchResult` - Result of a signal search with enriched metadata +/// - `OccurrenceSearchResult` - Result of an occurrence search with metadata +/// - `HierarchyService` - Abstract interface for hierarchy navigation +/// - `HierarchySearchController` - Pure Dart search state controller +/// +/// ## Adapters +/// - `BaseHierarchyAdapter` - Base class with shared adapter implementation +/// - `NetlistHierarchyAdapter` - Adapter for netlist JSON format +/// +/// This package has no dependencies and can be used standalone by any +/// application that needs to navigate hardware hierarchies. +/// +/// ## Quick Start +/// ```dart +/// 1. Create hierarchy +/// final root = HierarchyOccurrence(id: 'top', name: 'top'); +/// root.buildAddresses(); // Enable address-based navigation +/// +/// 2. Search +/// final service = BaseHierarchyAdapter.fromTree(root); +/// final results = service.searchSignals('clk'); +/// ``` +library; + +export 'src/base_hierarchy_adapter.dart'; +export 'src/hierarchy_models.dart'; +export 'src/hierarchy_query.dart'; +export 'src/hierarchy_search_controller.dart'; +export 'src/hierarchy_service.dart'; +export 'src/netlist_hierarchy_adapter.dart'; diff --git a/packages/rohd_hierarchy/lib/src/base_hierarchy_adapter.dart b/packages/rohd_hierarchy/lib/src/base_hierarchy_adapter.dart new file mode 100644 index 000000000..772007497 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/base_hierarchy_adapter.dart @@ -0,0 +1,80 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// base_hierarchy_adapter.dart +// Base class with shared implementation for hierarchy adapters. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/src/hierarchy_models.dart'; +import 'package:rohd_hierarchy/src/hierarchy_service.dart'; + +/// Base class providing shared implementation for hierarchy adapters. +/// +/// The [HierarchyOccurrence] tree rooted at [root] is the single source of +/// truth. Children and signals are read directly from each occurrence's +/// [HierarchyOccurrence.children] and [HierarchyOccurrence.signals] lists. +/// Lookups use [OccurrenceAddress]-based navigation. +/// +/// Concrete adapters should: +/// 1. Extend this class +/// 2. Build a complete [HierarchyOccurrence] tree (with children and signals +/// populated on each occurrence) +/// 3. Set the [root] occurrence +/// +/// Search, autocomplete, and signal lookup are implemented by +/// [HierarchyService] via recursive tree walking. +abstract class BaseHierarchyAdapter with HierarchyService { + HierarchyOccurrence? _root; + + /// Creates a [BaseHierarchyAdapter]. + BaseHierarchyAdapter(); + + /// Creates an adapter wrapping an existing [HierarchyOccurrence] tree. + /// + /// The tree itself is the single source of truth — children and signals + /// are read directly from the [HierarchyOccurrence] lists. + /// + /// Example usage: + /// ```dart + /// final treeRoot = await dataSource.evalModuleTree(); + /// final service = BaseHierarchyAdapter.fromTree(treeRoot); + /// final paths = service.searchSignalPaths('clk'); + /// ``` + factory BaseHierarchyAdapter.fromTree( + HierarchyOccurrence rootNode, + ) = _TreeBackedAdapter; + + /// Sets the root occurrence. Call this once during initialisation. + set root(HierarchyOccurrence node) { + _root = node; + } + + // ───────────────────────────────────────────────────────────────────────── + // HierarchyService concrete accessors — all tree-walking, no flat maps + // ───────────────────────────────────────────────────────────────────────── + + @override + HierarchyOccurrence get root { + if (_root == null) { + throw StateError( + 'Root occurrence not set. Call setRoot() during initialization.'); + } + return _root!; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tree-backed implementation returned by BaseHierarchyAdapter.fromTree() +// ───────────────────────────────────────────────────────────────────────────── + +/// Private adapter that wraps an existing [HierarchyOccurrence] tree. +/// +/// Children and signals are read directly from the tree occurrences. +class _TreeBackedAdapter extends BaseHierarchyAdapter { + _TreeBackedAdapter(HierarchyOccurrence rootNode) { + root = rootNode; + rootNode.buildAddresses(); + } +} diff --git a/packages/rohd_hierarchy/lib/src/hierarchy_models.dart b/packages/rohd_hierarchy/lib/src/hierarchy_models.dart new file mode 100644 index 000000000..6d686e053 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_models.dart @@ -0,0 +1,15 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_models.dart +// Barrel file re-exporting all hierarchy data model classes. +// +// 2026 January +// Author: Desmond Kirkpatrick + +export 'hierarchy_occurrence.dart'; +export 'hierarchy_search_result.dart'; +export 'occurrence_address.dart'; +export 'occurrence_search_result.dart'; +export 'signal_occurrence.dart'; +export 'signal_search_result.dart'; diff --git a/packages/rohd_hierarchy/lib/src/hierarchy_occurrence.dart b/packages/rohd_hierarchy/lib/src/hierarchy_occurrence.dart new file mode 100644 index 000000000..5d991f391 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_occurrence.dart @@ -0,0 +1,253 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_occurrence.dart +// An occurrence of a module definition in the unfolded hierarchy tree. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:meta/meta.dart'; +import 'package:rohd_hierarchy/src/occurrence_address.dart'; +import 'package:rohd_hierarchy/src/signal_occurrence.dart'; + +/// An occurrence of a module definition in the unfolded hierarchy tree. +/// +/// This is the core structural data model, independent of waveform data. +/// Path strings are computed on demand from parent references rather than +/// stored — call [path] with your desired separator. +class HierarchyOccurrence { + /// Display name of this occurrence (instance name within its parent). + final String name; + + /// Definition (module) name for this occurrence. + final String? definition; + + /// Whether this occurrence is a primitive cell (gate, operator, register, + /// etc.) whose internal structure is not useful for design navigation. + /// + /// Set by the parser/adapter that creates the occurrence. The netlist + /// adapter sets this for cells that lack a module definition in the JSON or + /// whose definition starts with `$` (netlist built-in primitives). + /// Tool-specific primitives (e.g. ROHD's FlipFlop → `$dff`) are handled by + /// the synthesizer mapping them to `$`-prefixed definitions before the JSON + /// is written. + final bool isPrimitive; + + /// Signals within this occurrence (includes both internal signals and + /// ports). Empty for leaf occurrences. + final List signals; + + /// Child occurrences. Populated from sub-modules in the hierarchy. + final List children; + + /// Hierarchical address for this occurrence. + /// Assigned by [buildAddresses] to enable efficient navigation. + /// Format: [child0, child1, ..., childN] for nested occurrences. + OccurrenceAddress? get address => _address; + OccurrenceAddress? _address; + + /// Parent occurrence, or `null` for the root. + /// Set by [buildAddresses]. + HierarchyOccurrence? get parent => _parent; + HierarchyOccurrence? _parent; + + /// Creates a [HierarchyOccurrence] with the given properties. + HierarchyOccurrence({ + required this.name, + this.definition, + this.isPrimitive = false, + List? signals, + List? children, + }) : signals = signals ?? [], + children = children ?? []; + + /// Compute the full hierarchical path by walking up the parent chain. + /// + /// Uses [separator] between path segments (default `/`). + /// Returns just [name] for the root (no parent). + String path({String separator = '/'}) { + if (_parent == null) { + return name; + } + final parts = []; + HierarchyOccurrence? cur = this; + while (cur != null) { + parts.add(cur.name); + cur = cur._parent; + } + return parts.reversed.join(separator); + } + + /// Returns only signals that are ports (have a direction). + List get ports => signals.where((s) => s.isPort).toList(); + + // ───────────────── Name → offset (index) lookups ───────────────── + + /// Lazily-built index: child name → offset in [children]. + Map? _childNameIndex; + + /// Lazily-built index: signal name → offset in [signals]. + Map? _signalNameIndex; + + /// Return the offset (index) of the child with [name] in [children], + /// or -1 if not found. Case-sensitive. + /// O(1) after first call (lazily builds index). + int childIndexByName(String name) { + _childNameIndex ??= { + for (var i = 0; i < children.length; i++) children[i].name: i, + }; + return _childNameIndex![name] ?? -1; + } + + /// Return the offset (index) of the signal with [name] in [signals], + /// or -1 if not found. Case-sensitive. + /// O(1) after first call (lazily builds index). + int signalIndexByName(String name) { + _signalNameIndex ??= { + for (var i = 0; i < signals.length; i++) signals[i].name: i, + }; + return _signalNameIndex![name] ?? -1; + } + + /// Whether [cellType] represents a netlist built-in primitive cell type. + /// + /// Returns `true` for `$`-prefixed types (`$mux`, `$dff`, `$and`, etc.) + /// which are netlist built-in operators and primitives. + /// + /// Tool-specific primitive types (e.g. ROHD's `FlipFlop`) should be + /// handled by the producer: the synthesizer should map them to + /// `$`-prefixed cell types in the JSON output, or the adapter should + /// set [isPrimitive] on the occurrence at construction time. + /// + /// Use this before a [HierarchyOccurrence] exists (e.g. when deciding + /// whether to recurse into a netlist cell definition). For an existing + /// occurrence, use the getter [isPrimitiveCell] instead. + static bool isPrimitiveType(String cellType) => cellType.startsWith(r'$'); + + /// Whether this occurrence represents a primitive cell that should be hidden + /// from the occurrence tree. + /// + /// Checks the [isPrimitive] field (set by the adapter at construction time) + /// and falls back to [isPrimitiveType] on the occurrence's [definition]. + bool get isPrimitiveCell => + isPrimitive || (definition != null && isPrimitiveType(definition!)); + + /// Returns only input signals. + List get inputs => + signals.where((s) => s.direction == 'input').toList(); + + /// Returns only output signals. + List get outputs => + signals.where((s) => s.direction == 'output').toList(); + + /// Number of port signals in this occurrence. + int get portCount => signals.where((s) => s.isPort).length; + + /// Finds the sub-field [SignalOccurrence] entries for a struct/array signal. + /// + /// Given a `parentSignal` that has `logicType` metadata (struct fields or + /// array dims), looks up the expected sub-field signal names in this + /// occurrence's signal list using the Namer/Sanitizer naming convention: + /// `{parentSignalName}_{fieldName}` + /// + /// Returns a list of resolved sub-field signals in field order. + /// Entries may be null if a particular sub-field signal wasn't found + /// (e.g. the netlist didn't emit it, or it was optimized away). + List<({SignalOccurrence? signal, String fieldLabel, int width, int startBit})> + findSubFieldSignals(SignalOccurrence parentSignal) { + final descriptors = parentSignal.subFieldDescriptors; + if (descriptors.isEmpty) { + return const []; + } + + return descriptors.map((d) { + final idx = signalIndexByName(d.expectedName); + final sig = idx >= 0 ? signals[idx] : null; + return ( + signal: sig, + fieldLabel: d.fieldLabel, + width: d.width, + startBit: d.startBit, + ); + }).toList(); + } + + /// Collect all signals under this occurrence in depth-first order. + /// + /// Visits this occurrence's [signals] first, then recurses into + /// [children] in order. Useful for flat iteration or counting, but + /// signals should always be identified by their [OccurrenceAddress] or + /// path — never by a positional index in this list. + /// + /// Production code should use [signalCount], [computedSignalCount], or + /// a recursive visitor instead of materializing the full list. + @visibleForTesting + List depthFirstSignals() => + [...signals, ...children.expand((c) => c.depthFirstSignals())]; + + /// Total number of signals in this subtree (O(n) recursive count). + /// + /// Equivalent to `depthFirstSignals().length` but avoids allocating the + /// intermediate list. + int get signalCount => + signals.length + children.fold(0, (sum, c) => sum + c.signalCount); + + /// Number of computed signals in this subtree. + /// + /// Equivalent to + /// `depthFirstSignals().where((s) => s.isComputed).length` + /// but avoids allocating the intermediate list. + int get computedSignalCount => + signals.where((s) => s.isComputed).length + + children.fold(0, (sum, c) => sum + c.computedSignalCount); + + /// Build hierarchical addresses for this occurrence and all descendants. + /// + /// This performs a single O(n) tree traversal to assign [OccurrenceAddress] + /// to every occurrence and signal in the tree. Call this once after tree + /// construction to enable efficient address-based navigation. + /// + /// **Signal address ordering**: ports (signals with a non-null + /// [SignalOccurrence.direction]) are assigned indices first + /// (`0 .. portCount-1`), followed by internal signals + /// (`portCount .. signals.length-1`). Within each group the + /// original list order is preserved. + /// + /// This means a port's [SignalOccurrence.portIndex] always equals its + /// signal address index, which consumers (e.g. schematic hyperedges) can + /// rely on remaining stable across incremental expansion. + /// + /// Example: + /// ```dart + /// root.buildAddresses(); // Assign addresses to all occurrences/signals + /// final signalAddr = signals[0].address; // Now available + /// ``` + void buildAddresses([OccurrenceAddress startAddr = OccurrenceAddress.root]) { + _address = startAddr; + + // Assign ports first, then internal signals, so that port indices + // are stable across incremental hierarchy expansion. + var idx = 0; + for (final s in signals) { + if (s.isPort) { + s + ..address = startAddr.signal(idx++) + ..parent = this; + } + } + for (final s in signals) { + if (!s.isPort) { + s + ..address = startAddr.signal(idx++) + ..parent = this; + } + } + + for (final (i, c) in children.indexed) { + c + .._parent = this + ..buildAddresses(startAddr.child(i)); + } + } +} diff --git a/packages/rohd_hierarchy/lib/src/hierarchy_query.dart b/packages/rohd_hierarchy/lib/src/hierarchy_query.dart new file mode 100644 index 000000000..c350a2391 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_query.dart @@ -0,0 +1,152 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_query.dart +// Pluggable search query abstraction for hierarchy search. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/src/hierarchy_occurrence.dart'; +import 'package:rohd_hierarchy/src/hierarchy_service.dart'; +import 'package:rohd_hierarchy/src/prefix_query.dart'; +import 'package:rohd_hierarchy/src/regex_query.dart'; + +// Re-export so callers importing hierarchy_query.dart get the concrete types. +export 'package:rohd_hierarchy/src/prefix_query.dart'; +export 'package:rohd_hierarchy/src/regex_query.dart'; + +/// What kind of hierarchy elements a query should match. +enum SearchTarget { + /// Match only [HierarchyOccurrence] nodes (modules, instances). + occurrences, + + /// Match only signals within occurrences. + signals, + + /// Match both occurrences and signals. + both, +} + +/// Abstract base class for hierarchy search queries. +/// +/// A [HierarchyQuery] encapsulates the *matching strategy* (how names are +/// compared) independently of the *tree traversal* (which is always +/// performed by [HierarchyService]). +/// +/// ## Contract with [HierarchyService] +/// +/// The service walks the [HierarchyOccurrence] tree depth-first. +/// At each node it calls: +/// +/// 1. [matchOccurrence] — does this occurrence name satisfy the query at the +/// current match state? Returns a set of successor states (empty = +/// prune this branch). +/// 2. [matchSignal] — does this signal name satisfy the query at the +/// current match state? +/// 3. [isComplete] — have all parts of the query been consumed at the +/// given state? +/// +/// "Match state" is an opaque integer that the query owns. It typically +/// tracks how many segments/tokens of the query have been consumed so far. +/// The initial state is always `0`. +/// +/// ## Crossing hierarchy boundaries +/// +/// If [crossesBoundaries] is true the service will, at each depth, +/// additionally try advancing with the *current* state even when the +/// occurrence doesn't match — allowing matches to span across +/// intermediate hierarchy levels (like `**` in glob patterns). +/// +/// ## Subclassing +/// +/// Implement a concrete query by overriding at least [matchOccurrence], +/// [matchSignal], [isComplete], and [segmentCount]. +/// +/// The factory [HierarchyQuery.prefix] creates the default +/// prefix-substring query. [HierarchyQuery.regex] creates a +/// regex/glob query. +/// +/// ```dart +/// // Custom fuzzy query +/// class FuzzyQuery extends HierarchyQuery { +/// FuzzyQuery(String rawQuery) +/// : super(rawQuery, target: SearchTarget.signals); +/// ... +/// } +/// ``` +abstract class HierarchyQuery { + /// The original user-supplied query string. + final String rawQuery; + + /// What this query matches — occurrences, signals, or both. + final SearchTarget target; + + /// Whether this query can match across hierarchy boundaries. + /// + /// When true, the tree walker will try the current match state at + /// deeper levels even when intermediate occurrences don't match. + /// Conceptually equivalent to an implicit `**` between segments. + final bool crossesBoundaries; + + /// Creates a query from [rawQuery]. + /// + /// Subclasses should parse/compile the query in their constructor. + const HierarchyQuery( + this.rawQuery, { + this.target = SearchTarget.signals, + this.crossesBoundaries = false, + }); + + /// Number of logical segments in the parsed query. + /// + /// Used by the tree walker to know when the query is fully consumed. + int get segmentCount; + + /// Whether the query is empty / trivial (should return no results). + bool get isEmpty => rawQuery.trim().isEmpty; + + /// Try matching an occurrence name at match state [stateIndex]. + /// + /// Returns a set of successor states. Multiple successors arise when + /// the query is ambiguous at this point (e.g. a glob-star `**` can + /// consume zero or more levels). + /// + /// An empty set means "no match — prune this subtree". + Set matchOccurrence(String occurrenceName, int stateIndex); + + /// Whether [signalName] matches the query at state [stateIndex]. + /// + /// Only called when [target] includes signals. + bool matchSignal(String signalName, int stateIndex); + + /// Whether the query is fully consumed at [stateIndex]. + /// + /// Returns true when all segments have been matched and the current + /// tree position is a valid result. + bool isComplete(int stateIndex); + + // ──────────────── Built-in query factories ──────────────── + + /// Create a **prefix-substring** query. + /// + /// The query is split on `/` or `.` into segments. Each segment is + /// matched via `startsWith` (for signals) or + /// `contains` (for occurrences) against names at successive depths. + factory HierarchyQuery.prefix( + String rawQuery, { + SearchTarget target, + }) = PrefixQuery; + + /// Create a **regex/glob** query. + /// + /// Segments are separated by `/`. Each segment is compiled as a + /// case-sensitive regex anchored to the full name. The special + /// segment `**` matches zero or more hierarchy levels. + /// + /// Glob wildcards `*` and `?` are auto-converted to regex equivalents. + factory HierarchyQuery.regex( + String rawQuery, { + SearchTarget target, + }) = RegexQuery; +} diff --git a/packages/rohd_hierarchy/lib/src/hierarchy_search_controller.dart b/packages/rohd_hierarchy/lib/src/hierarchy_search_controller.dart new file mode 100644 index 000000000..a82375b4b --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_search_controller.dart @@ -0,0 +1,216 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_search_controller.dart +// Pure Dart controller for hierarchy search list navigation. +// +// 2026 February +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; + +/// Pure Dart controller for hierarchy search list navigation. +/// +/// Manages search results and keyboard-style list selection without +/// any Flutter dependency. Widgets call controller methods, then +/// refresh their own UI (e.g. `setState`). +/// +/// Generic over the result type [R] — typically [SignalSearchResult] +/// or [OccurrenceSearchResult]. +/// +/// ```dart +/// // In a Flutter widget: +/// final controller = HierarchySearchController.forSignals(hierarchy); +/// +/// void _onSearchChanged() { +/// controller.updateQuery(_textController.text); +/// setState(() {}); +/// } +/// ``` +class HierarchySearchController { + /// The search function that produces results from a normalised query. + final List Function(String normalizedQuery) _searchFn; + + /// Normalises a raw user query (e.g. replaces `.` with `/`). + final String Function(String rawQuery) _normalizeFn; + + List _results = []; + int _selectedIndex = 0; + + /// Create a controller with custom search and normalise functions. + HierarchySearchController({ + required List Function(String normalizedQuery) searchFn, + required String Function(String rawQuery) normalizeFn, + }) : _searchFn = searchFn, + _normalizeFn = normalizeFn; + + /// Create a controller for **signal** search on the given + /// [HierarchyService]. + /// + /// When the query contains glob/regex metacharacters, normalisation + /// is skipped so that `.` keeps its regex meaning (use `/` as the + /// hierarchy separator in regex patterns). + factory HierarchySearchController.forSignals( + HierarchyService hierarchy, + ) => + HierarchySearchController( + searchFn: (q) => hierarchy.searchSignals(q) as List, + normalizeFn: (q) => HierarchyService.hasRegexChars(q) + ? q + : HierarchySearchResult.normalizeQuery(q), + ); + + /// Create a controller for **occurrence** search on the given + /// [HierarchyService]. + /// + /// When the query contains glob/regex metacharacters, normalisation + /// is skipped so that `.` keeps its regex meaning (use `/` as the + /// hierarchy separator in regex patterns). + factory HierarchySearchController.forOccurrences( + HierarchyService hierarchy, + ) => + HierarchySearchController( + searchFn: (q) => hierarchy.searchOccurrences(q) as List, + normalizeFn: (q) => HierarchyService.hasRegexChars(q) + ? q + : HierarchySearchResult.normalizeQuery(q), + ); + + // ─────────────── State accessors ─────────────── + + /// The current search results. + List get results => _results; + + /// Index of the currently highlighted result. + int get selectedIndex => _selectedIndex; + + /// Whether there are any results. + bool get hasResults => _results.isNotEmpty; + + /// A human-readable counter string, e.g. `"3/12"`, or empty when + /// there are no results. + String get counterText => + hasResults ? '${_selectedIndex + 1}/${_results.length}' : ''; + + /// The currently selected result, or `null` if the list is empty. + R? get currentSelection => _results.isEmpty ? null : _results[_selectedIndex]; + + // ─────────────── Mutations ─────────────── + + /// Update search results for [rawQuery]. + /// + /// Normalises the query, runs the search function, and resets the + /// selection to the first result. The caller should rebuild its UI + /// after calling this. + void updateQuery(String rawQuery) { + if (rawQuery.isEmpty) { + _results = []; + _selectedIndex = 0; + return; + } + final normalized = _normalizeFn(rawQuery); + _results = _searchFn(normalized); + _selectedIndex = 0; + } + + /// Move selection to the next result, wrapping around. + void selectNext() { + if (_results.isEmpty) { + return; + } + _selectedIndex = (_selectedIndex + 1) % _results.length; + } + + /// Move selection to the previous result, wrapping around. + void selectPrevious() { + if (_results.isEmpty) { + return; + } + _selectedIndex = (_selectedIndex - 1 + _results.length) % _results.length; + } + + /// Move selection to a specific [index]. + /// + /// Clamps to valid range. Useful for tap-to-select in a list view. + void selectAt(int index) { + if (_results.isEmpty) { + return; + } + _selectedIndex = index.clamp(0, _results.length - 1); + } + + /// Clear all results and reset the selection index. + void clear() { + _results = []; + _selectedIndex = 0; + } + + // ─────────────── Tab-completion ─────────────── + + /// Compute the tab-completion expansion for [currentQuery]. + /// + /// Finds the longest common prefix of all current result display paths + /// and returns it if it is strictly longer than [currentQuery]. + /// Returns `null` when there is nothing to expand. + /// + /// [displayPath] extracts the comparable path string from each result. + /// The default implementation handles [SignalSearchResult] and + /// [OccurrenceSearchResult] automatically; pass a custom extractor for + /// other result types. + String? tabComplete( + String currentQuery, { + String Function(R result)? displayPath, + }) { + if (_results.isEmpty) { + return null; + } + + final extractor = displayPath ?? _defaultDisplayPath; + final paths = _results.map(extractor).toList(); + final prefix = HierarchyService.longestCommonPrefix(paths); + if (prefix == null) { + return null; + } + + // Normalise the query the same way UpdateQuery does so lengths are + // comparable (e.g. dots → slashes). + final normalizedQuery = _normalizeFn(currentQuery); + if (prefix.length <= normalizedQuery.length) { + return null; + } + return prefix; + } + + /// Default display-path extractor for the well-known result types. + static String _defaultDisplayPath(T result) { + if (result is HierarchySearchResult) { + return result.displayPath; + } + return result.toString(); + } + + // ─────────────── Scroll helper ─────────────── + + /// Compute the scroll offset needed to reveal the selected item in a + /// fixed-height list. + /// + /// Returns `null` if the item is already visible. The caller should + /// call `scrollController.jumpTo(offset)` with the returned value. + /// + /// This is a pure calculation with no Flutter dependency. + static double? scrollOffsetToReveal({ + required int selectedIndex, + required double itemHeight, + required double viewportHeight, + required double currentOffset, + }) { + final target = selectedIndex * itemHeight; + if (target < currentOffset) { + return target; + } + if (target + itemHeight > currentOffset + viewportHeight) { + return target + itemHeight - viewportHeight; + } + return null; + } +} diff --git a/packages/rohd_hierarchy/lib/src/hierarchy_search_result.dart b/packages/rohd_hierarchy/lib/src/hierarchy_search_result.dart new file mode 100644 index 000000000..3f40b4c82 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_search_result.dart @@ -0,0 +1,60 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_search_result.dart +// Base class for hierarchy search results. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:meta/meta.dart'; + +/// Base class for hierarchy search results. +/// +/// Holds the common fields shared by signal and occurrence search +/// results: a canonical ID string, pre-split path segments, and display +/// helpers that strip the top-level module name. +@immutable +abstract class HierarchySearchResult { + /// The full hierarchical path that was found. + /// Example: `"Top/counter/clk"` or `"Top/CPU/ALU"`. + final String id; + + /// The hierarchical path segments. + /// Example: `["Top", "counter", "clk"]`. + final List path; + + /// Creates a hierarchy search result. + const HierarchySearchResult({required this.id, required this.path}); + + /// The leaf name (last path segment). + String get name => path.isNotEmpty ? path.last : id; + + // ───────────────────── Display helpers ───────────────────── + + /// Display path with the top-level module name stripped. + /// + /// For `Top/counter/clk` this returns `counter/clk`. + /// For a single-segment path returns the original [id]. + String get displayPath => displaySegments.join('/'); + + /// Path segments with the top-level module name stripped. + /// + /// For `["Top", "counter", "clk"]` returns `["counter", "clk"]`. + List get displaySegments => path.length > 1 ? path.sublist(1) : path; + + /// Normalize a user query for hierarchy search. + /// + /// Converts common separators (`.`) to the canonical `/` separator. + static String normalizeQuery(String query) => query.replaceAll('.', '/'); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is HierarchySearchResult && + runtimeType == other.runtimeType && + id == other.id; + + @override + int get hashCode => id.hashCode; +} diff --git a/packages/rohd_hierarchy/lib/src/hierarchy_service.dart b/packages/rohd_hierarchy/lib/src/hierarchy_service.dart new file mode 100644 index 000000000..08c2add90 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_service.dart @@ -0,0 +1,872 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_service.dart +// Abstract interface for source-agnostic hardware hierarchy navigation. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/src/hierarchy_models.dart'; + +/// Default path separator used when constructing paths from the tree. +const String _hierarchySeparator = '/'; + +/// A source-agnostic interface for navigating hardware hierarchy. +/// +/// All search and navigation is driven by walking the [HierarchyOccurrence] +/// tree. Occurrences hold their [HierarchyOccurrence.name], +/// [HierarchyOccurrence.children], and [HierarchyOccurrence.signals]. Full +/// paths are constructed on the fly by joining names with [_hierarchySeparator] +/// — no pre-baked path strings are needed for search. +/// +/// Key methods: +/// - [searchSignals] — incremental signal search +/// - [searchOccurrences] — find occurrences by name +/// - [matchOccurrences] — find occurrences, returning [HierarchyOccurrence] +/// objects +/// - [autocompletePaths] — incremental path completion +abstract mixin class HierarchyService { + /// The root occurrence for the hierarchy. + HierarchyOccurrence get root; + + /// Maximum number of results returned by search methods when no explicit + /// `limit` is provided. + static const int _defaultSearchLimit = 100; + + // ───────────── Address-based occurrence/signal lookup ──────────────── + + /// Find an occurrence by its [OccurrenceAddress]. O(depth). + HierarchyOccurrence? occurrenceByAddress(OccurrenceAddress address) => + address.path.fold( + root, + (node, idx) => node != null && idx >= 0 && idx < node.children.length + ? node.children[idx] + : null); + + /// Find a signal by its [OccurrenceAddress]. + /// + /// The parent portion of [address] navigates to the owning occurrence; + /// the last index selects the signal within that occurrence. O(depth). + SignalOccurrence? signalByAddress(OccurrenceAddress address) { + if (address.path.isEmpty) { + return null; + } + final node = occurrenceByAddress( + OccurrenceAddress(address.path.sublist(0, address.path.length - 1))); + final sigIdx = address.path.last; + return (node != null && sigIdx >= 0 && sigIdx < node.signals.length) + ? node.signals[sigIdx] + : null; + } + + // ───────────── Address ↔ pathname conversion ────────────────── + + /// Convert a pathname (e.g. `"Top/sub/clk"` or `"Top.sub.clk"`) to a + /// [OccurrenceAddress] by walking the tree. + /// + /// Delegates to [OccurrenceAddress.tryFromPathname]. + OccurrenceAddress? pathnameToAddress(String pathname) => + OccurrenceAddress.tryFromPathname(pathname, root); + + /// Resolve a `/`-separated pathname to a [HierarchyOccurrence]. + /// + /// Convenience that composes [pathnameToAddress] and [occurrenceByAddress]. + /// Returns `null` when [pathname] does not match any occurrence in the + /// tree. + HierarchyOccurrence? occurrenceByPathname(String pathname) { + final addr = pathnameToAddress(pathname); + return addr == null ? null : occurrenceByAddress(addr); + } + + /// Convert a [OccurrenceAddress] back to a `/`-separated pathname by + /// walking the tree using child indices. + /// + /// Returns `null` if the address doesn't resolve in the current tree + /// (e.g. out-of-bounds indices). O(depth). + /// + /// For signal addresses, the last index is resolved as a signal within + /// the parent occurrence. For pure occurrence addresses, every index + /// is a child. + /// + /// Set [asSignal] to `true` when you know the address points to a signal + /// (the last index is a signal offset rather than a child offset). + /// When `false` (default), all indices are treated as child offsets. + String? addressToPathname(OccurrenceAddress address, + {bool asSignal = false}) { + if (address.path.isEmpty) { + return root.name; + } + + final indices = address.path; + final moduleEndIdx = asSignal ? indices.length - 1 : indices.length; + + final walked = indices + .sublist(0, moduleEndIdx) + .fold<({List parts, HierarchyOccurrence node})?>(( + parts: [root.name], + node: root, + ), (cur, idx) { + if (cur == null || idx < 0 || idx >= cur.node.children.length) { + return null; + } + final child = cur.node.children[idx]; + return (parts: [...cur.parts, child.name], node: child); + }); + if (walked == null) { + return null; + } + + if (asSignal && indices.isNotEmpty) { + final sigIdx = indices.last; + return (sigIdx >= 0 && sigIdx < walked.node.signals.length) + ? [...walked.parts, walked.node.signals[sigIdx].name] + .join(_hierarchySeparator) + : null; + } + return walked.parts.join(_hierarchySeparator); + } + + /// Resolve a waveform-style ID (dot-separated, e.g. `"dut.adder.clk"`) + /// to a [OccurrenceAddress]. + /// + /// Normalises `.` → `/` then delegates to [pathnameToAddress]. + OccurrenceAddress? waveformIdToAddress(String waveformId) => + pathnameToAddress(waveformId); + + // ───────────────────── Search / autocomplete ───────────────────── + + /// Find hierarchical signal paths matching [query]. + /// + /// Walks the tree, matching name segments incrementally. When the last + /// query segment partially matches a signal name at or below the current + /// node the full path is returned (e.g. `Top/block/signal`). + /// + /// Returns up to [limit] results. + List searchSignalPaths(String query, {int? limit}) { + final effectiveLimit = limit ?? _defaultSearchLimit; + if (query.trim().isEmpty) { + return const []; + } + final parts = _splitPath(query); + final results = []; + _searchSignalsRecursive( + root, [root.name], parts, 0, results, effectiveLimit); + return results; + } + + /// Whether [query] contains glob or regex metacharacters that should + /// trigger the regex search engine instead of the plain substring search. + static bool hasRegexChars(String query) => + query.contains('*') || + query.contains('?') || + query.contains('[') || + query.contains('(') || + query.contains('|') || + query.contains('+'); + + /// Check if an occurrence or any of its descendants match [searchTerm]. + /// + /// The search term is split on `/` or `.` into hierarchical segments. + /// Each segment is matched via substring containment + /// against occurrence names at successive depths. + /// + /// Returns `true` if [searchTerm] is null/empty, or if the occurrence + /// (or a descendant) matches all segments in order. + /// + /// This is useful for tree-view filtering: show an occurrence only when + /// it or one of its descendants matches the user's query. + static bool isOccurrenceMatching( + HierarchyOccurrence node, String? searchTerm) { + if (searchTerm == null || searchTerm.isEmpty) { + return true; + } + + final normalizedQuery = searchTerm.replaceAll('.', '/'); + final queryParts = normalizedQuery + .split('/') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + + return _isOccurrenceMatchingRecursive(node, queryParts, 0); + } + + static bool _isOccurrenceMatchingRecursive( + HierarchyOccurrence node, List queryParts, int queryIdx) { + if (queryIdx >= queryParts.length) { + return true; + } + + final currentQueryPart = queryParts[queryIdx]; + final nodeName = node.name; + + final matched = nodeName.contains(currentQueryPart); + final nextQueryIdx = matched ? queryIdx + 1 : queryIdx; + + if (nextQueryIdx >= queryParts.length) { + return true; + } + + return node.children.any((child) => + _isOccurrenceMatchingRecursive(child, queryParts, nextQueryIdx)); + } + + /// Search for signals and return enriched [SignalSearchResult] objects. + /// + /// Automatically dispatches to [searchSignalsRegex] when the query + /// contains glob or regex metacharacters (`*`, `?`, `[`, `(`, `|`, + /// `+`). Otherwise uses [searchSignalPaths] for prefix-based matching. + List searchSignals(String query, {int? limit}) { + final effectiveLimit = limit ?? _defaultSearchLimit; + if (hasRegexChars(query)) { + final pattern = (query.startsWith('**/') || query.startsWith('*/')) + ? query + : '*/$query'; + return searchSignalsRegex(pattern, limit: effectiveLimit); + } + return _toSignalResults(searchSignalPaths(query, limit: effectiveLimit)); + } + + /// Find hierarchical occurrence paths matching [query]. + /// + /// Similar to [searchSignalPaths] but for occurrences instead of + /// signals. Walks the tree, matching name segments incrementally. When + /// the query segments match occurrence names at or below the current + /// level the full path is returned (e.g. `Top/CPU/ALU`). + /// + /// Returns up to [limit] results. + List searchOccurrencePaths(String query, {int? limit}) { + final effectiveLimit = limit ?? _defaultSearchLimit; + if (query.trim().isEmpty) { + return const []; + } + final parts = _splitPath(query); + final results = []; + _searchOccurrencePathsRecursive( + root, [root.name], parts, 0, results, effectiveLimit); + return results; + } + + /// Find hierarchy occurrences whose path matches [query]. + /// + /// Like [searchOccurrencePaths] but returns the [HierarchyOccurrence] objects + /// themselves instead of path strings. + List matchOccurrences(String query, {int? limit}) { + final effectiveLimit = limit ?? _defaultSearchLimit; + if (query.trim().isEmpty) { + return const []; + } + final parts = _splitPath(query); + final results = []; + _matchOccurrencesRecursive(root, parts, 0, results, effectiveLimit); + return results; + } + + /// Autocomplete suggestions for a partial hierarchical path. + /// + /// The partial path is split into segments. Completed segments navigate + /// down the tree; the final (possibly empty) segment is used as a prefix + /// filter on children at that level. Returns up to [limit] full paths + /// (with `/` appended for nodes that have children). + List autocompletePaths(String partialPath, {int? limit}) { + final effectiveLimit = limit ?? _defaultSearchLimit; + final normalized = partialPath.replaceAll('.', _hierarchySeparator); + final endsWithSep = normalized.endsWith(_hierarchySeparator); + final parts = _splitPath(partialPath); + + // Navigate to the deepest complete segment. + var current = root; + final completedParts = [root.name]; + + final navParts = endsWithSep || parts.isEmpty + ? parts + : parts.sublist(0, parts.length - 1); + for (final seg in navParts) { + // If the segment matches the current node name, stay at this level + // (handles the root name appearing as the first path segment). + if (current.name == seg) { + continue; + } + final child = current.children.where((c) => c.name == seg).firstOrNull; + if (child == null) { + return const []; + } + current = child; + completedParts.add(child.name); + } + + // The trailing prefix to filter on (empty if path ends with separator). + final prefix = (endsWithSep || parts.isEmpty) ? '' : parts.last; + + final suggestions = []; + + // When the prefix matches the current (root-level) node itself and we + // haven't navigated past it, suggest the root path so that typing a + // partial root name produces a completion. + if (prefix.isNotEmpty && + completedParts.length == 1 && + current == root && + current.name.startsWith(prefix)) { + final rootPath = current.name; + suggestions.add(current.children.isNotEmpty + ? '$rootPath$_hierarchySeparator' + : rootPath); + } + + for (final child in current.children) { + if (prefix.isEmpty || child.name.startsWith(prefix)) { + final path = [...completedParts, child.name].join(_hierarchySeparator); + suggestions.add( + child.children.isNotEmpty ? '$path$_hierarchySeparator' : path); + if (suggestions.length >= effectiveLimit) { + break; + } + } + } + return suggestions; + } + + /// Search for occurrences and return enriched + /// [OccurrenceSearchResult] objects. + /// + /// Automatically dispatches to [searchOccurrencesRegex] when the query + /// contains glob or regex metacharacters (`*`, `?`, `[`, `(`, `|`, + /// `+`). Otherwise uses [searchOccurrencePaths] for prefix-based matching. + List searchOccurrences(String query, {int? limit}) { + final effectiveLimit = limit ?? _defaultSearchLimit; + if (hasRegexChars(query)) { + final pattern = (query.startsWith('**/') || query.startsWith('*/')) + ? query + : '**/$query'; + return searchOccurrencesRegex(pattern, limit: effectiveLimit); + } + return _toOccurrenceResults( + searchOccurrencePaths(query, limit: effectiveLimit)); + } + + // ───────────────── Regex search ───────────────── + + /// Search for signals whose hierarchical path matches a regex [pattern]. + /// + /// The pattern is split on `/` or `.` into segments. Each segment is + /// compiled as a [RegExp] and matched against the + /// corresponding depth in the hierarchy tree. Special segments: + /// + /// - `**` — matches zero or more hierarchy levels (glob-star). Use this + /// to search across hierarchy boundaries, e.g. `Top/**/clk` finds + /// `Top/CPU/ALU/clk`, `Top/Memory/clk`, etc. + /// - Any other string is compiled as a regex anchored to the full name + /// (`^…$`). Plain names therefore match exactly and regex meta- + /// characters like `.*`, `[0-9]+`, etc. work as expected. + /// + /// Returns up to [limit] full hierarchical signal paths. + /// + /// Examples: + /// ```text + /// 'Top/CPU/clk' — exact match at each level + /// 'Top/CPU/.*' — all signals in Top/CPU + /// 'Top/.*/clk' — clk signal one level below Top + /// 'Top/**/clk' — clk signal at any depth below Top + /// 'Top/**/c.*' — signals starting with 'c' at any depth + /// '**/(clk|reset)' — clk or reset anywhere in hierarchy + /// 'Top/CPU/d[0-9]+' — signals like d0, d1, d12 in Top/CPU + /// ``` + List searchSignalPathsRegex(String pattern, {int? limit}) { + final effectiveLimit = limit ?? _defaultSearchLimit; + if (pattern.trim().isEmpty) { + return const []; + } + final segments = _splitRegexPattern(pattern); + final compiled = _compileSegments(segments); + final results = []; + _searchSignalsRegex( + root, [root.name], compiled, 0, results, effectiveLimit); + return results; + } + + /// Search for signals by regex pattern and return enriched results. + List searchSignalsRegex(String pattern, {int? limit}) => + _toSignalResults(searchSignalPathsRegex(pattern, limit: limit)); + + /// Search for occurrence paths matching a regex [pattern]. + /// + /// Same segment syntax as [searchSignalPathsRegex] but matches + /// occurrences instead of signals. + /// + /// Returns up to [limit] full hierarchical occurrence paths. + List searchOccurrencePathsRegex(String pattern, {int? limit}) { + final effectiveLimit = limit ?? _defaultSearchLimit; + if (pattern.trim().isEmpty) { + return const []; + } + final segments = _splitRegexPattern(pattern); + final compiled = _compileSegments(segments); + final results = []; + _matchOccurrencesRegex( + root, [root.name], compiled, 0, results, effectiveLimit); + return results; + } + + /// Search for occurrences by regex pattern and return enriched results. + List searchOccurrencesRegex(String pattern, + {int? limit}) => + _toOccurrenceResults(searchOccurrencePathsRegex(pattern, limit: limit)); + + // ─────────────────── Utility helpers ─────────────────── + + /// Returns the longest common prefix shared by all [paths]. + /// + /// Comparison is case-sensitive. Returns `null` when [paths] is empty + /// or no common prefix exists. + static String? longestCommonPrefix(List paths) { + if (paths.isEmpty) { + return null; + } + final prefix = paths.skip(1).fold(paths.first, (pre, s) { + if (pre == null || pre.isEmpty) { + return null; + } + final end = pre.length < s.length ? pre.length : s.length; + final j = + Iterable.generate(end).takeWhile((i) => pre[i] == s[i]).length; + return j > 0 ? pre.substring(0, j) : null; + }); + return prefix; + } + + // ─────────────────── Private helpers ─────────────────── + + /// Split a query or path on `/` or `.` into non-empty segments. + static List _splitPath(String input) => input + .replaceAll('.', _hierarchySeparator) + .split(_hierarchySeparator) + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + + /// Split a path on `/` or `.` into non-empty segments, preserving case. + /// + /// Use this when the result is for display or building [SignalSearchResult] + /// path parts — not for matching. + static List _splitPathPreserveCase(String input) => input + .replaceAll('.', _hierarchySeparator) + .split(_hierarchySeparator) + .where((s) => s.isNotEmpty) + .toList(); + + /// Enrich signal paths into [SignalSearchResult] objects. + List _toSignalResults(List paths) => + paths.map((fullPath) { + final addr = OccurrenceAddress.tryFromPathname(fullPath, root); + return SignalSearchResult( + signalId: fullPath, + path: _splitPathPreserveCase(fullPath), + signal: addr != null ? signalByAddress(addr) : null, + ); + }).toList(); + + /// Enrich occurrence paths into [OccurrenceSearchResult] objects. + List _toOccurrenceResults(List paths) => + paths.map((fullPath) { + final addr = OccurrenceAddress.tryFromPathname(fullPath, root); + return OccurrenceSearchResult( + occurrenceId: fullPath, + path: _splitPathPreserveCase(fullPath), + occurrence: (addr != null ? occurrenceByAddress(addr) : null) ?? root, + ); + }).toList(); + + /// Recursively search for signals matching query parts. + /// + /// Walks the tree maintaining the path of names. When the accumulated match + /// depth reaches the query length, checks signals at that node. Partial + /// last-segment matching also checks signals at partially-matched nodes. + /// + /// Uses [HierarchyOccurrence.children] and [HierarchyOccurrence.signals] + /// directly. + void _searchSignalsRecursive( + HierarchyOccurrence node, + List pathSoFar, + List queryParts, + int qIdx, + List results, + int limit, + ) { + if (results.length >= limit) { + return; + } + + // Try matching current node name against current query part + final nodeName = node.name; + final currentQuery = qIdx < queryParts.length ? queryParts[qIdx] : null; + final matched = currentQuery != null && nodeName.startsWith(currentQuery); + final nextIdx = matched ? qIdx + 1 : qIdx; + + // Determine how many query parts remain after any node-name match. + final remaining = queryParts.length - nextIdx; + + // If 0 or 1 query parts remain, search signals at this node. + if (remaining <= 1) { + // When the current node consumed the last segment (remaining==0, + // matched==true), reuse that segment as the signal filter so that + // e.g. "a" doesn't return every signal under a module named "alu". + // When remaining==0 because we're recursing into a subtree where + // a parent already consumed all segments, use empty (return all). + final signalQuery = remaining == 1 + ? queryParts[nextIdx] + : (matched && qIdx < queryParts.length ? queryParts[qIdx] : ''); + for (final signal in node.signals) { + if (results.length >= limit) { + return; + } + if (signalQuery.isEmpty || signal.name.startsWith(signalQuery)) { + final fullPath = + [...pathSoFar, signal.name].join(_hierarchySeparator); + results.add(fullPath); + } + } + } + + // Recurse into children + for (final child in node.children) { + if (results.length >= limit) { + return; + } + _searchSignalsRecursive( + child, + [...pathSoFar, child.name], + queryParts, + nextIdx, + results, + limit, + ); + } + } + + /// Recursively search for occurrences matching query parts. + /// + /// Similar to [_searchSignalsRecursive] but matches occurrences instead + /// of signals. Walks the tree maintaining the path of names. When the + /// query segments match occurrence names, adds them to results. + void _searchOccurrencePathsRecursive( + HierarchyOccurrence node, + List pathSoFar, + List queryParts, + int qIdx, + List results, + int limit, + ) { + if (results.length >= limit) { + return; + } + + // Try matching current node name against current query part + final nodeName = node.name; + final currentQuery = qIdx < queryParts.length ? queryParts[qIdx] : null; + final matched = currentQuery != null && nodeName.contains(currentQuery); + final nextIdx = matched ? qIdx + 1 : qIdx; + + // If all query parts are matched, this node is a result + if (nextIdx >= queryParts.length) { + final fullPath = pathSoFar.join(_hierarchySeparator); + results.add(fullPath); + if (results.length >= limit) { + return; + } + } + + // Recurse into children + for (final child in node.children) { + if (results.length >= limit) { + return; + } + _searchOccurrencePathsRecursive( + child, + [...pathSoFar, child.name], + queryParts, + nextIdx, + results, + limit, + ); + } + } + + /// Recursively search for occurrences matching query parts, returning + /// the occurrences. + void _matchOccurrencesRecursive( + HierarchyOccurrence node, + List queryParts, + int qIdx, + List results, + int limit) { + if (results.length >= limit) { + return; + } + + final matched = + qIdx < queryParts.length && node.name.contains(queryParts[qIdx]); + final nextIdx = matched ? qIdx + 1 : qIdx; + + if (nextIdx >= queryParts.length) { + results.add(node); + if (results.length >= limit) { + return; + } + } + + for (final child in node.children) { + _matchOccurrencesRecursive(child, queryParts, nextIdx, results, limit); + if (results.length >= limit) { + return; + } + } + } + + // ─────────────── Regex search helpers ─────────────── + + /// A compiled regex segment. `isGlobStar` indicates a `**` segment that + /// matches zero or more hierarchy levels. + static const _globStarSentinel = '**'; + + /// Split `pattern` into segments on `/` only. + /// + /// Unlike [_splitPath] (which also splits on `.`), regex patterns use only + /// `/` as the hierarchy separator because `.` has meaning inside regular + /// expressions (e.g. `.*`, `a.b`). + List _splitRegexPattern(String input) => + input.split('/').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); + + /// Convert glob-style `*` and `?` wildcards to regex equivalents. + /// + /// A standalone `*` (not preceded/followed by another regex metachar) + /// becomes `.*` (match anything). `?` becomes `.` (match one char). + /// This lets users write natural patterns like `*m`, `clk*`, `*data*` + /// without needing to know regex syntax. + String _globToRegex(String segment) { + final buf = StringBuffer(); + for (var i = 0; i < segment.length; i++) { + final c = segment[i]; + if (c == '*') { + // If already preceded by `.` (i.e. user wrote `.*`), skip conversion. + if (buf.toString().endsWith('.')) { + buf.write('*'); + } else { + buf.write('.*'); + } + } else if (c == '?') { + // If already preceded by a valid quantifier target, keep literal `?`. + // Otherwise treat as single-char wildcard `.`. + if (i > 0 && !'.?*+'.contains(segment[i - 1])) { + buf.write('?'); + } else { + buf.write('.'); + } + } else { + buf.write(c); + } + } + return buf.toString(); + } + + /// Compile string segments into [_RegexSegment] list. + /// + /// Each segment is first run through [_globToRegex] so that glob-style + /// wildcards (`*`, `?`) work alongside full regex syntax. + List<_RegexSegment> _compileSegments(List segments) => + segments.map((s) { + if (s == _globStarSentinel) { + return _RegexSegment.globStar(); + } + final pattern = _globToRegex(s); + // Anchor the regex to match the full name. + return _RegexSegment(RegExp('^$pattern\$')); + }).toList(); + + /// Recursive signal search driven by compiled regex segments. + /// + /// [segIdx] is the index into [segments] that we are currently trying to + /// match at this tree depth. + void _searchSignalsRegex( + HierarchyOccurrence node, + List pathSoFar, + List<_RegexSegment> segments, + int segIdx, + List results, + int limit, + ) { + if (results.length >= limit) { + return; + } + + // Determine how many segments remain after consuming the current node. + final consumed = _matchNode(node.name, segments, segIdx); + + for (final nextIdx in consumed) { + if (results.length >= limit) { + return; + } + + // Try to match signals at this node. + // Find all indices reachable from nextIdx by skipping glob-stars + // where a signal-level regex (or end-of-pattern) can be applied. + for (final sigIdx in _signalReachableIndices(segments, nextIdx)) { + if (results.length >= limit) { + return; + } + if (sigIdx >= segments.length) { + // All segments consumed: collect all signals at this node. + for (final signal in node.signals) { + if (results.length >= limit) { + return; + } + results.add([...pathSoFar, signal.name].join(_hierarchySeparator)); + } + } else { + // sigIdx points to a non-** regex that should match signal names. + final sigSeg = segments[sigIdx]; + // Only use as signal-level match if this is the last non-** segment + // (possibly followed by more **'s that can match zero levels). + if (_allGlobStarAfter(segments, sigIdx + 1)) { + for (final signal in node.signals) { + if (results.length >= limit) { + return; + } + if (sigSeg.regex!.hasMatch(signal.name)) { + results + .add([...pathSoFar, signal.name].join(_hierarchySeparator)); + } + } + } + } + } + + // Recurse into children. + for (final child in node.children) { + if (results.length >= limit) { + return; + } + _searchSignalsRegex( + child, + [...pathSoFar, child.name], + segments, + nextIdx, + results, + limit, + ); + } + } + } + + /// Recursive occurrence search driven by compiled regex segments. + void _matchOccurrencesRegex( + HierarchyOccurrence node, + List pathSoFar, + List<_RegexSegment> segments, + int segIdx, + List results, + int limit, + ) { + if (results.length >= limit) { + return; + } + + final consumed = _matchNode(node.name, segments, segIdx); + + for (final nextIdx in consumed) { + if (results.length >= limit) { + return; + } + + // All segments consumed (or only trailing **'s remain) → match. + if (_allGlobStarAfter(segments, nextIdx)) { + results.add(pathSoFar.join(_hierarchySeparator)); + if (results.length >= limit) { + return; + } + } + + // Recurse into children. + for (final child in node.children) { + if (results.length >= limit) { + return; + } + _matchOccurrencesRegex( + child, + [...pathSoFar, child.name], + segments, + nextIdx, + results, + limit, + ); + } + } + } + + /// Try to match [nodeName] against the segment at [segIdx]. + /// + /// Returns a set of possible next-segment indices (branching is needed + /// because `**` can consume zero or more levels). + Set _matchNode( + String nodeName, List<_RegexSegment> segments, int segIdx) { + final results = {}; + if (segIdx >= segments.length) { + // No more segments to match — nothing to advance to. + return results; + } + + final seg = segments[segIdx]; + + if (seg.isGlobStar) { + // ** matches zero levels (skip the **) … + results + ..addAll(_matchNode(nodeName, segments, segIdx + 1)) + // … or consumes this node and stays at ** (one-or-more levels). + ..add(segIdx); + } else if (seg.regex!.hasMatch(nodeName)) { + results.add(segIdx + 1); + } + // If the segment doesn't match at all, return empty → prune this branch. + return results; + } + + /// Returns indices in [segments] reachable from [fromIdx] by skipping + /// consecutive `**` glob-star segments. Always includes [fromIdx] itself + /// if it is in range (or == segments.length, meaning "past the end"). + Set _signalReachableIndices(List<_RegexSegment> segments, int fromIdx) { + final result = {}; + var i = fromIdx; + // Walk forward: each time we see a **, we can skip it (zero levels). + while (i < segments.length) { + if (segments[i].isGlobStar) { + // ** can match zero levels → skip and also record i (stay at **). + result.add(i + 1); // skip the ** + i++; + } else { + result.add(i); + break; // stop at first non-** segment + } + } + // If we walked past the end, record that too. + if (i >= segments.length) { + result.add(segments.length); + } + return result; + } + + /// Returns true if all segments from [fromIdx] onward are glob-stars + /// (or if [fromIdx] >= length, i.e. no more segments). + bool _allGlobStarAfter(List<_RegexSegment> segments, int fromIdx) => + segments.skip(fromIdx).every((s) => s.isGlobStar); +} + +/// Internal representation of a compiled regex segment. +class _RegexSegment { + final RegExp? regex; + final bool isGlobStar; + + _RegexSegment(this.regex) : isGlobStar = false; + _RegexSegment.globStar() + : regex = null, + isGlobStar = true; +} diff --git a/packages/rohd_hierarchy/lib/src/netlist_hierarchy_adapter.dart b/packages/rohd_hierarchy/lib/src/netlist_hierarchy_adapter.dart new file mode 100644 index 000000000..6ec675293 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/netlist_hierarchy_adapter.dart @@ -0,0 +1,224 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_hierarchy_adapter.dart +// Hierarchy adapter for netlist JSON format (derived from Yosys JSON) +// using rohd_hierarchy. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'dart:convert'; + +import 'package:rohd_hierarchy/src/base_hierarchy_adapter.dart'; +import 'package:rohd_hierarchy/src/hierarchy_models.dart'; + +/// Adapter that exposes a netlist as a source-agnostic hierarchy. +/// +/// Extends [BaseHierarchyAdapter] from rohd_hierarchy package, using the shared +/// implementation for search, autocomplete, and lookup methods. +/// Only the netlist format-specific +/// JSON parsing logic is implemented here. +/// +/// Features: +/// - Parses ports, netnames, and cells from netlist JSON +/// - Filters auto-generated netnames (`hide_name`, `$`-prefixed, port dupes) +/// - Extracts `port_directions` on primitive cells for signal visibility +/// - Supports optional root-name override for VCD name alignment +class NetlistHierarchyAdapter extends BaseHierarchyAdapter { + NetlistHierarchyAdapter._(); + + /// Convenience factory to parse a netlist JSON string directly. + /// + /// [rootNameOverride] replaces the top-module name derived from the JSON. + /// Use this when VCD scopes use instance names that differ from the + /// definition names in the netlist output (often capitalized). + factory NetlistHierarchyAdapter.fromJson( + String netlistJson, { + String? rootNameOverride, + }) { + final obj = jsonDecode(netlistJson); + if (obj is! Map) { + throw const FormatException('Invalid netlist JSON root'); + } + return NetlistHierarchyAdapter.fromMap( + obj, + rootNameOverride: rootNameOverride, + ); + } + + /// Factory to parse a pre-decoded netlist JSON map. + /// + /// [netlistJson] must contain a top-level `modules` key. + /// [rootNameOverride] optionally replaces the detected top-module name. + factory NetlistHierarchyAdapter.fromMap( + Map netlistJson, { + String? rootNameOverride, + }) { + final adapter = NetlistHierarchyAdapter._() + .._buildFromNetlist(netlistJson, rootNameOverride: rootNameOverride); + return adapter; + } + + void _buildFromNetlist( + Map netlistJson, { + String? rootNameOverride, + }) { + final modules = netlistJson['modules'] as Map?; + if (modules == null || modules.isEmpty) { + throw const FormatException('Netlist JSON contained no modules'); + } + + // Find top module or default to first + final topName = modules.entries + .where( + (e) => + ((e.value as Map)['attributes'] + as Map?)?['top'] == + 1, + ) + .map((e) => e.key) + .firstOrNull ?? + modules.keys.first; + + final resolvedRootName = rootNameOverride ?? topName; + + final rootNode = _parseModule( + name: resolvedRootName, + definition: topName, + moduleData: modules[topName] as Map, + allModules: modules, + ); + root = rootNode; + rootNode.buildAddresses(); + } + + /// Parse a module definition and return the created + /// [HierarchyOccurrence]. + HierarchyOccurrence _parseModule({ + required String name, + required String definition, + required Map moduleData, + required Map allModules, + }) { + // Ports (signals with direction) + final portsData = moduleData['ports'] as Map?; + final signalsList = [ + if (portsData != null) + ...portsData.entries.indexed.map((entry) { + final (idx, kv) = entry; + final p = kv.value as Map; + final dir = p['direction']?.toString() ?? 'inout'; + final bits = (p['bits'] as List?)?.length ?? 0; + final logicType = p['logic_type'] as Map?; + return SignalOccurrence( + name: kv.key, + direction: dir, + width: bits > 0 ? bits : 1, + portIndex: idx, + logicType: logicType, + ); + }), + ]; + + // Netnames (internal signals without direction). + // Netlist `netnames` contains ALL named signals including port-connected + // ones. We skip names already covered by `ports` above, as well as + // auto-generated names (hide_name=1 or $-prefixed). + final netsData = moduleData['netnames'] as Map?; + if (netsData != null) { + final portNames = portsData?.keys.toSet() ?? {}; + signalsList.addAll( + netsData.entries + .where( + (entry) => + !portNames.contains(entry.key) && + !entry.key.startsWith(r'$') && + () { + final h = (entry.value as Map)['hide_name']; + return h != 1 && h != '1'; + }(), + ) + .map((entry) { + final netData = entry.value as Map; + final bits = (netData['bits'] as List?)?.length ?? 0; + final attrs = netData['attributes'] as Map?; + final isComputed = + attrs?['computed'] == 1 || attrs?['computed'] == true; + final logicType = netData['logic_type'] as Map?; + return SignalOccurrence( + name: entry.key, + width: bits > 0 ? bits : 1, + isComputed: isComputed, + logicType: logicType, + ); + }), + ); + } + + // Cells -> submodules or instances + final childNodes = []; + final cells = moduleData['cells'] as Map?; + if (cells != null) { + for (final entry in cells.entries) { + final cellName = entry.key; + final cellData = entry.value as Map; + final cellType = cellData['type']?.toString() ?? ''; + + if (allModules.containsKey(cellType) && + !HierarchyOccurrence.isPrimitiveType(cellType)) { + final childNode = _parseModule( + name: cellName, + definition: cellType, + moduleData: allModules[cellType] as Map, + allModules: allModules, + ); + childNodes.add(childNode); + } else { + // Primitive cell — create leaf occurrence. + // Extract port signals from `port_directions` when available so + // that primitive I/O appears in signal search results. + final isCellComputed = cellType.startsWith(r'$'); + final portDirections = + cellData['port_directions'] as Map?; + final connections = cellData['connections'] as Map?; + final portWidths = cellData['port_widths'] as Map?; + final cellSignals = [ + if (portDirections != null) + ...portDirections.entries.indexed.map((pEntry) { + final (pIdx, kv) = pEntry; + final pName = kv.key; + final pDir = kv.value.toString(); + final bits = (connections?[pName] as List?)?.length ?? + (portWidths?[pName] as int?) ?? + 1; + return SignalOccurrence( + name: pName, + direction: pDir, + width: bits, + isComputed: isCellComputed, + portIndex: pIdx, + ); + }), + ]; + + final instNode = HierarchyOccurrence( + name: cellName, + definition: cellType, + isPrimitive: true, + signals: cellSignals, + ); + childNodes.add(instNode); + } + } + } + + // Create the occurrence with children and signals embedded + return HierarchyOccurrence( + name: name, + definition: definition, + signals: signalsList, + children: childNodes, + ); + } +} diff --git a/packages/rohd_hierarchy/lib/src/occurrence_address.dart b/packages/rohd_hierarchy/lib/src/occurrence_address.dart new file mode 100644 index 000000000..b8a9c8bba --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/occurrence_address.dart @@ -0,0 +1,142 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// occurrence_address.dart +// Efficient hierarchical address using indices instead of strings. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; + +import 'package:rohd_hierarchy/src/hierarchy_occurrence.dart'; + +/// Efficient hierarchical address using indices instead of strings. +/// +/// Format: [index0, index1, ...] or [] for root. +/// Example: [0, 2, 4] means root's 0th child, then 2nd child of that, then +/// the 4th child (occurrence) or 4th signal, depending on context. +/// +/// Advantages: +/// - O(1) address creation (just append index) +/// - O(depth) tree navigation (direct array indexing) +/// - Deterministic serialization (no parsing needed) +/// - Natural alignment with waveform dictionary (integer indices) +/// - Supports hierarchical queries (ancestor matching, batching by prefix) +/// +/// This replaces string-based path lookups with typed, semantic addressing. +@immutable +class OccurrenceAddress { + /// Path through tree as indices stored as immutable list. + /// Empty list represents the root occurrence. + /// Non-empty list: indices navigate through the hierarchy. The last index + /// refers to either a child occurrence or a signal, depending on context. + final List path; + + /// Create a hierarchy address from a path list. + const OccurrenceAddress(this.path); + + /// Root address (empty path). + static const OccurrenceAddress root = OccurrenceAddress([]); + + /// Create a child address by appending an occurrence index. + /// Use this when navigating to a child occurrence. + OccurrenceAddress child(int childIndex) => + OccurrenceAddress([...path, childIndex]); + + /// Create a signal address by appending signal index. + /// Use this when addressing a signal within current occurrence. + OccurrenceAddress signal(int signalIndex) => + OccurrenceAddress([...path, signalIndex]); + + /// Serialize to a dot-separated string suitable for use as a JSON key. + /// + /// Examples: `""` (root), `"0"`, `"0.2.4"`. + /// Round-trips with [OccurrenceAddress.fromDotString]. + String toDotString() => path.join('.'); + + /// Deserialize from a dot-separated string produced by [toDotString]. + /// + /// An empty string returns [root]. + factory OccurrenceAddress.fromDotString(String s) { + if (s.isEmpty) { + return root; + } + return OccurrenceAddress(s.split('.').map(int.parse).toList()); + } + + @override + String toString() { + if (path.isEmpty) { + return '[ROOT]'; + } + return '[${path.join(".")}]'; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is OccurrenceAddress && + const ListEquality().equals(path, other.path); + + @override + int get hashCode => Object.hashAll(path); + + /// Resolve a pathname string (e.g. `"Top/counter/clk"` or + /// `"Top.counter.clk"`) to a [OccurrenceAddress] by walking [root]. + /// + /// Supports both `/` and `.` as separators. If the first segment + /// matches [root]'s name, it is skipped — the root + /// occurrence is always at the empty address. + /// + /// The last segment is first tried as a **signal** name within the + /// current occurrence; if that fails it is tried as a **child** + /// occurrence name. + /// This mirrors the pathname convention where a signal path has one more + /// segment than its parent module path. + /// + /// Returns `null` if any segment cannot be resolved. + /// + /// ```dart + /// final addr = OccurrenceAddress.tryFromPathname('Top/cpu/clk', root); + /// if (addr != null) { + /// final signal = service.signalByAddress(addr); + /// } + /// ``` + static OccurrenceAddress? tryFromPathname( + String pathname, + HierarchyOccurrence root, + ) { + final rootAddr = root.address ?? OccurrenceAddress.root; + final parts = pathname + .replaceAll('.', '/') + .split('/') + .where((s) => s.isNotEmpty) + .toList(); + + // Skip leading segment that matches the root name. + final segments = + parts.isNotEmpty && parts.first == root.name ? parts.skip(1) : parts; + + ({HierarchyOccurrence node, OccurrenceAddress addr})? step( + ({HierarchyOccurrence node, OccurrenceAddress addr})? cur, + String segment, + ) { + if (cur == null) { + return null; + } + final si = cur.node.signalIndexByName(segment); + if (identical(segment, segments.last) && si >= 0) { + return (node: cur.node, addr: cur.addr.signal(si)); + } + final ci = cur.node.childIndexByName(segment); + return ci >= 0 + ? (node: cur.node.children[ci], addr: cur.addr.child(ci)) + : null; + } + + return segments.fold<({HierarchyOccurrence node, OccurrenceAddress addr})?>( + (node: root, addr: rootAddr), step)?.addr; + } +} diff --git a/packages/rohd_hierarchy/lib/src/occurrence_search_result.dart b/packages/rohd_hierarchy/lib/src/occurrence_search_result.dart new file mode 100644 index 000000000..fafe8623c --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/occurrence_search_result.dart @@ -0,0 +1,45 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// occurrence_search_result.dart +// Result of a module/node search with enriched metadata. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:meta/meta.dart'; + +import 'package:rohd_hierarchy/src/hierarchy_occurrence.dart'; +import 'package:rohd_hierarchy/src/hierarchy_search_result.dart'; + +/// Result of an occurrence search with enriched metadata. +/// +/// Contains the occurrence's full path, parsed path segments, and the full +/// [HierarchyOccurrence] object. This mirrors `SignalSearchResult` for +/// occurrences and provides a consistent search results interface. +@immutable +class OccurrenceSearchResult extends HierarchySearchResult { + /// Alias for [id] — the occurrence's full hierarchical path. + String get occurrenceId => id; + + /// The underlying [HierarchyOccurrence] from the hierarchy service. + /// Contains the occurrence's name, type, children, and signals. + final HierarchyOccurrence occurrence; + + /// Creates an occurrence search result. + const OccurrenceSearchResult({ + required String occurrenceId, + required super.path, + required this.occurrence, + }) : super(id: occurrenceId); + + /// Whether this occurrence has sub-hierarchy (i.e. is not a primitive + /// leaf). + bool get isModule => !occurrence.isPrimitive; + + /// Number of direct child occurrences. + int get childCount => occurrence.children.length; + + @override + String toString() => 'OccurrenceSearchResult($id)'; +} diff --git a/packages/rohd_hierarchy/lib/src/prefix_query.dart b/packages/rohd_hierarchy/lib/src/prefix_query.dart new file mode 100644 index 000000000..2ce617683 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/prefix_query.dart @@ -0,0 +1,60 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// prefix_query.dart +// Prefix-substring query implementation for hierarchy search. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/src/hierarchy_query.dart'; + +/// Prefix-substring query: segments are matched via `startsWith` (signals) +/// or `contains` (occurrences) at successive hierarchy depths. +class PrefixQuery extends HierarchyQuery { + /// Non-empty segments parsed from the raw query. + late final List segments; + + /// Create a prefix query from [rawQuery]. + PrefixQuery( + super.rawQuery, { + super.target = SearchTarget.signals, + }) : super(crossesBoundaries: false) { + segments = rawQuery + .replaceAll('.', '/') + .split('/') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + } + + @override + int get segmentCount => segments.length; + + @override + Set matchOccurrence(String occurrenceName, int stateIndex) { + if (stateIndex >= segments.length) { + return {stateIndex}; + } + final name = occurrenceName; + if (name.contains(segments[stateIndex])) { + return {stateIndex + 1}; + } + return const {}; + } + + @override + bool matchSignal(String signalName, int stateIndex) { + if (stateIndex >= segments.length) { + return true; + } + // Only the last segment can match a signal name. + if (stateIndex != segments.length - 1) { + return false; + } + return signalName.startsWith(segments[stateIndex]); + } + + @override + bool isComplete(int stateIndex) => stateIndex >= segments.length; +} diff --git a/packages/rohd_hierarchy/lib/src/regex_query.dart b/packages/rohd_hierarchy/lib/src/regex_query.dart new file mode 100644 index 000000000..6c0ebfe41 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/regex_query.dart @@ -0,0 +1,176 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// regex_query.dart +// Regex/glob query implementation for hierarchy search. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/src/hierarchy_query.dart'; + +/// Regex/glob query: each segment is a compiled regex, with `**` support +/// for crossing hierarchy boundaries. +/// +/// ## Segment syntax +/// +/// The query string is split on `/` into segments. Each segment is +/// independently compiled as a case-insensitive [RegExp] anchored to the +/// full occurrence or signal name (`^…$`). This means: +/// +/// - **Plain names** match exactly: `Top/CPU/clk`. +/// - **Glob wildcards** are auto-converted before compilation: +/// - `*` → `.*` (match any characters) +/// - `?` → `.` (match one character) +/// - These compose naturally: `clk*` matches `clk`, `clk_gated`, +/// `clk_div2`, etc. +/// - **Full regex** is supported within each segment since the string +/// is passed to [RegExp]: +/// - `d[0-9]+` — signals named `d0`, `d1`, `d12`, … +/// - `(clk|reset)` — either `clk` or `reset` +/// - `data_[a-z]{2}` — `data_ab`, `data_xy`, … +/// - `.*mux.*` — any name containing `mux` +/// - `ch[0-3]` — `ch0`, `ch1`, `ch2`, `ch3` +/// - `r[0-9]{1,2}` — `r0` through `r99` +/// - **`**`** (double-star, as its own segment) matches zero or more +/// hierarchy levels, allowing searches to cross boundaries: +/// - `Top/**/clk` — `clk` at any depth below `Top` +/// - `**/d[0-9]+` — any signal like `d0` anywhere +/// - `Top/**/ch*/data_*` — `data_*` signals inside `ch*` modules +/// +/// ## Interaction between glob and regex +/// +/// Glob conversion happens *before* regex compilation, so `*` and `?` +/// are always expanded. If you need a literal `*` or `?` in the regex, +/// escape them: `\*`, `\?`. All other regex metacharacters (`.`, `+`, +/// `|`, `(`, `)`, `[`, `]`, `{`, `}`, `^`, `$`) work as-is inside +/// each segment. +/// +/// ## Examples +/// +/// ```text +/// Query Matches +/// ───────────────────────────────────────────────────────────── +/// Top/CPU/clk exact: Top → CPU → clk +/// Top/CPU/* all signals in Top/CPU +/// Top/*/clk clk one level below Top +/// Top/**/clk clk at any depth below Top +/// Top/**/c.* signals starting with 'c' anywhere +/// **/clk clk anywhere in hierarchy +/// **/(clk|reset) clk or reset anywhere +/// Top/CPU/d[0-9]+ d0, d1, d12, … in Top/CPU +/// Top/**/ch[0-3]/data_* data_* in ch0–ch3 at any depth +/// Top/mem_*/addr[0-9]* addr0, addr1, … in mem_* modules +/// **/.*mux.* any name containing 'mux' anywhere +/// ``` +class RegexQuery extends HierarchyQuery { + /// Compiled segments — either a regex or a glob-star sentinel. + late final List segments; + + /// Create a regex query from [rawQuery]. + /// + /// A standalone `*` is converted to `.*`, `?` to `.`. The segment + /// `**` matches zero or more hierarchy levels. + RegexQuery( + super.rawQuery, { + super.target = SearchTarget.signals, + }) : super(crossesBoundaries: false) { + final parts = rawQuery + .split('/') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + segments = parts.map((s) { + if (s == '**') { + return RegexSegment.globStar(); + } + final pattern = _globToRegex(s); + return RegexSegment(RegExp('^$pattern\$')); + }).toList(); + } + + @override + int get segmentCount => segments.length; + + @override + Set matchOccurrence(String occurrenceName, int stateIndex) { + if (stateIndex >= segments.length) { + return const {}; + } + final seg = segments[stateIndex]; + final results = {}; + if (seg.isGlobStar) { + // ** matches zero levels (skip) … + results + ..addAll(matchOccurrence(occurrenceName, stateIndex + 1)) + // … or consumes this node and stays at ** (one-or-more levels). + ..add(stateIndex); + } else if (seg.regex!.hasMatch(occurrenceName)) { + results.add(stateIndex + 1); + } + return results; + } + + @override + bool matchSignal(String signalName, int stateIndex) { + // Walk past any trailing **'s to find the signal-matching segment. + var i = stateIndex; + while (i < segments.length && segments[i].isGlobStar) { + i++; + } + if (i >= segments.length) { + return true; // all consumed + } + // The segment at i must be the last real regex. + if (!_allGlobStarAfter(i + 1)) { + return false; + } + return segments[i].regex!.hasMatch(signalName); + } + + @override + bool isComplete(int stateIndex) => + stateIndex >= segments.length || + segments.skip(stateIndex).every((s) => s.isGlobStar); + + /// Check if all segments from [fromIdx] onward are glob-stars. + bool _allGlobStarAfter(int fromIdx) => + segments.skip(fromIdx).every((s) => s.isGlobStar); + + /// Convert glob wildcards to regex equivalents. + static String _globToRegex(String segment) { + final buf = StringBuffer(); + for (var i = 0; i < segment.length; i++) { + final c = segment[i]; + if (c == '*') { + if (buf.toString().endsWith('.')) { + buf.write('*'); + } else { + buf.write('.*'); + } + } else if (c == '?') { + buf.write('.'); + } else { + buf.write(c); + } + } + return buf.toString(); + } +} + +/// A compiled regex segment for [RegexQuery]. +class RegexSegment { + /// The compiled regex, or null for glob-star segments. + final RegExp? regex; + + /// Whether this segment is a `**` glob-star. + final bool isGlobStar; + + /// Create a regex segment. + RegexSegment(this.regex) : isGlobStar = false; + + /// Create a glob-star segment (`**`). + RegexSegment.globStar() + : regex = null, + isGlobStar = true; +} diff --git a/packages/rohd_hierarchy/lib/src/signal_occurrence.dart b/packages/rohd_hierarchy/lib/src/signal_occurrence.dart new file mode 100644 index 000000000..d780cff8d --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/signal_occurrence.dart @@ -0,0 +1,273 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_occurrence.dart +// A signal in the hardware occurrence hierarchy. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:meta/meta.dart'; +import 'package:rohd_hierarchy/src/hierarchy_occurrence.dart'; +import 'package:rohd_hierarchy/src/occurrence_address.dart'; + +/// Signals are the fundamental data carriers in hardware. A signal can be: +/// - An internal signal within an occurrence +/// - A port on an occurrence interface (has direction: input/output/inout) +/// +/// This is a structural model without waveform data. Path strings are +/// computed on demand from the parent occurrence reference — call [path] +/// with your desired separator. +class SignalOccurrence { + /// The name of the signal (bare name within its scope). + /// + /// Used for display, search, and local lookups within an occurrence. + /// Not guaranteed unique across the full hierarchy — use [path] for + /// unique keying. + final String name; + + /// The bit width of the signal. + final int width; + + /// Direction of the signal if it's a port. + /// Null for internal signals. + /// "input", "output", or "inout" for ports. + final String? direction; + + /// Current runtime value of the signal (if available). + /// Typically a hex or binary string representation. + final String? value; + + /// Whether this signal's value is computed/derivable (e.g. constant, + /// gate output, InlineSystemVerilog result) rather than directly tracked + /// by the waveform service. + final bool isComputed; + + /// Stable ordering index among ports in the parent occurrence. + /// + /// Set by the adapter that creates the signal. For ports (signals with + /// a [direction]), this records the deterministic position from the + /// original source (netlist JSON iteration order, ROHD module port + /// declaration order, etc.). Internal signals have `null`. + /// + /// [HierarchyOccurrence.buildAddresses] places ports before internal + /// signals when assigning [OccurrenceAddress] indices, so a port with + /// `portIndex == k` will receive signal address index `k`. + /// + /// Consumers that store connectivity by `(nodeId, portIndex)` tuples + /// (e.g. schematic hyperedges) rely on this value remaining stable + /// across incremental hierarchy expansion. + final int? portIndex; + + /// Type metadata from the netlist `logic_type` JSON field. + /// + /// For a **LogicStructure** (non-array), the format is: + /// ```json + /// {"typeName": "FloatingPoint", "fields": [ + /// {"name": "mantissa", "width": 4, "bits": [0,1,2,3]}, + /// {"name": "exponent", "width": 4, "bits": [4,5,6,7]}, + /// {"name": "sign", "width": 1, "bits": [8]} + /// ]} + /// ``` + /// + /// For a **LogicArray**, the format is: + /// ```json + /// {"width": 80, "arrayDims": [10], "elementWidth": 8} + /// ``` + /// + /// For a plain signal: `{"width": N}` or `null`. + /// + /// Nested structs have a recursive `"type"` key in their field entries. + Map? logicType; + + /// Hierarchical address for this signal. Assigned by + /// [HierarchyOccurrence.buildAddresses] to enable efficient navigation. + /// Format: [...occurrenceIndices, signalIndex] + OccurrenceAddress? get address => _address; + OccurrenceAddress? _address; + + /// Sets the address. Only for use by [HierarchyOccurrence.buildAddresses]. + @internal + set address(OccurrenceAddress? value) => _address = value; + + /// Parent occurrence containing this signal. Set by + /// [HierarchyOccurrence.buildAddresses]. + HierarchyOccurrence? get parent => _parent; + HierarchyOccurrence? _parent; + + /// Sets the parent. Only for use by [HierarchyOccurrence.buildAddresses]. + @internal + set parent(HierarchyOccurrence? value) => _parent = value; + + /// Creates a [SignalOccurrence] with the given properties. + SignalOccurrence({ + required this.name, + required this.width, + this.direction, + this.value, + this.isComputed = false, + this.portIndex, + this.logicType, + }); + + /// Whether this signal is a LogicStructure (has named sub-fields). + bool get isStruct => logicType != null && logicType!.containsKey('fields'); + + /// Whether this signal is a LogicArray (has indexed elements). + bool get isArray => logicType != null && logicType!.containsKey('arrayDims'); + + /// The struct type name (e.g. "FloatingPoint"), or null if not a struct. + String? get typeName => logicType?['typeName'] as String?; + + /// The struct field descriptors, or empty list if not a struct. + /// + /// Each field is `{"name": ..., "width": ..., "bits": [...]}` with an + /// optional `"type"` key for nested structs/arrays. + List> get structFields => + (logicType?['fields'] as List?)?.cast>() ?? + const []; + + /// Array dimensions (e.g. `[10]` for 1D, `[10, 2]` for 2D), or null. + List? get arrayDims => + (logicType?['arrayDims'] as List?)?.cast(); + + /// Element width for arrays, or null if not an array. + int? get arrayElementWidth => logicType?['elementWidth'] as int?; + + /// Returns the expected sub-field signal names derived from [logicType]. + /// + /// For structs, the synthesizer creates separate netnames for each field + /// following the Namer/Sanitizer conventions: + /// `Sanitizer.sanitizeSV(structureName)` → `{parentName}_{fieldName}` + /// + /// For example, signal `fp` with fields `mantissa`, `exponent`, `sign` + /// produces sub-field signal names: `fp_mantissa`, `fp_exponent`, `fp_sign`. + /// + /// These become separate [SignalOccurrence] entries in the same parent + /// module. Use `HierarchyOccurrence.findSubFieldSignals` to look + /// them up. + /// + /// Returns a list of `(expectedName, fieldLabel, width, startBit, + /// subLogicType)` for direct children. `expectedName` follows the + /// `{parentSignalName}_{fieldName}` convention. + /// `subLogicType` is non-null when the child is itself a sub-array + /// (remaining dimensions) and can be further expanded. + /// Empty if this is not a struct/array with known sub-fields. + List< + ({ + String expectedName, + String fieldLabel, + int width, + int startBit, + Map? subLogicType, + })> get subFieldDescriptors { + if (logicType == null) { + return const []; + } + return subFieldDescriptorsForType(logicType!, name); + } + + /// Compute sub-field descriptors for an arbitrary [logicType] map. + /// + /// [parentName] is used to derive expected signal names. + /// This is static so it can be called recursively for nested arrays + /// without needing a full [SignalOccurrence]. + static List< + ({ + String expectedName, + String fieldLabel, + int width, + int startBit, + Map? subLogicType, + })> subFieldDescriptorsForType( + Map logicType, + String parentName, + ) { + final fields = logicType['fields'] as List?; + if (fields != null) { + return fields.map((f) { + final field = f as Map; + final fieldName = field['name'] as String? ?? '?'; + final width = field['width'] as int? ?? 1; + final bits = field['bits'] as List?; + final startBit = bits != null && bits.isNotEmpty + ? (bits.cast().reduce((a, b) => a < b ? a : b)) + : 0; + // Naming convention: Sanitizer.sanitizeSV("$parentName.$fieldName") + // which produces "$parentName_$fieldName" + final expectedName = '${parentName}_$fieldName'; + return ( + expectedName: expectedName, + fieldLabel: fieldName, + width: width, + startBit: startBit, + subLogicType: field['type'] as Map?, + ); + }).toList(); + } + + final arrayDims = logicType['arrayDims'] as List?; + if (arrayDims != null && arrayDims.isNotEmpty) { + final leafWidth = (logicType['elementWidth'] as int?) ?? 1; + final outerDim = arrayDims.first as int; + // For multi-dimensional arrays, each outer element spans all + // remaining dimensions times the leaf element width. + final remainingDims = + arrayDims.length > 1 ? arrayDims.sublist(1).cast() : []; + final elementWidth = remainingDims.isEmpty + ? leafWidth + : remainingDims.fold(leafWidth, (acc, d) => acc * d); + + // Build sub-logicType for remaining dimensions (if any). + final subLogicType = remainingDims.isEmpty + ? null + : { + 'width': elementWidth, + 'arrayDims': remainingDims, + 'elementWidth': leafWidth, + }; + + return List.generate(outerDim, (i) { + // Naming convention: Sanitizer.sanitizeSV("$parentName[$i]") + // which produces "$parentName_${i}_" + final expectedName = '${parentName}_${i}_'; + return ( + expectedName: expectedName, + fieldLabel: '[$i]', + width: elementWidth, + startBit: i * elementWidth, + subLogicType: subLogicType, + ); + }); + } + + return const []; + } + + /// Compute the full hierarchical path for this signal. + /// + /// Joins the parent occurrence's path with this signal's [name] using + /// [separator]. Falls back to just [name] if parent is not yet set + /// (e.g. in test fixtures before `buildAddresses`). + String path({String separator = '/'}) { + if (_parent == null) { + return name; + } + return '${_parent!.path(separator: separator)}$separator$name'; + } + + /// Returns true if this signal is a port (has a direction). + bool get isPort => direction != null; + + /// Returns true if this is an input port. + bool get isInput => direction == 'input'; + + /// Returns true if this is an output port. + bool get isOutput => direction == 'output'; + + /// Returns true if this is a bidirectional port. + bool get isInout => direction == 'inout'; + + @override + String toString() => '$name (width=$width${isPort ? ', $direction' : ''})'; +} diff --git a/packages/rohd_hierarchy/lib/src/signal_search_result.dart b/packages/rohd_hierarchy/lib/src/signal_search_result.dart new file mode 100644 index 000000000..970855f74 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/signal_search_result.dart @@ -0,0 +1,49 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_search_result.dart +// Result of a signal search with enriched metadata. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:meta/meta.dart'; + +import 'package:rohd_hierarchy/src/hierarchy_search_result.dart'; +import 'package:rohd_hierarchy/src/signal_occurrence.dart'; + +/// Result of a signal search with enriched metadata. +/// +/// Contains the signal's full path, parsed path segments, and the full +/// [SignalOccurrence] object if available. This is the hierarchy-only portion +/// of search results; UI layers can use the pre-computed display helpers +/// directly without re-parsing paths. +@immutable +class SignalSearchResult extends HierarchySearchResult { + /// Alias for [id] — the signal's full hierarchical path. + String get signalId => id; + + /// The underlying [SignalOccurrence] from the hierarchy service (if + /// available). Contains width, direction, and other signal metadata. + final SignalOccurrence? signal; + + /// Creates a signal search result. + const SignalSearchResult({ + required String signalId, + required super.path, + this.signal, + }) : super(id: signalId); + + /// Occurrence names that need to be expanded to reveal this signal. + /// + /// These are the intermediate path segments between the top occurrence + /// and the signal name — i.e. everything except the first (top + /// occurrence) and last (signal name) segments. + /// + /// For `Top/sub1/sub2/clk` this returns `["sub1", "sub2"]`. + List get intermediateOccurrenceNames => + path.length > 2 ? path.sublist(1, path.length - 1) : const []; + + @override + String toString() => 'SignalSearchResult($id, width=${signal?.width ?? "?"})'; +} diff --git a/packages/rohd_hierarchy/pubspec.yaml b/packages/rohd_hierarchy/pubspec.yaml new file mode 100644 index 000000000..68c9bc4c8 --- /dev/null +++ b/packages/rohd_hierarchy/pubspec.yaml @@ -0,0 +1,19 @@ +name: rohd_hierarchy +description: "Generic hierarchy data models for hardware module navigation - HierarchyNode, Port, and HierarchyService." +homepage: https://intel.github.io/rohd-website/ +repository: https://github.com/intel/rohd +version: 0.1.0 +issue_tracker: https://github.com/intel/rohd/issues + +publish_to: none + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + collection: ^1.15.0 + meta: ^1.9.0 + +dev_dependencies: + lints: ^3.0.0 + test: ^1.17.3 diff --git a/packages/rohd_hierarchy/test/adapter_search_parity_test.dart b/packages/rohd_hierarchy/test/adapter_search_parity_test.dart new file mode 100644 index 000000000..cde3a301a --- /dev/null +++ b/packages/rohd_hierarchy/test/adapter_search_parity_test.dart @@ -0,0 +1,285 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// adapter_search_parity_test.dart +// Baseline tests verifying that search produces identical results +// regardless of which adapter populated the HierarchyService. +// +// 2026 April +// Author: Desmond Kirkpatrick + +// This is the key contract: once a HierarchyService is built, callers +// cannot tell whether the data came from VCD (BaseHierarchyAdapter.fromTree), +// netlist JSON (NetlistHierarchyAdapter), or any other source. + +import 'dart:convert'; + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +/// Concrete subclass that does NOT set root, so we can test the +/// StateError thrown by uninitialized access. +class _UnsetAdapter extends BaseHierarchyAdapter {} + +/// Resolve a pathname to a [SignalOccurrence] via +/// [OccurrenceAddress.tryFromPathname]. +SignalOccurrence? _resolve(HierarchyService svc, String path) { + final addr = OccurrenceAddress.tryFromPathname(path, svc.root); + if (addr == null) { + return null; + } + return svc.signalByAddress(addr); +} + +// ────────────────────────────────────────────────────────────────────── +// Build the SAME design via two different adapter paths +// ────────────────────────────────────────────────────────────────────── + +/// VCD-style: HierarchyNode tree with children/signals populated inline. +/// This is what `wellen` produces when loading a VCD/FST file. +BaseHierarchyAdapter _buildVcdAdapter() => BaseHierarchyAdapter.fromTree( + HierarchyOccurrence( + name: 'Abcd', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'resetn', width: 1), + SignalOccurrence(name: 'arvalid_s', width: 1), + ], + children: [ + HierarchyOccurrence( + name: 'lab', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'fromUpstream_request__st', width: 64), + ], + children: [ + HierarchyOccurrence( + name: 'cam', + signals: [ + SignalOccurrence(name: 'hit', width: 1), + SignalOccurrence(name: 'entry', width: 32), + ], + ), + ], + ), + ], + ), + ); + +/// Netlist JSON-style: flat-map adapter (like what DevTools/schematic viewer +/// builds from ROHD inspector JSON or netlist JSON). +/// Children and signals live in the adapter's flat maps, NOT inside +/// the HierarchyNode objects. +NetlistHierarchyAdapter _buildJsonAdapter() => + NetlistHierarchyAdapter.fromJson(jsonEncode({ + 'modules': { + 'Abcd': { + 'attributes': {'top': 1}, + 'ports': { + 'clk': { + 'direction': 'input', + 'bits': [1] + }, + 'resetn': { + 'direction': 'input', + 'bits': [2] + }, + 'arvalid_s': { + 'direction': 'input', + 'bits': [3] + }, + }, + 'netnames': {}, + 'cells': { + 'lab': { + 'type': 'Lab', + 'connections': {}, + }, + }, + }, + 'Lab': { + 'ports': { + 'clk': { + 'direction': 'input', + 'bits': [10] + }, + 'reset': { + 'direction': 'input', + 'bits': [11] + }, + 'fromUpstream_request__st': { + 'direction': 'input', + 'bits': List.generate(64, (i) => 100 + i) + }, + }, + 'netnames': {}, + 'cells': { + 'cam': { + 'type': 'Cam', + 'connections': {}, + }, + }, + }, + 'Cam': { + 'ports': { + 'hit': { + 'direction': 'input', + 'bits': [200] + }, + 'entry': { + 'direction': 'input', + 'bits': List.generate(32, (i) => 300 + i) + }, + }, + 'netnames': {}, + 'cells': {}, + }, + }, + })); + +void main() { + late HierarchyService vcdService; + late HierarchyService jsonService; + + setUp(() { + vcdService = _buildVcdAdapter(); + jsonService = _buildJsonAdapter(); + }); + + // ── The two services must be interchangeable for all search ops ── + // Case-insensitivity, dot separators, controller state, and + // search semantics are covered in address_conversion_test, + // hierarchy_search_controller_test, and regex_search_test. + // This file focuses exclusively on *parity* between adapters. + + group('Adapter search parity — both sources produce same results', () { + test('root name matches', () { + expect(vcdService.root.name, 'Abcd'); + expect(jsonService.root.name, 'Abcd'); + }); + + test('root.children returns same module names', () { + final vcdChildren = vcdService.root.children.map((c) => c.name).toSet(); + final jsonChildren = jsonService.root.children.map((c) => c.name).toSet(); + expect(vcdChildren, jsonChildren); + }); + + test('root.signals returns same signal names at root', () { + final vcdSigs = vcdService.root.signals.map((s) => s.name).toSet(); + final jsonSigs = jsonService.root.signals.map((s) => s.name).toSet(); + expect(vcdSigs, jsonSigs); + }); + + test('nested node signals() returns same signal names', () { + final vcdLab = vcdService.root.children.first; + final jsonLab = jsonService.root.children.first; + final vcdSigs = vcdLab.signals.map((s) => s.name).toSet(); + final jsonSigs = jsonLab.signals.map((s) => s.name).toSet(); + expect(vcdSigs, jsonSigs); + }); + + test('signalByAddress works on both — top level', () { + final vcdClk = _resolve(vcdService, 'Abcd/clk'); + final jsonClk = _resolve(jsonService, 'Abcd/clk'); + expect(vcdClk, isNotNull, reason: 'VCD: Abcd/clk'); + expect(jsonClk, isNotNull, reason: 'JSON: Abcd/clk'); + expect(vcdClk!.name, 'clk'); + expect(jsonClk!.name, 'clk'); + }); + + test('signalByAddress works on both — nested', () { + final vcdHit = _resolve(vcdService, 'Abcd/lab/cam/hit'); + final jsonHit = _resolve(jsonService, 'Abcd/lab/cam/hit'); + expect(vcdHit, isNotNull, reason: 'VCD: Abcd/lab/cam/hit'); + expect(jsonHit, isNotNull, reason: 'JSON: Abcd/lab/cam/hit'); + expect(vcdHit!.name, 'hit'); + expect(jsonHit!.name, 'hit'); + }); + + test('searchSignals plain query — same result names', () { + final vcdResults = + vcdService.searchSignals('clk').map((r) => r.name).toSet(); + final jsonResults = + jsonService.searchSignals('clk').map((r) => r.name).toSet(); + expect(vcdResults, isNotEmpty); + expect(vcdResults, jsonResults); + }); + + test('searchSignals glob query — same result names', () { + final vcdResults = + vcdService.searchSignals('**/clk').map((r) => r.name).toSet(); + final jsonResults = + jsonService.searchSignals('**/clk').map((r) => r.name).toSet(); + expect(vcdResults, isNotEmpty); + expect(vcdResults, jsonResults); + }); + + test('searchSignals path query — same result names', () { + final vcdResults = + vcdService.searchSignals('lab/clk').map((r) => r.name).toSet(); + final jsonResults = + jsonService.searchSignals('lab/clk').map((r) => r.name).toSet(); + expect(vcdResults, isNotEmpty); + expect(vcdResults, jsonResults); + }); + + test('searchModules — same module names', () { + final vcdNodes = vcdService + .searchOccurrences('lab') + .map((r) => r.occurrence.name) + .toSet(); + final jsonNodes = jsonService + .searchOccurrences('lab') + .map((r) => r.occurrence.name) + .toSet(); + expect(vcdNodes, isNotEmpty); + expect(vcdNodes, jsonNodes); + }); + + test('searchModules nested — same module names', () { + final vcdNodes = vcdService + .searchOccurrences('cam') + .map((r) => r.occurrence.name) + .toSet(); + final jsonNodes = jsonService + .searchOccurrences('cam') + .map((r) => r.occurrence.name) + .toSet(); + expect(vcdNodes, isNotEmpty); + expect(vcdNodes, jsonNodes); + }); + }); + + // ── Verify the external-hierarchy handoff works ── + // Individual search/address semantics are covered elsewhere. + // This group tests the adapter re-wrapping contract. + + group('External hierarchy flow (simulates DevTools → wave viewer)', () { + test('BaseHierarchyAdapter.fromTree produces identical search results', () { + final rewrapped = BaseHierarchyAdapter.fromTree(jsonService.root); + + final results = rewrapped.searchSignals('clk'); + expect(results, isNotEmpty); + expect( + results.map((r) => r.name).toSet(), + jsonService.searchSignals('clk').map((r) => r.name).toSet(), + ); + }); + + test('BaseHierarchyAdapter.fromTree preserves signalByAddress', () { + final rewrapped = BaseHierarchyAdapter.fromTree(jsonService.root); + + final hit = _resolve(rewrapped, 'Abcd/lab/cam/hit'); + expect(hit, isNotNull); + expect(hit!.name, 'hit'); + }); + }); + + group('BaseHierarchyAdapter.root', () { + test('throws StateError when root is not set', () { + final adapter = _UnsetAdapter(); + expect(() => adapter.root, throwsStateError); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/address_conversion_test.dart b/packages/rohd_hierarchy/test/address_conversion_test.dart new file mode 100644 index 000000000..2caabdef6 --- /dev/null +++ b/packages/rohd_hierarchy/test/address_conversion_test.dart @@ -0,0 +1,286 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// address_conversion_test.dart +// Tests for HierarchyService address ↔ pathname conversion methods. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + group('Address ↔ pathname conversion', () { + late HierarchyService service; + late HierarchyOccurrence root; + + // Build a test hierarchy: + // Top + // ├─ cpu (child 0) + // │ ├─ signals: clk, rst + // │ └─ alu (child 0 of cpu) + // │ └─ signals: a, b, out + // └─ mem (child 1) + // └─ signals: addr, data + + setUpAll(() { + final alu = HierarchyOccurrence( + name: 'alu', + signals: [ + SignalOccurrence( + name: 'a', + width: 1, + ), + SignalOccurrence( + name: 'b', + width: 1, + ), + SignalOccurrence( + name: 'out', + width: 1, + ), + ], + ); + + final cpu = HierarchyOccurrence( + name: 'cpu', + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + ), + SignalOccurrence( + name: 'rst', + width: 1, + ), + ], + children: [alu], + ); + + final mem = HierarchyOccurrence( + name: 'mem', + signals: [ + SignalOccurrence( + name: 'addr', + width: 1, + ), + SignalOccurrence( + name: 'data', + width: 1, + ), + ], + ); + + root = HierarchyOccurrence( + name: 'Top', + children: [cpu, mem], + )..buildAddresses(); + + service = BaseHierarchyAdapter.fromTree(root); + }); + + group('pathnameToAddress', () { + test('root name resolves to root address', () { + final addr = service.pathnameToAddress('Top'); + expect(addr, isNotNull); + expect(addr!.path, equals([])); + }); + + test('module path resolves correctly', () { + final addr = service.pathnameToAddress('Top/cpu'); + expect(addr, isNotNull); + expect(addr!.path, equals([0])); + }); + + test('nested module path resolves correctly', () { + final addr = service.pathnameToAddress('Top/cpu/alu'); + expect(addr, isNotNull); + expect(addr!.path, equals([0, 0])); + }); + + test('second child module resolves correctly', () { + final addr = service.pathnameToAddress('Top/mem'); + expect(addr, isNotNull); + expect(addr!.path, equals([1])); + }); + + test('signal path resolves correctly', () { + final addr = service.pathnameToAddress('Top/cpu/clk'); + expect(addr, isNotNull); + expect(addr!.path, equals([0, 0])); // cpu[0], signal clk[0] + }); + + test('second signal resolves correctly', () { + final addr = service.pathnameToAddress('Top/cpu/rst'); + expect(addr, isNotNull); + expect(addr!.path, equals([0, 1])); // cpu[0], signal rst[1] + }); + + test('nested signal resolves correctly', () { + final addr = service.pathnameToAddress('Top/cpu/alu/out'); + expect(addr, isNotNull); + expect(addr!.path, equals([0, 0, 2])); // cpu[0], alu[0], out[2] + }); + + test('dot-separated paths work too', () { + final addr = service.pathnameToAddress('Top.cpu.alu.b'); + expect(addr, isNotNull); + expect(addr!.path, equals([0, 0, 1])); // cpu[0], alu[0], b[1] + }); + + test('non-existent path returns null', () { + expect(service.pathnameToAddress('Top/nonexistent'), isNull); + }); + + test('non-existent signal returns null', () { + expect(service.pathnameToAddress('Top/cpu/nonexistent'), isNull); + }); + + test('empty string returns root', () { + final addr = service.pathnameToAddress(''); + expect(addr, isNotNull); + expect(addr!.path, isEmpty); + }); + }); + + group('addressToPathname', () { + test('root address returns root name', () { + expect( + service.addressToPathname(OccurrenceAddress.root), + equals('Top'), + ); + }); + + test('module address resolves correctly', () { + expect( + service.addressToPathname(const OccurrenceAddress([0])), + equals('Top/cpu'), + ); + }); + + test('nested module address resolves correctly', () { + expect( + service.addressToPathname(const OccurrenceAddress([0, 0])), + equals('Top/cpu/alu'), + ); + }); + + test('signal address resolves with asSignal flag', () { + expect( + service.addressToPathname( + const OccurrenceAddress([0, 0]), + asSignal: true, + ), + equals('Top/cpu/clk'), + ); + }); + + test('nested signal address resolves with asSignal flag', () { + expect( + service.addressToPathname( + const OccurrenceAddress([0, 0, 2]), + asSignal: true, + ), + equals('Top/cpu/alu/out'), + ); + }); + + test('out-of-bounds child returns null', () { + expect( + service.addressToPathname(const OccurrenceAddress([5])), + isNull, + ); + }); + + test('out-of-bounds signal returns null', () { + expect( + service.addressToPathname( + const OccurrenceAddress([0, 99]), + asSignal: true, + ), + isNull, + ); + }); + }); + + group('nodeByAddress', () { + test('root address returns root', () { + final node = service.occurrenceByAddress(OccurrenceAddress.root); + expect(node?.name, equals('Top')); + }); + + test('child address returns correct child', () { + final node = service.occurrenceByAddress(const OccurrenceAddress([0])); + expect(node?.name, equals('cpu')); + }); + + test('nested address returns correct node', () { + final node = + service.occurrenceByAddress(const OccurrenceAddress([0, 0])); + expect(node?.name, equals('alu')); + }); + + test('out-of-bounds returns null', () { + expect( + service.occurrenceByAddress(const OccurrenceAddress([99])), + isNull, + ); + }); + }); + + group('signalByAddress', () { + test('signal address returns correct signal', () { + // cpu's first signal (clk) has address [0, 0] + final clkAddr = root.children[0].signals[0].address!; + final sig = service.signalByAddress(clkAddr); + expect(sig?.name, equals('clk')); + }); + + test('nested signal address returns correct signal', () { + // alu's third signal (out) has address [0, 0, 2] + final outAddr = root.children[0].children[0].signals[2].address!; + final sig = service.signalByAddress(outAddr); + expect(sig?.name, equals('out')); + }); + + test('root address returns null (not a signal)', () { + expect(service.signalByAddress(OccurrenceAddress.root), isNull); + }); + }); + + group('waveformIdToAddress', () { + test('dot-separated waveform ID resolves', () { + final addr = service.waveformIdToAddress('Top.cpu.alu.a'); + expect(addr, isNotNull); + expect(addr!.path, equals([0, 0, 0])); // cpu[0], alu[0], a[0] + }); + }); + + group('round-trip', () { + test('pathname → address → pathname preserves module path', () { + const path = 'Top/cpu/alu'; + final addr = service.pathnameToAddress(path); + expect(addr, isNotNull); + final roundTripped = service.addressToPathname(addr!); + expect(roundTripped, equals(path)); + }); + + test('pathname → address → pathname preserves signal path', () { + const path = 'Top/cpu/alu/out'; + final addr = service.pathnameToAddress(path); + expect(addr, isNotNull); + final roundTripped = service.addressToPathname(addr!, asSignal: true); + expect(roundTripped, equals(path)); + }); + + test('address → pathname → address preserves module address', () { + const addr = OccurrenceAddress([0, 0]); + final path = service.addressToPathname(addr); + expect(path, isNotNull); + final roundTripped = service.pathnameToAddress(path!); + expect(roundTripped?.path, equals(addr.path)); + }); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/coverage_gaps_test.dart b/packages/rohd_hierarchy/test/coverage_gaps_test.dart new file mode 100644 index 000000000..1e29bcfe5 --- /dev/null +++ b/packages/rohd_hierarchy/test/coverage_gaps_test.dart @@ -0,0 +1,120 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// coverage_gaps_test.dart +// Tests for API surface not covered by other test files: +// - BaseHierarchyAdapter.root StateError on uninitialized access +// - SignalOccurrence as port +// - HierarchyOccurrence.parent +// - SignalOccurrence.value +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +/// Concrete subclass that does NOT set root, so we can test the +/// StateError thrown by uninitialized access. +class _UnsetAdapter extends BaseHierarchyAdapter {} + +void main() { + group('BaseHierarchyAdapter.root', () { + test('throws StateError when root is not set', () { + final adapter = _UnsetAdapter(); + expect(() => adapter.root, throwsStateError); + }); + }); + + group('SignalOccurrence as port', () { + test('creates a port signal with defaults', () { + final p = SignalOccurrence(name: 'clk', width: 1, direction: 'input'); + expect(p.name, 'clk'); + expect(p.direction, 'input'); + expect(p.width, 1); + expect(p.isPort, isTrue); + expect(p.isInput, isTrue); + }); + + test('creates a port signal with explicit overrides', () { + final p = SignalOccurrence( + name: 'data', + direction: 'output', + width: 32, + isComputed: true, + ); + HierarchyOccurrence(name: 'Top', signals: [p]).buildAddresses(); + expect(p.name, 'data'); + expect(p.width, 32); + expect(p.direction, 'output'); + expect(p.path(), 'Top/data'); + expect(p.parent!.path(), 'Top'); + expect(p.isComputed, isTrue); + expect(p.isOutput, isTrue); + }); + }); + + group('HierarchyOccurrence.parent', () { + test('parent is null for root', () { + final root = HierarchyOccurrence(name: 'Top')..buildAddresses(); + expect(root.parent, isNull); + }); + + test('parent is set for child nodes after buildAddresses', () { + final child = HierarchyOccurrence(name: 'sub'); + final root = HierarchyOccurrence(name: 'Top', children: [child]) + ..buildAddresses(); + expect(child.parent, same(root)); + expect(child.path(), 'Top/sub'); + }); + }); + + group('SignalOccurrence.value', () { + test('value is null by default', () { + final s = SignalOccurrence(name: 'a', width: 1); + expect(s.value, isNull); + }); + + test('value stores the provided runtime value', () { + final s = SignalOccurrence(name: 'a', width: 8, value: 'ff'); + expect(s.value, 'ff'); + }); + }); + + group('HierarchyOccurrence.definition', () { + test('type is null when not provided', () { + final n = HierarchyOccurrence(name: 'a'); + expect(n.definition, isNull); + }); + + test('type is stored when provided', () { + final n = HierarchyOccurrence(name: 'a', definition: 'Counter'); + expect(n.definition, 'Counter'); + }); + }); + + group('SignalOccurrence.parent', () { + test('parent is null before buildAddresses', () { + final s = SignalOccurrence(name: 'a', width: 1); + expect(s.parent, isNull); + }); + + test('parent is set after buildAddresses', () { + final s = SignalOccurrence(name: 'a', width: 1); + HierarchyOccurrence( + name: 'Top', + children: [ + HierarchyOccurrence(name: 'sub', signals: [s]) + ], + ).buildAddresses(); + expect(s.parent!.path(), 'Top/sub'); + }); + }); + + group('isPrimitive on nodes', () { + test('default isPrimitive is false', () { + final n = HierarchyOccurrence(name: 'sub'); + expect(n.isPrimitive, isFalse); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/devtools_search_flow_test.dart b/packages/rohd_hierarchy/test/devtools_search_flow_test.dart new file mode 100644 index 000000000..ac5f11ad9 --- /dev/null +++ b/packages/rohd_hierarchy/test/devtools_search_flow_test.dart @@ -0,0 +1,214 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// devtools_search_flow_test.dart +// Tests that simulate the DevTools embedding flow with local signal IDs: +// HierarchyNode tree → BaseHierarchyAdapter.fromTree → search +// +// 2026 April +// Author: Desmond Kirkpatrick + +// The test verifies that search works correctly with local signal IDs +// (as opposed to VCD-style full-path IDs), catching any assumption +// mismatches in the search engine. + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +/// Build the test hierarchy tree directly, matching the structure +/// that would be produced from ROHD inspector JSON. +/// Signals have local IDs and full qualified paths. +HierarchyOccurrence _buildTestHierarchy() { + final cam = HierarchyOccurrence( + name: 'cam', + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'hit', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'entry', + width: 32, + direction: 'input', + ), + SignalOccurrence( + name: 'match_out', + width: 1, + direction: 'output', + ), + ], + ); + + final lab = HierarchyOccurrence( + name: 'lab', + children: [cam], + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'reset', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'fromUpstream_request__st', + width: 64, + direction: 'input', + ), + SignalOccurrence( + name: 'toUpstream_response__st', + width: 64, + direction: 'output', + ), + ], + ); + + final dmaEngine = HierarchyOccurrence( + name: 'engine', + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'enable', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'data_in', + width: 64, + direction: 'input', + ), + SignalOccurrence( + name: 'data_out', + width: 64, + direction: 'output', + ), + SignalOccurrence( + name: 'done', + width: 1, + direction: 'output', + ), + ], + ); + + return HierarchyOccurrence( + name: 'Abcd', + children: [lab, dmaEngine], + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'resetn', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'araddr_s', + width: 32, + direction: 'input', + ), + SignalOccurrence( + name: 'rdata_s', + width: 32, + direction: 'output', + ), + ], + ); +} + +void main() { + late BaseHierarchyAdapter service; + + setUp(() { + final root = _buildTestHierarchy()..buildAddresses(); + service = BaseHierarchyAdapter.fromTree(root); + }); + + group( + 'DevTools flow — local signal IDs ' + '→ BaseHierarchyAdapter.fromTree → search', () { + // Basic search, address, glob, and controller behavior is covered by + // hierarchy_search_controller_test, regex_search_test, + // address_conversion_test, and module_search_test. + // + // This group focuses on what is unique to the DevTools local-ID flow: + // search correctness when SignalOccurrence.name is a local name (not a full + // path). + + test('search works with local signal IDs', () { + // Plain prefix search still finds signals by name + final results = service.searchSignals('clk'); + expect(results, isNotEmpty); + expect(results.map((r) => r.name), everyElement('clk')); + // Glob still works + final globResults = service.searchSignals('**/entry'); + expect(globResults, isNotEmpty); + expect(globResults.first.name, 'entry'); + }); + + test('signalByAddress resolves despite local IDs', () { + final addr = + OccurrenceAddress.tryFromPathname('Abcd/lab/cam/hit', service.root); + expect(addr, isNotNull); + final hit = service.signalByAddress(addr!); + expect(hit, isNotNull); + expect(hit!.name, 'hit'); + expect(hit.name, 'hit'); // local, not full path + expect(hit.path(), 'Abcd/lab/cam/hit'); + }); + + test('searchModules works with local-ID tree', () { + final results = service.searchOccurrences('cam'); + expect(results, isNotEmpty); + expect(results.first.occurrence.name, 'cam'); + }); + }); + + // ── SignalOccurrence ID format verification ── + + group('local signal ID format', () { + test('signals have local IDs (not full paths)', () { + final sigs = service.root.signals; + final clk = sigs.firstWhere((s) => s.name == 'clk'); + // The signal id is the local name, not the full path + expect(clk.name, 'clk'); + // But fullPath is the full qualified path + expect(clk.path(), 'Abcd/clk'); + }); + + test('local signal IDs do not break address resolution', () { + final addr = OccurrenceAddress.tryFromPathname( + 'Abcd/lab/cam/match_out', service.root); + expect(addr, isNotNull); + final result = service.signalByAddress(addr!); + expect(result, isNotNull); + expect(result!.name, 'match_out'); + expect(result.name, 'match_out'); // local name + }); + + test('search results carry the correct signal object', () { + final results = service.searchSignals('Abcd/rdata_s'); + expect(results, isNotEmpty); + final r = results.first; + expect(r.signal, isNotNull); + expect(r.signal!.name, 'rdata_s'); // local name + expect(r.signal!.path(), 'Abcd/rdata_s'); // full path + expect(r.signal!.width, 32); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/filter_bank_integration_test.dart b/packages/rohd_hierarchy/test/filter_bank_integration_test.dart new file mode 100644 index 000000000..4ed1ee309 --- /dev/null +++ b/packages/rohd_hierarchy/test/filter_bank_integration_test.dart @@ -0,0 +1,710 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// filter_bank_integration_test.dart +// Integration tests using a real ROHD FilterBank netlist JSON fixture. +// Covers model getters, service methods, and adapter edge cases. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'dart:io'; + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +/// Load the slim FilterBank fixture and build a NetlistHierarchyAdapter. +NetlistHierarchyAdapter _loadFixture() { + final json = File('test/fixtures/filter_bank.json').readAsStringSync(); + return NetlistHierarchyAdapter.fromJson(json); +} + +void main() { + late NetlistHierarchyAdapter adapter; + late HierarchyService service; + + setUpAll(() { + adapter = _loadFixture(); + service = adapter; + service.root.buildAddresses(); + }); + + // ─────────────── NetlistHierarchyAdapter parsing ─────────────── + + group('NetlistHierarchyAdapter — FilterBank fixture', () { + test('top module is FilterBank', () { + expect(service.root.name, 'FilterBank'); + }); + + test('rootNameOverride replaces root node name', () { + final json = File('test/fixtures/filter_bank.json').readAsStringSync(); + final custom = NetlistHierarchyAdapter.fromJson( + json, + rootNameOverride: 'MyDesign', + ); + expect(custom.root.name, 'MyDesign'); + expect(custom.root.definition, 'FilterBank'); + }); + + test('child modules preserve definition separately from instance name', () { + final controller = service.root.children.firstWhere( + (child) => child.name == 'controller_1', + ); + + expect(controller.name, 'controller_1'); + expect(controller.definition, 'FilterController'); + }); + + test('root has expected ports as signals', () { + final portNames = service.root.signals.map((s) => s.name).toSet(); + expect(portNames, containsAll(['clk', 'reset', 'start', 'done'])); + }); + + test('has hierarchical children (ch0, ch1, controller)', () { + final childNames = service.root.children.map((c) => c.name).toSet(); + // ch0_1 and ch1_1 are FilterChannel instances; controller_1 is + // FilterController + expect(childNames, containsAll(['ch0_1', 'ch1_1', 'controller_1'])); + }); + + test('primitive cells are marked isPrimitive', () { + // array_slice cells in FilterBank are $slice — primitive + final sliceCells = service.root.children.where( + (c) => c.definition != null && c.definition!.startsWith(r'$'), + ); + expect(sliceCells, isNotEmpty); + for (final cell in sliceCells) { + expect( + cell.isPrimitive, + isTrue, + reason: '${cell.name} (${cell.definition}) should be primitive', + ); + } + }); + + test('primitive cells have port signals from port_directions', () { + final primitives = service.root.children.where((c) => c.isPrimitive); + for (final prim in primitives) { + expect( + prim.signals, + isNotEmpty, + reason: '${prim.name} should have port signals', + ); + // All signals on primitive cells should have a direction + for (final s in prim.signals) { + expect( + s.isPort, + isTrue, + reason: '${prim.name}/${s.name} should be a port', + ); + expect(s.direction, isNotEmpty); + } + } + }); + + test('netnames with hide_name=1 are excluded', () { + // FilterBank has controller_1_loadingPhase with hide_name=1 + final allSignalNames = service.root.depthFirstSignals().map( + (s) => s.name, + ); + expect(allSignalNames, isNot(contains('controller_1_loadingPhase'))); + }); + + test('netnames with computed attribute are included with isComputed', () { + // CoeffBank has const_0_2_h0 with computed=1 + // Navigate: FilterBank → ch0_1 → one of its children should have + // a CoeffBank with computed signals + bool foundComputed(HierarchyOccurrence node) { + for (final s in node.signals) { + if (s.isComputed) { + return true; + } + } + return node.children.any(foundComputed); + } + + expect( + foundComputed(service.root), + isTrue, + reason: 'Should have at least one computed signal', + ); + }); + + test(r'$-prefixed netnames are excluded', () { + // Any netname starting with $ should be filtered out + final allNames = service.root.depthFirstSignals().map((s) => s.name); + final dollarNames = allNames.where((n) => n.startsWith(r'$')); + expect( + dollarNames, + isEmpty, + reason: r'No $-prefixed netnames should appear', + ); + }); + }); + + // ─────────────── HierarchyNode model getters ─────────────── + + group('HierarchyNode model getters', () { + test('ports returns only signals with direction', () { + final ports = service.root.ports; + expect(ports, isNotEmpty); + for (final p in ports) { + expect(p.isPort, isTrue); + expect(p.direction, isNotEmpty); + } + }); + + test('inputs returns only input ports', () { + final inputs = service.root.inputs; + expect(inputs, isNotEmpty); + for (final s in inputs) { + expect(s.direction, 'input'); + } + expect(inputs.map((s) => s.name), contains('clk')); + }); + + test('outputs returns only output ports', () { + final outputs = service.root.outputs; + expect(outputs, isNotEmpty); + for (final s in outputs) { + expect(s.direction, 'output'); + } + expect(outputs.map((s) => s.name), contains('done')); + }); + + test(r'isPrimitiveType is true for $-prefixed types', () { + expect(HierarchyOccurrence.isPrimitiveType(r'$mux'), isTrue); + expect(HierarchyOccurrence.isPrimitiveType(r'$and'), isTrue); + }); + + test(r'isPrimitiveType is false for non-$-prefixed types', () { + expect(HierarchyOccurrence.isPrimitiveType('FilterBank'), isFalse); + }); + + test('isPrimitiveType is false for empty string', () { + expect(HierarchyOccurrence.isPrimitiveType(''), isFalse); + }); + + test('isPrimitiveCell reflects isPrimitive field and type', () { + // A node marked isPrimitive=true + final primCell = service.root.children.firstWhere((c) => c.isPrimitive); + expect(primCell.isPrimitiveCell, isTrue); + + // The root module is not primitive + expect(service.root.isPrimitiveCell, isFalse); + }); + + test('depthFirstSignals places root signals first', () { + final all = service.root.depthFirstSignals(); + expect(all, isNotEmpty); + + final rootSigs = service.root.signals; + for (var i = 0; i < rootSigs.length; i++) { + expect(all[i].name, rootSigs[i].name); + } + }); + + test('depthFirstSignals count equals recursive signal total', () { + final all = service.root.depthFirstSignals(); + int countSignals(HierarchyOccurrence n) => + n.signals.length + + n.children.fold(0, (sum, c) => sum + countSignals(c)); + expect(all.length, countSignals(service.root)); + }); + }); + + // ─────────────── SignalOccurrence model getters ─────────────── + + group('SignalOccurrence model getters', () { + test('isPort is true for Port instances', () { + final port = service.root.signals.first; + expect(port.isPort, isTrue); + }); + + test('input port has isInput true and isOutput/isInout false', () { + final clk = service.root.signals.firstWhere((s) => s.name == 'clk'); + expect(clk.isPort, isTrue); + expect(clk.isInput, isTrue); + expect(clk.isOutput, isFalse); + expect(clk.isInout, isFalse); + }); + + test('output port has isOutput true and isInput false', () { + final done = service.root.signals.firstWhere((s) => s.name == 'done'); + expect(done.isOutput, isTrue); + expect(done.isInput, isFalse); + }); + + test('isPort is false for non-Port signals (internal wires)', () { + // Internal signals (from netnames) are SignalOccurrence, not Port. + // The fixture includes visible non-port netnames like tapMatch0. + final allSigs = service.root.depthFirstSignals(); + final nonPorts = allSigs.where((s) => !s.isPort).toList(); + expect( + nonPorts, + isNotEmpty, + reason: 'Should have non-Port internal signals from netnames', + ); + }); + + test('SignalOccurrence.toString includes name and width', () { + final clk = service.root.signals.firstWhere((s) => s.name == 'clk'); + final str = clk.toString(); + expect(str, contains('clk')); + }); + }); + + // ─────────────── HierarchyService methods ─────────────── + + group('HierarchyService — search coverage', () { + test('searchNodes returns HierarchyNode objects', () { + final nodes = service.matchOccurrences('controller'); + expect(nodes, isNotEmpty); + for (final n in nodes) { + expect(n, isA()); + } + }); + + test('autocompletePaths returns children for partial path', () { + final suggestions = service.autocompletePaths('FilterBank/'); + expect(suggestions, isNotEmpty); + for (final s in suggestions) { + expect(s, startsWith('FilterBank/')); + } + }); + + test('autocompletePaths filters by prefix', () { + final suggestions = service.autocompletePaths('FilterBank/ch'); + expect(suggestions, isNotEmpty); + for (final s in suggestions) { + expect(s.toLowerCase(), contains('/ch')); + } + }); + + test('autocompletePaths with empty string returns root', () { + final suggestions = service.autocompletePaths(''); + // Should suggest root-level completions + expect(suggestions, isNotEmpty); + }); + + test('autocompletePaths appends / for nodes with children', () { + final suggestions = service.autocompletePaths('FilterBank/'); + final withSlash = suggestions.where((s) => s.endsWith('/')); + // At least ch0_1 and ch1_1 have children + expect(withSlash, isNotEmpty); + }); + + test('hasRegexChars is false for plain text', () { + expect(HierarchyService.hasRegexChars('clk'), isFalse); + }); + + test('hasRegexChars detects * glob', () { + expect(HierarchyService.hasRegexChars('c*'), isTrue); + }); + + test('hasRegexChars detects ? glob', () { + expect(HierarchyService.hasRegexChars('cl?'), isTrue); + }); + + test('hasRegexChars detects character class', () { + expect(HierarchyService.hasRegexChars('[a-z]'), isTrue); + }); + + test('hasRegexChars detects group alternation', () { + expect(HierarchyService.hasRegexChars('(a|b)'), isTrue); + }); + + test('hasRegexChars detects + quantifier', () { + expect(HierarchyService.hasRegexChars('a+'), isTrue); + }); + + test('longestCommonPrefix finds shared prefix', () { + expect( + HierarchyService.longestCommonPrefix([ + 'FilterBank/ch0', + 'FilterBank/ch1', + ]), + 'FilterBank/ch', + ); + }); + + test('longestCommonPrefix returns null for empty list', () { + expect(HierarchyService.longestCommonPrefix([]), isNull); + }); + + test('longestCommonPrefix returns null for no common prefix', () { + expect(HierarchyService.longestCommonPrefix(['abc', 'xyz']), isNull); + }); + + test('longestCommonPrefix is case-sensitive', () { + final prefix = HierarchyService.longestCommonPrefix([ + 'Filter/abc', + 'Filter/abd', + ]); + expect(prefix, 'Filter/ab'); + }); + }); + + // ─────────────── HierarchySearchController ─────────────── + + group('HierarchySearchController — additional coverage', () { + test('selectAt selects valid index', () { + final ctrl = HierarchySearchController.forSignals( + service, + )..updateQuery('clk'); + expect(ctrl.hasResults, isTrue); + + ctrl.selectAt(0); + expect(ctrl.selectedIndex, 0); + }); + + test('selectAt clamps high index to last result', () { + final ctrl = HierarchySearchController.forSignals( + service, + )..updateQuery('clk'); + expect(ctrl.hasResults, isTrue); + + ctrl.selectAt(999); + expect(ctrl.selectedIndex, ctrl.results.length - 1); + }); + + test('selectAt clamps negative index to zero', () { + final ctrl = HierarchySearchController.forSignals( + service, + )..updateQuery('clk'); + expect(ctrl.hasResults, isTrue); + + ctrl.selectAt(-5); + expect(ctrl.selectedIndex, 0); + }); + + test('selectAt on empty results is no-op', () { + final ctrl = HierarchySearchController.forSignals( + service, + )..selectAt(3); + expect(ctrl.selectedIndex, 0); + expect(ctrl.hasResults, isFalse); + }); + + test('tabComplete expands to longest common prefix', () { + final ctrl = HierarchySearchController.forSignals( + service, + )..updateQuery('clk'); + if (ctrl.results.length > 1) { + final expansion = ctrl.tabComplete('clk'); + // Expansion should be longer than the query if results share a + // common prefix beyond 'clk' + if (expansion != null) { + expect(expansion.length, greaterThan(3)); + } + } + }); + + test('tabComplete returns null when no results', () { + final ctrl = HierarchySearchController.forSignals( + service, + )..updateQuery('zzz_nonexistent'); + expect(ctrl.tabComplete('zzz_nonexistent'), isNull); + }); + + test('tabComplete returns null when prefix is not longer', () { + final ctrl = HierarchySearchController.forSignals( + service, + )..updateQuery('clk'); + // If there's a single result whose displayPath equals normalized + // query, tabComplete should return null or the path itself. + // With multiple results from different modules, the common prefix + // may not be longer. + final result = ctrl.tabComplete(ctrl.results.first.displayPath); + // Either null or the same length — shouldn't crash + expect(result, anyOf(isNull, isA())); + }); + }); + + // ─────────────── ModuleSearchResult getters ─────────────── + + group('ModuleSearchResult — additional getters', () { + test('isModule reflects non-primitive node', () { + final results = service.searchOccurrences('ch0'); + expect(results, isNotEmpty); + final r = results.first; + expect(r.isModule, isNotNull); + }); + + test('childCount reflects node.children.length', () { + final results = service.searchOccurrences('FilterBank'); + final fbResult = results.firstWhere( + (r) => r.path.length == 1, + orElse: () => results.first, + ); + expect(fbResult.childCount, greaterThan(0)); + }); + + test('toString includes module name', () { + final results = service.searchOccurrences('ch0'); + expect(results.first.toString(), contains('ch0')); + }); + }); + + // ─────────────── SignalSearchResult toString ─────────────── + + group('SignalSearchResult.toString', () { + test('toString includes signal name', () { + final results = service.searchSignals('clk'); + expect(results, isNotEmpty); + expect(results.first.toString(), contains('clk')); + }); + }); + + // ─────────────── BaseHierarchyAdapter edge case ─────────────── + // The real uninitialized-root StateError test lives in + // coverage_gaps_test.dart. Here we just verify fromTree works. + + group('BaseHierarchyAdapter — fromTree produces usable root', () { + test('fromTree immediately sets root', () { + final tree = HierarchyOccurrence(name: 'r'); + final svc = BaseHierarchyAdapter.fromTree(tree); + expect(svc.root.name, 'r'); + }); + }); + + // ─────────────── Multiple instantiation (dedup) ─────────────── + + group('Multiple instantiation — FilterChannel dedup', () { + test('ch0 and ch1 are separate node instances', () { + final ch0 = service.root.children.firstWhere((c) => c.name == 'ch0_1'); + final ch1 = service.root.children.firstWhere((c) => c.name == 'ch1_1'); + expect(identical(ch0, ch1), isFalse); + }); + + test('ch0 and ch1 have identical signal structure', () { + final ch0 = service.root.children.firstWhere((c) => c.name == 'ch0_1'); + final ch1 = service.root.children.firstWhere((c) => c.name == 'ch1_1'); + + expect(ch0.signals.length, ch1.signals.length); + + final ch0PortNames = ch0.signals.map((s) => s.name).toSet(); + final ch1PortNames = ch1.signals.map((s) => s.name).toSet(); + expect(ch0PortNames, ch1PortNames); + }); + + test('search finds signals in both channel instances', () { + // Both channels should have a clk port + final results = service.searchSignals('clk'); + final channelClks = results + .where( + (r) => r.signalId.contains('ch0_1') || r.signalId.contains('ch1_1'), + ) + .toList(); + // Should find clk in both ch0_1 and ch1_1 + expect( + channelClks.where((r) => r.signalId.contains('ch0_1')), + isNotEmpty, + ); + expect( + channelClks.where((r) => r.signalId.contains('ch1_1')), + isNotEmpty, + ); + }); + + test('addresses resolve independently for each instance', () { + final ch0Addr = OccurrenceAddress.tryFromPathname( + 'FilterBank/ch0_1/clk', + service.root, + ); + final ch1Addr = OccurrenceAddress.tryFromPathname( + 'FilterBank/ch1_1/clk', + service.root, + ); + + expect(ch0Addr, isNotNull); + expect(ch1Addr, isNotNull); + expect(ch0Addr, isNot(equals(ch1Addr))); + + final ch0Sig = service.signalByAddress(ch0Addr!); + final ch1Sig = service.signalByAddress(ch1Addr!); + expect(ch0Sig, isNotNull); + expect(ch1Sig, isNotNull); + expect(ch0Sig!.name, 'clk'); + expect(ch1Sig!.name, 'clk'); + }); + + test('both instances have internal (non-port) signals', () { + final ch0 = service.root.children.firstWhere((c) => c.name == 'ch0_1'); + final ch1 = service.root.children.firstWhere((c) => c.name == 'ch1_1'); + + final ch0Internal = ch0.signals.where((s) => !s.isPort).toList(); + final ch1Internal = ch1.signals.where((s) => !s.isPort).toList(); + + expect( + ch0Internal, + isNotEmpty, + reason: 'ch0_1 should have internal signals from netnames', + ); + expect( + ch1Internal, + isNotEmpty, + reason: 'ch1_1 should have internal signals from netnames', + ); + }); + + test('both instances share the same internal signal names', () { + final ch0 = service.root.children.firstWhere((c) => c.name == 'ch0_1'); + final ch1 = service.root.children.firstWhere((c) => c.name == 'ch1_1'); + + final ch0Names = + ch0.signals.where((s) => !s.isPort).map((s) => s.name).toSet(); + final ch1Names = + ch1.signals.where((s) => !s.isPort).map((s) => s.name).toSet(); + expect(ch0Names, ch1Names); + }); + + test('internal signals are addressable per-instance', () { + // validPipe exists as a netname in both FilterChannel definitions + final ch0Addr = OccurrenceAddress.tryFromPathname( + 'FilterBank/ch0_1/validPipe', + service.root, + ); + final ch1Addr = OccurrenceAddress.tryFromPathname( + 'FilterBank/ch1_1/validPipe', + service.root, + ); + + expect(ch0Addr, isNotNull, reason: 'ch0_1/validPipe should resolve'); + expect(ch1Addr, isNotNull, reason: 'ch1_1/validPipe should resolve'); + expect(ch0Addr, isNot(equals(ch1Addr))); + + final ch0Sig = service.signalByAddress(ch0Addr!); + final ch1Sig = service.signalByAddress(ch1Addr!); + expect(ch0Sig, isNotNull); + expect(ch1Sig, isNotNull); + expect(ch0Sig!.name, 'validPipe'); + expect(ch1Sig!.name, 'validPipe'); + expect(ch0Sig.isPort, isFalse); + }); + + test('search finds internal signals in both instances', () { + final results = service.searchSignals('validPipe'); + final inCh0 = results.where((r) => r.signalId.contains('ch0_1')); + final inCh1 = results.where((r) => r.signalId.contains('ch1_1')); + expect(inCh0, isNotEmpty, reason: 'validPipe should be found in ch0_1'); + expect(inCh1, isNotEmpty, reason: 'validPipe should be found in ch1_1'); + }); + + test('depthFirstSignals includes internal signals from both instances', () { + final all = service.root.depthFirstSignals(); + final vpSigs = all.where((s) => s.name == 'validPipe').toList(); + expect( + vpSigs.length, + greaterThanOrEqualTo(2), + reason: 'validPipe should appear in at least ch0 and ch1', + ); + }); + }); + + // ─────────────── InOut (bidirectional) port tests ─────────────── + + group('InOut port — dataBus', () { + test('root has dataBus as inout port', () { + final dataBus = service.root.signals + .where((s) => s.isPort) + .where((p) => p.name == 'dataBus') + .firstOrNull; + expect(dataBus, isNotNull, reason: 'FilterBank should have dataBus'); + expect(dataBus!.direction, 'inout'); + expect(dataBus.isInout, isTrue); + expect(dataBus.isInput, isFalse); + expect(dataBus.isOutput, isFalse); + }); + + test('inputs getter excludes inout ports', () { + final inputs = service.root.inputs; + final inoutInInputs = inputs.where((s) => s.direction == 'inout'); + expect( + inoutInInputs, + isEmpty, + reason: 'inputs should not include inout ports', + ); + }); + + test('outputs getter excludes inout ports', () { + final outputs = service.root.outputs; + final inoutInOutputs = outputs.where((s) => s.direction == 'inout'); + expect( + inoutInOutputs, + isEmpty, + reason: 'outputs should not include inout ports', + ); + }); + + test('ports getter includes inout ports', () { + final allPorts = service.root.ports; + final inouts = allPorts.where((p) => p.direction == 'inout').toList(); + expect(inouts, isNotEmpty, reason: 'ports should include inout ports'); + expect(inouts.first.name, 'dataBus'); + }); + + test('dataBus is addressable and resolvable', () { + final addr = OccurrenceAddress.tryFromPathname( + 'FilterBank/dataBus', + service.root, + ); + expect(addr, isNotNull, reason: 'dataBus should be addressable'); + + final sig = service.signalByAddress(addr!); + expect(sig, isNotNull); + expect(sig!.name, 'dataBus'); + expect(sig.isInout, isTrue); + }); + + test('search finds dataBus inout port', () { + final results = service.searchSignals('dataBus'); + expect(results, isNotEmpty); + final dataBusResults = results.where( + (r) => r.signalId.contains('dataBus'), + ); + expect(dataBusResults, isNotEmpty); + }); + + test('SharedDataBus child also has dataBus inout', () { + final sharedBus = service.root.children + .where((c) => c.name == 'sharedBus_1') + .firstOrNull; + expect( + sharedBus, + isNotNull, + reason: 'sharedBus_1 cell should be present', + ); + final childDataBus = sharedBus!.signals + .where((s) => s.isPort) + .where((p) => p.name == 'dataBus') + .firstOrNull; + expect( + childDataBus, + isNotNull, + reason: 'SharedDataBus should have dataBus inout', + ); + expect(childDataBus!.isInout, isTrue); + }); + + test('depthFirstSignals includes inout ports', () { + final all = service.root.depthFirstSignals(); + final inouts = all.where((s) => s.isInout); + expect( + inouts, + isNotEmpty, + reason: 'depthFirstSignals should include inout ports', + ); + }); + + test('addressToPathname round-trips for inout signal', () { + final addr = OccurrenceAddress.tryFromPathname( + 'FilterBank/dataBus', + service.root, + ); + expect(addr, isNotNull); + final pathname = service.addressToPathname(addr!, asSignal: true); + expect(pathname, 'FilterBank/dataBus'); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/fixtures/filter_bank.json b/packages/rohd_hierarchy/test/fixtures/filter_bank.json new file mode 100644 index 000000000..494318612 --- /dev/null +++ b/packages/rohd_hierarchy/test/fixtures/filter_bank.json @@ -0,0 +1,1183 @@ +{ + "modules": { + "CoeffBank_T3_W16": { + "attributes": { + "src": "generated" + }, + "ports": { + "tapIndex": { + "direction": "input", + "bits": [ + 2, + 3 + ] + }, + "coeffArray": { + "direction": "input", + "bits": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51 + ] + }, + "coeffOut": { + "direction": "output", + "bits": [ + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67 + ] + } + }, + "netnames": { + "tapMatch0": { + "bits": [ + 68 + ], + "attributes": {} + }, + "tapMatch1": { + "bits": [ + 69 + ], + "attributes": {} + }, + "const_0_2_h0": { + "bits": [ + 103, + 104 + ], + "attributes": { + "computed": 1 + } + } + }, + "cells": { + "mux_3": { + "type": "$mux", + "port_directions": { + "S": "input", + "A": "input", + "B": "input", + "Y": "output" + } + }, + "equals_3": { + "type": "$eq", + "port_directions": { + "A": "input", + "B": "input", + "Y": "output" + } + }, + "mux_0_1": { + "type": "$mux", + "port_directions": { + "S": "input", + "A": "input", + "B": "input", + "Y": "output" + } + }, + "equals_0_1": { + "type": "$eq", + "port_directions": { + "A": "input", + "B": "input", + "Y": "output" + } + }, + "mux_1_1": { + "type": "$mux", + "port_directions": { + "S": "input", + "A": "input", + "B": "input", + "Y": "output" + } + } + } + }, + "MacUnit_W16": { + "attributes": { + "src": "generated" + }, + "ports": { + "sampleIn": { + "direction": "input", + "bits": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17 + ] + }, + "coeffIn": { + "direction": "input", + "bits": [ + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33 + ] + }, + "accumIn": { + "direction": "input", + "bits": [ + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49 + ] + }, + "clk": { + "direction": "input", + "bits": [ + 50 + ] + }, + "reset": { + "direction": "input", + "bits": [ + 51 + ] + }, + "enable": { + "direction": "input", + "bits": [ + 52 + ] + }, + "result": { + "direction": "output", + "bits": [ + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68 + ] + } + }, + "netnames": { + "sampleIn_stage2_i": { + "bits": [ + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84 + ], + "attributes": {} + }, + "sampleIn_stage0_o": { + "bits": [ + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100 + ], + "attributes": {} + } + }, + "cells": { + "comb_stage2_1": { + "type": "Combinational", + "port_directions": { + "_in0_sampleIn_stage2_i": "input", + "_in2_coeffIn_stage2_i": "input", + "_in4_accumIn_stage2_i": "input", + "_out1_sampleIn_stage2": "output", + "_out3_coeffIn_stage2": "output", + "_out5_accumIn_stage2": "output" + } + }, + "ff_sampleIn_1": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in4_sampleIn_stage0_o": "input", + "_in5_sampleIn_stage1_o": "input", + "_trigger0_clk": "input", + "_out6_sampleIn_stage1_i": "output", + "_out7_sampleIn_stage2_i": "output" + } + }, + "comb_stage0_1": { + "type": "Combinational", + "port_directions": { + "_in0_sampleIn_stage0_i": "input", + "_in2_coeffIn_stage0_i": "input", + "_in4_accumIn_stage0_i": "input", + "_in6_product": "input", + "_out1_sampleIn_stage0": "output", + "_out3_coeffIn_stage0": "output", + "_out5_accumIn_stage0": "output", + "_out7_sampleIn_stage0": "output" + } + }, + "multiply_1": { + "type": "$mul", + "port_directions": { + "A": "input", + "B": "input", + "Y": "output" + } + }, + "ff_coeffIn_1": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in4_coeffIn_stage0_o": "input", + "_in5_coeffIn_stage1_o": "input", + "_trigger0_clk": "input", + "_out6_coeffIn_stage1_i": "output", + "_out7_coeffIn_stage2_i": "output" + } + } + } + }, + "FilterChannel_T3_W16": { + "attributes": { + "src": "generated" + }, + "ports": { + "sampleIn": { + "direction": "input", + "bits": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17 + ] + }, + "validIn": { + "direction": "input", + "bits": [ + 18 + ] + }, + "clk": { + "direction": "input", + "bits": [ + 19 + ] + }, + "reset": { + "direction": "input", + "bits": [ + 20 + ] + }, + "enable": { + "direction": "input", + "bits": [ + 21 + ] + }, + "dataOut": { + "direction": "output", + "bits": [ + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37 + ] + }, + "validOut": { + "direction": "output", + "bits": [ + 38 + ] + } + }, + "netnames": { + "validPipe": { + "bits": [ + 39 + ], + "attributes": {} + }, + "outputReady": { + "bits": [ + 40 + ], + "attributes": {} + }, + "const_0_2_h0": { + "bits": [ + 420, + 421 + ], + "attributes": { + "computed": 1 + } + } + }, + "cells": { + "combinational_2": { + "type": "Combinational", + "port_directions": { + "_in0_validPipe": "input", + "_in1_outputReg": "input", + "_out4_dataOut": "output", + "_out5_validOut": "output" + } + }, + "sequential_3": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in3_enable": "input", + "_in4_outputReady": "input", + "_trigger0_clk": "input", + "_out5_validPipe": "output" + } + }, + "and__3": { + "type": "$and", + "port_directions": { + "A": "input", + "B": "input", + "Y": "output" + } + }, + "sequential_0_1": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in5_lastTap": "input", + "_in6_lastTapD1": "input", + "_in7_lastTapD2": "input", + "_in8_accumReg": "input", + "_trigger0_clk": "input", + "_out9_lastTapD1": "output", + "_out10_lastTapD2": "output", + "_out11_outputReg": "output" + } + }, + "sequential_1_1": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in3_enable": "input", + "_in4_lastTap": "input", + "_in7__tapCounter_add_const_1": "input", + "_trigger0_clk": "input", + "_out10_tapCounter": "output" + } + } + } + }, + "FilterController": { + "attributes": { + "src": "generated" + }, + "ports": { + "clk": { + "direction": "input", + "bits": [ + 2 + ] + }, + "reset": { + "direction": "input", + "bits": [ + 3 + ] + }, + "start": { + "direction": "input", + "bits": [ + 4 + ] + }, + "inputValid": { + "direction": "input", + "bits": [ + 5 + ] + }, + "inputDone": { + "direction": "input", + "bits": [ + 6 + ] + }, + "filterEnable": { + "direction": "output", + "bits": [ + 7 + ] + }, + "loadingPhase": { + "direction": "output", + "bits": [ + 8 + ] + }, + "doneFlag": { + "direction": "output", + "bits": [ + 9 + ] + }, + "state": { + "direction": "output", + "bits": [ + 10, + 11, + 12 + ] + } + }, + "netnames": { + "currentState": { + "bits": [ + 13, + 14, + 15 + ], + "attributes": {} + }, + "isDraining": { + "bits": [ + 16 + ], + "attributes": {} + }, + "const_0_3_h3": { + "bits": [ + 94, + 95, + 96 + ], + "attributes": { + "computed": 1 + } + } + }, + "cells": { + "combinational_1": { + "type": "Combinational", + "port_directions": { + "_in0_currentState": "input", + "_in1_FilterState_idle": "input", + "_in6_start": "input", + "_in9_FilterState_loading": "input", + "_in14_inputValid": "input", + "_in17_FilterState_running": "input", + "_in22_inputDone": "input", + "_in25_FilterState_draining": "input", + "_in30_drainDone": "input", + "_in33_FilterState_done": "input", + "_out42_filterEnable": "output", + "_out43_loadingPhase": "output", + "_out44_doneFlag": "output", + "_out45_nextState": "output" + } + }, + "swizzle_1": { + "type": "$buf", + "port_directions": { + "A": "input", + "Y": "output" + } + }, + "equals_2": { + "type": "$eq", + "port_directions": { + "A": "input", + "B": "input", + "Y": "output" + } + }, + "sequential_2": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in3_isDraining": "input", + "_in4__drainCount_add_const_1": "input", + "_trigger0_clk": "input", + "_out7_drainCount": "output" + } + }, + "equals_0_1": { + "type": "$eq", + "port_directions": { + "A": "input", + "B": "input", + "Y": "output" + } + } + } + }, + "FilterChannel_T3_W16_0": { + "attributes": { + "src": "generated" + }, + "ports": { + "sampleIn": { + "direction": "input", + "bits": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17 + ] + }, + "validIn": { + "direction": "input", + "bits": [ + 18 + ] + }, + "clk": { + "direction": "input", + "bits": [ + 19 + ] + }, + "reset": { + "direction": "input", + "bits": [ + 20 + ] + }, + "enable": { + "direction": "input", + "bits": [ + 21 + ] + }, + "dataOut": { + "direction": "output", + "bits": [ + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37 + ] + }, + "validOut": { + "direction": "output", + "bits": [ + 38 + ] + } + }, + "netnames": { + "validPipe": { + "bits": [ + 39 + ], + "attributes": {} + }, + "outputReady": { + "bits": [ + 40 + ], + "attributes": {} + }, + "const_0_2_h0": { + "bits": [ + 420, + 421 + ], + "attributes": { + "computed": 1 + } + } + }, + "cells": { + "combinational_2": { + "type": "Combinational", + "port_directions": { + "_in0_validPipe": "input", + "_in1_outputReg": "input", + "_out4_dataOut": "output", + "_out5_validOut": "output" + } + }, + "sequential_3": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in3_enable": "input", + "_in4_outputReady": "input", + "_trigger0_clk": "input", + "_out5_validPipe": "output" + } + }, + "and__3": { + "type": "$and", + "port_directions": { + "A": "input", + "B": "input", + "Y": "output" + } + }, + "sequential_0_1": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in5_lastTap": "input", + "_in6_lastTapD1": "input", + "_in7_lastTapD2": "input", + "_in8_accumReg": "input", + "_trigger0_clk": "input", + "_out9_lastTapD1": "output", + "_out10_lastTapD2": "output", + "_out11_outputReg": "output" + } + }, + "sequential_1_1": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in3_enable": "input", + "_in4_lastTap": "input", + "_in7__tapCounter_add_const_1": "input", + "_trigger0_clk": "input", + "_out10_tapCounter": "output" + } + } + } + }, + "SharedDataBus": { + "attributes": { + "src": "generated" + }, + "ports": { + "writeEnable": { + "direction": "input", + "bits": [ + 502 + ] + }, + "clk": { + "direction": "input", + "bits": [ + 503 + ] + }, + "reset": { + "direction": "input", + "bits": [ + 504 + ] + }, + "storedValue": { + "direction": "output", + "bits": [ + 505, + 506, + 507, + 508, + 509, + 510, + 511, + 512, + 513, + 514, + 515, + 516, + 517, + 518, + 519, + 520 + ] + }, + "dataBus": { + "direction": "inout", + "bits": [ + 521, + 522, + 523, + 524, + 525, + 526, + 527, + 528, + 529, + 530, + 531, + 532, + 533, + 534, + 535, + 536 + ] + } + }, + "netnames": { + "latch": { + "bits": [ + 537, + 538, + 539, + 540, + 541, + 542, + 543, + 544, + 545, + 546, + 547, + 548, + 549, + 550, + 551, + 552 + ], + "attributes": {} + } + }, + "cells": {} + }, + "FilterBank": { + "attributes": { + "src": "generated", + "top": 1 + }, + "ports": { + "clk": { + "direction": "input", + "bits": [ + 2 + ] + }, + "reset": { + "direction": "input", + "bits": [ + 3 + ] + }, + "start": { + "direction": "input", + "bits": [ + 4 + ] + }, + "samplesIn": { + "direction": "input", + "bits": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36 + ] + }, + "validIn": { + "direction": "input", + "bits": [ + 37 + ] + }, + "inputDone": { + "direction": "input", + "bits": [ + 38 + ] + }, + "channelOut": { + "direction": "output", + "bits": [ + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70 + ] + }, + "validOut": { + "direction": "output", + "bits": [ + 71 + ] + }, + "done": { + "direction": "output", + "bits": [ + 72 + ] + }, + "state": { + "direction": "output", + "bits": [ + 73, + 74, + 75 + ] + }, + "dataBus": { + "direction": "inout", + "bits": [ + 600, + 601, + 602, + 603, + 604, + 605, + 606, + 607, + 608, + 609, + 610, + 611, + 612, + 613, + 614, + 615 + ] + } + }, + "netnames": { + "channelOut_0_": { + "bits": [ + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54 + ], + "attributes": {} + }, + "sample0_data": { + "bits": [ + 147, + 148, + 149, + 150, + 151, + 152, + 153, + 154, + 155, + 156, + 157, + 158, + 159, + 160, + 161, + 162 + ], + "attributes": {} + }, + "controller_1_loadingPhase": { + "bits": [ + 146 + ], + "hide_name": 1, + "attributes": {} + } + }, + "cells": { + "ch0_1": { + "type": "FilterChannel_T3_W16_0", + "port_directions": { + "sampleIn": "input", + "validIn": "input", + "clk": "input", + "reset": "input", + "enable": "input", + "dataOut": "output", + "validOut": "output" + } + }, + "controller_1": { + "type": "FilterController", + "port_directions": { + "clk": "input", + "reset": "input", + "start": "input", + "inputValid": "input", + "inputDone": "input", + "filterEnable": "output", + "loadingPhase": "output", + "doneFlag": "output", + "state": "output" + } + }, + "ch1_1": { + "type": "FilterChannel_T3_W16", + "port_directions": { + "sampleIn": "input", + "validIn": "input", + "clk": "input", + "reset": "input", + "enable": "input", + "dataOut": "output", + "validOut": "output" + } + }, + "array_slice_3": { + "type": "$slice", + "port_directions": { + "A": "input", + "Y": "output" + } + }, + "array_slice_4": { + "type": "$slice", + "port_directions": { + "A": "input", + "Y": "output" + } + }, + "sharedBus_1": { + "type": "SharedDataBus", + "port_directions": { + "writeEnable": "input", + "clk": "input", + "reset": "input", + "storedValue": "output", + "dataBus": "inout" + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/rohd_hierarchy/test/hierarchy_path_vs_signal_id_test.dart b/packages/rohd_hierarchy/test/hierarchy_path_vs_signal_id_test.dart new file mode 100644 index 000000000..9c574df79 --- /dev/null +++ b/packages/rohd_hierarchy/test/hierarchy_path_vs_signal_id_test.dart @@ -0,0 +1,194 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_path_vs_signal_id_test.dart +// Verifies that signal.path(separator:) and search result signalId +// work correctly with different separators. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + group('signal.path() with separator', () { + late BaseHierarchyAdapter adapter; + + setUp(() { + final root = HierarchyOccurrence( + name: 'abcd', + signals: [ + SignalOccurrence(name: 'clk', width: 1, direction: 'input'), + SignalOccurrence(name: 'arvalid_s', width: 1, direction: 'input'), + ], + children: [ + HierarchyOccurrence( + name: 'lab', + signals: [ + SignalOccurrence(name: 'clk', width: 1, direction: 'input'), + SignalOccurrence(name: 'data', width: 8, direction: 'output'), + ], + ), + ], + )..buildAddresses(); + adapter = BaseHierarchyAdapter.fromTree(root); + }); + + SignalOccurrence? resolve(String path) { + final addr = OccurrenceAddress.tryFromPathname(path, adapter.root); + return addr != null ? adapter.signalByAddress(addr) : null; + } + + test('resolves dot-separated IDs', () { + final s = resolve('abcd.clk'); + expect(s, isNotNull); + expect(s!.path(separator: '.'), 'abcd.clk'); + }); + + test('resolves slash-separated IDs', () { + final s = resolve('abcd/clk'); + expect(s, isNotNull); + expect(s!.path(), 'abcd/clk'); + }); + + test('resolves with exact case', () { + final s = resolve('abcd.clk'); + expect(s, isNotNull); + expect(s!.path(), 'abcd/clk'); + }); + + test('resolves nested dot-separated IDs', () { + final s = resolve('abcd.lab.data'); + expect(s, isNotNull); + expect(s!.path(separator: '.'), 'abcd.lab.data'); + }); + + test('resolves nested slash-separated IDs', () { + final s = resolve('abcd/lab/data'); + expect(s, isNotNull); + expect(s!.path(), 'abcd/lab/data'); + }); + + test('searchSignals returns result with resolved signal', () { + final results = adapter.searchSignals('clk'); + expect(results, isNotEmpty); + for (final r in results) { + expect(r.signal, isNotNull, + reason: 'SignalOccurrence should be resolved for "${r.signalId}"'); + } + final clkResult = + results.firstWhere((r) => r.path.last == 'clk' && r.path.length == 2); + expect(clkResult.signal!.path(), 'abcd/clk'); + expect(clkResult.signal!.path(separator: '.'), 'abcd.clk'); + }); + + test('searchSignals signalId is walker-built (slash) path', () { + final results = adapter.searchSignals('clk'); + expect(results, isNotEmpty); + final clkResult = + results.firstWhere((r) => r.path.last == 'clk' && r.path.length == 2); + expect(clkResult.signalId, 'abcd/clk'); + }); + + test('signal.path() matches signalId with default separator', () { + final results = adapter.searchSignals('clk'); + final result = results.first; + expect(result.signal, isNotNull); + expect(result.signal!.path(), result.signalId); + }); + + test('searchSignalsRegex returns result with signal resolved', () { + final results = adapter.searchSignalsRegex('**/clk'); + expect(results.length, greaterThanOrEqualTo(2)); + for (final r in results) { + expect(r.signal, isNotNull, + reason: 'SignalOccurrence should be resolved for "${r.signalId}"'); + expect(r.signal!.path(), contains('/')); + } + }); + }); + + group('ROHD slash-separated hierarchy', () { + late BaseHierarchyAdapter adapter; + + setUp(() { + final root = HierarchyOccurrence( + name: 'Top', + signals: [SignalOccurrence(name: 'clk', width: 1)], + children: [ + HierarchyOccurrence( + name: 'cpu', + signals: [SignalOccurrence(name: 'data_out', width: 8)], + ), + ], + )..buildAddresses(); + adapter = BaseHierarchyAdapter.fromTree(root); + }); + + test('ROHD signals: path() matches signalId (both slash)', () { + final results = adapter.searchSignals('clk'); + expect(results, isNotEmpty); + final r = results.first; + expect(r.signal!.path(), r.signalId); + }); + }); + + group('SignalOccurrence as port', () { + test('creates a port signal with defaults', () { + final p = SignalOccurrence(name: 'clk', width: 1, direction: 'input'); + expect(p.name, 'clk'); + expect(p.direction, 'input'); + expect(p.width, 1); + expect(p.isPort, isTrue); + expect(p.isInput, isTrue); + }); + + test('creates a port signal with explicit overrides', () { + final p = SignalOccurrence( + name: 'data', + direction: 'output', + width: 32, + isComputed: true, + ); + HierarchyOccurrence(name: 'Top', signals: [p]).buildAddresses(); + expect(p.name, 'data'); + expect(p.width, 32); + expect(p.direction, 'output'); + expect(p.path(), 'Top/data'); + expect(p.parent!.path(), 'Top'); + expect(p.isComputed, isTrue); + expect(p.isOutput, isTrue); + }); + }); + + group('SignalOccurrence.value', () { + test('value is null by default', () { + final s = SignalOccurrence(name: 'a', width: 1); + expect(s.value, isNull); + }); + + test('value stores the provided runtime value', () { + final s = SignalOccurrence(name: 'a', width: 8, value: 'ff'); + expect(s.value, 'ff'); + }); + }); + + group('SignalOccurrence.parent', () { + test('parent is null before buildAddresses', () { + final s = SignalOccurrence(name: 'a', width: 1); + expect(s.parent, isNull); + }); + + test('parent is set after buildAddresses', () { + final s = SignalOccurrence(name: 'a', width: 1); + HierarchyOccurrence( + name: 'Top', + children: [ + HierarchyOccurrence(name: 'sub', signals: [s]) + ], + ).buildAddresses(); + expect(s.parent!.path(), 'Top/sub'); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/hierarchy_query_test.dart b/packages/rohd_hierarchy/test/hierarchy_query_test.dart new file mode 100644 index 000000000..7425aaa43 --- /dev/null +++ b/packages/rohd_hierarchy/test/hierarchy_query_test.dart @@ -0,0 +1,655 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_query_test.dart +// Tests for PrefixQuery and RegexQuery matching logic. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +/// Build a test hierarchy: +/// +/// ```text +/// SoC +/// ├─ signals: [clk, reset, irq0, irq1] +/// ├─ cpu0 +/// │ ├─ signals: [clk, reset, pc] +/// │ ├─ alu +/// │ │ └─ signals: [a, b, result, carry_out, overflow] +/// │ ├─ regfile +/// │ │ └─ signals: [clk, reset, d0, d1, d2, d15, wr_en] +/// │ └─ decoder +/// │ └─ signals: [opcode, enable, mode] +/// ├─ cpu1 +/// │ ├─ signals: [clk, reset, pc] +/// │ ├─ alu +/// │ │ └─ signals: [a, b, result, carry_out, overflow] +/// │ └─ regfile +/// │ └─ signals: [clk, reset, d0, d1, d2, d15, wr_en] +/// ├─ mem_ctrl +/// │ ├─ signals: [clk, reset, addr, data_in, data_out, valid] +/// │ ├─ ch0 +/// │ │ └─ signals: [clk, addr, data, hit, miss] +/// │ ├─ ch1 +/// │ │ └─ signals: [clk, addr, data, hit, miss] +/// │ └─ ch2 +/// │ └─ signals: [clk, addr, data, hit, miss] +/// └─ io_mux +/// ├─ signals: [clk, sel, data_muxed, valid_muxed] +/// ├─ uart0 +/// │ └─ signals: [clk, tx, rx, baud_sel] +/// └─ uart1 +/// └─ signals: [clk, tx, rx, baud_sel] +/// ``` +HierarchyService buildTestHierarchy() { + HierarchyOccurrence mkAlu() => HierarchyOccurrence( + name: 'alu', + definition: 'ALU', + signals: [ + SignalOccurrence(name: 'a', width: 8), + SignalOccurrence(name: 'b', width: 8), + SignalOccurrence(name: 'result', width: 8), + SignalOccurrence(name: 'carry_out', width: 1), + SignalOccurrence(name: 'overflow', width: 1), + ], + ); + + HierarchyOccurrence mkRegfile() => HierarchyOccurrence( + name: 'regfile', + definition: 'RegFile', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'd0', width: 8), + SignalOccurrence(name: 'd1', width: 8), + SignalOccurrence(name: 'd2', width: 8), + SignalOccurrence(name: 'd15', width: 8), + SignalOccurrence(name: 'wr_en', width: 1), + ], + ); + + final decoder = HierarchyOccurrence( + name: 'decoder', + definition: 'Decoder', + signals: [ + SignalOccurrence(name: 'opcode', width: 4), + SignalOccurrence(name: 'enable', width: 1), + SignalOccurrence(name: 'mode', width: 2), + ], + ); + + final cpu0 = HierarchyOccurrence( + name: 'cpu0', + definition: 'CPU', + children: [mkAlu(), mkRegfile(), decoder], + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'pc', width: 32), + ], + ); + + final cpu1 = HierarchyOccurrence( + name: 'cpu1', + definition: 'CPU', + children: [mkAlu(), mkRegfile()], + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'pc', width: 32), + ], + ); + + HierarchyOccurrence mkCacheChannel(String name) => HierarchyOccurrence( + name: name, + definition: 'CacheChannel', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'addr', width: 16), + SignalOccurrence(name: 'data', width: 32), + SignalOccurrence(name: 'hit', width: 1), + SignalOccurrence(name: 'miss', width: 1), + ], + ); + + final memCtrl = HierarchyOccurrence( + name: 'mem_ctrl', + definition: 'MemController', + children: [ + mkCacheChannel('ch0'), + mkCacheChannel('ch1'), + mkCacheChannel('ch2') + ], + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'addr', width: 16), + SignalOccurrence(name: 'data_in', width: 32), + SignalOccurrence(name: 'data_out', width: 32), + SignalOccurrence(name: 'valid', width: 1), + ], + ); + + HierarchyOccurrence mkUart(String name) => HierarchyOccurrence( + name: name, + definition: 'UART', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'tx', width: 1), + SignalOccurrence(name: 'rx', width: 1), + SignalOccurrence(name: 'baud_sel', width: 3), + ], + ); + + final ioMux = HierarchyOccurrence( + name: 'io_mux', + definition: 'IOMux', + children: [mkUart('uart0'), mkUart('uart1')], + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'sel', width: 2), + SignalOccurrence(name: 'data_muxed', width: 8), + SignalOccurrence(name: 'valid_muxed', width: 1), + ], + ); + + final root = HierarchyOccurrence( + name: 'SoC', + definition: 'SoC', + children: [cpu0, cpu1, memCtrl, ioMux], + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'irq0', width: 1), + SignalOccurrence(name: 'irq1', width: 1), + ], + ); + + return BaseHierarchyAdapter.fromTree(root); +} + +void main() { + late HierarchyService svc; + + setUpAll(() { + svc = buildTestHierarchy(); + }); + + // ═══════════════════════════════════════════════════════════════ + // PrefixQuery + // ═══════════════════════════════════════════════════════════════ + + group('PrefixQuery', () { + group('matchOccurrence', () { + test('matches occurrence name containing segment', () { + final q = PrefixQuery('cpu'); + // 'cpu0' contains 'cpu' + expect(q.matchOccurrence('cpu0', 0), equals({1})); + }); + + test('returns empty set when no match', () { + final q = PrefixQuery('mem'); + expect(q.matchOccurrence('cpu0', 0), isEmpty); + }); + + test('past end of segments returns current state', () { + final q = PrefixQuery('cpu'); + // stateIndex == segmentCount → already consumed + expect(q.matchOccurrence('anything', 1), equals({1})); + }); + + test('multi-segment: advances one segment at a time', () { + final q = PrefixQuery('cpu/alu'); + expect(q.matchOccurrence('cpu0', 0), equals({1})); + expect(q.matchOccurrence('alu', 1), equals({2})); + // 'regfile' doesn't match 'alu' + expect(q.matchOccurrence('regfile', 1), isEmpty); + }); + + test('dot separator treated as slash', () { + final q = PrefixQuery('cpu.alu'); + expect(q.segmentCount, equals(2)); + expect(q.matchOccurrence('cpu0', 0), equals({1})); + }); + }); + + group('matchSignal', () { + test('matches signal name with startsWith', () { + final q = PrefixQuery('cpu/clk'); + // At state 1 (after matching 'cpu'), 'clk' starts with 'clk' + expect(q.matchSignal('clk', 1), isTrue); + expect(q.matchSignal('clk_gated', 1), isTrue); + }); + + test('does not match signal for non-last segment', () { + final q = PrefixQuery('cpu/alu/res'); + // At state 1, there are still 2 segments left → only last matches + expect(q.matchSignal('result', 1), isFalse); + // At state 2, this is the last segment + expect(q.matchSignal('result', 2), isTrue); + }); + + test('past end matches any signal', () { + final q = PrefixQuery('cpu'); + expect(q.matchSignal('anything', 1), isTrue); + }); + }); + + group('isComplete', () { + test('complete when stateIndex >= segmentCount', () { + final q = PrefixQuery('cpu/alu'); + expect(q.isComplete(0), isFalse); + expect(q.isComplete(1), isFalse); + expect(q.isComplete(2), isTrue); + expect(q.isComplete(3), isTrue); + }); + }); + + group('isEmpty', () { + test('empty for blank query', () { + expect(PrefixQuery('').isEmpty, isTrue); + expect(PrefixQuery(' ').isEmpty, isTrue); + }); + + test('not empty for real query', () { + expect(PrefixQuery('clk').isEmpty, isFalse); + }); + }); + + group('target property', () { + test('defaults to signals', () { + expect(PrefixQuery('x').target, equals(SearchTarget.signals)); + }); + + test('can be set to occurrences', () { + final q = PrefixQuery('x', target: SearchTarget.occurrences); + expect(q.target, equals(SearchTarget.occurrences)); + }); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // RegexQuery + // ═══════════════════════════════════════════════════════════════ + + group('RegexQuery', () { + group('exact name matching', () { + test('matches exact occurrence name', () { + final q = RegexQuery('SoC/cpu0/alu'); + expect(q.matchOccurrence('SoC', 0), equals({1})); + expect(q.matchOccurrence('cpu0', 1), equals({2})); + expect(q.matchOccurrence('alu', 2), equals({3})); + }); + + test('does not match wrong name', () { + final q = RegexQuery('SoC/cpu0'); + expect(q.matchOccurrence('cpu1', 1), isEmpty); + }); + + test('case sensitive', () { + final q = RegexQuery('SoC/cpu0'); + expect(q.matchOccurrence('SoC', 0), equals({1})); + expect(q.matchOccurrence('cpu0', 1), equals({2})); + }); + }); + + group('glob wildcard *', () { + test('star matches any characters', () { + final q = RegexQuery('SoC/cpu*'); + expect(q.matchOccurrence('cpu0', 1), equals({2})); + expect(q.matchOccurrence('cpu1', 1), equals({2})); + expect(q.matchOccurrence('mem_ctrl', 1), isEmpty); + }); + + test('star at start', () { + final q = RegexQuery('SoC/*_ctrl'); + expect(q.matchOccurrence('mem_ctrl', 1), equals({2})); + expect(q.matchOccurrence('cpu0', 1), isEmpty); + }); + + test('star in middle', () { + final q = RegexQuery('SoC/io_*'); + expect(q.matchOccurrence('io_mux', 1), equals({2})); + expect(q.matchOccurrence('io_ctrl', 1), equals({2})); + expect(q.matchOccurrence('cpu0', 1), isEmpty); + }); + + test('standalone star matches anything', () { + final q = RegexQuery('SoC/*'); + expect(q.matchOccurrence('cpu0', 1), equals({2})); + expect(q.matchOccurrence('mem_ctrl', 1), equals({2})); + expect(q.matchOccurrence('io_mux', 1), equals({2})); + }); + }); + + group('glob wildcard ?', () { + test('question mark matches one character', () { + final q = RegexQuery('SoC/cpu?'); + expect(q.matchOccurrence('cpu0', 1), equals({2})); + expect(q.matchOccurrence('cpu1', 1), equals({2})); + // 'cpuXY' is two chars after 'cpu' → no match + expect(q.matchOccurrence('cpuXY', 1), isEmpty); + }); + }); + + group('glob-star ** (cross hierarchy boundaries)', () { + test('** matches zero levels', () { + final q = RegexQuery('SoC/**/alu'); + // ** at index 1 can match zero levels → try index 2 ('alu') + // directly against children of SoC + final states = q.matchOccurrence('alu', 1); + // Should include state 1 (stay at **) and possibly skip to 2 + expect(states, contains(1)); + }); + + test('** matches one or more levels', () { + final q = RegexQuery('SoC/**/clk'); + // ** stays at ** when consuming a node + expect(q.matchOccurrence('cpu0', 1), contains(1)); + expect(q.matchOccurrence('alu', 1), contains(1)); + }); + + test('** followed by exact segment', () { + final q = RegexQuery('SoC/**/alu'); + // At state 1 (**), 'alu' should match both staying and advancing + final states = q.matchOccurrence('alu', 1); + expect(states, contains(1)); // stay at ** + expect(states, contains(3)); // skip ** + match 'alu' → index 3 + }); + + test('isComplete with trailing **', () { + final q = RegexQuery('SoC/**'); + expect(q.isComplete(1), isTrue); // ** can match zero + expect(q.isComplete(2), isTrue); // past end + }); + }); + + group('character classes [...]', () { + test('matches character range', () { + final q = RegexQuery('SoC/**/d[0-9]+'); + // Signal matching + expect(q.matchSignal('d0', 2), isTrue); + expect(q.matchSignal('d1', 2), isTrue); + expect(q.matchSignal('d15', 2), isTrue); + expect(q.matchSignal('clk', 2), isFalse); + }); + + test('fixed character set', () { + final q = RegexQuery('SoC/mem_ctrl/ch[012]'); + expect(q.matchOccurrence('ch0', 2), equals({3})); + expect(q.matchOccurrence('ch1', 2), equals({3})); + expect(q.matchOccurrence('ch2', 2), equals({3})); + expect(q.matchOccurrence('ch3', 2), isEmpty); + }); + }); + + group('alternation (...|...)', () { + test('matches either alternative', () { + final q = RegexQuery('SoC/**/(clk|reset)'); + expect(q.matchSignal('clk', 2), isTrue); + expect(q.matchSignal('reset', 2), isTrue); + expect(q.matchSignal('data', 2), isFalse); + }); + + test('alternation on occurrences', () { + final q = RegexQuery('SoC/(cpu0|cpu1)'); + expect(q.matchOccurrence('cpu0', 1), equals({2})); + expect(q.matchOccurrence('cpu1', 1), equals({2})); + expect(q.matchOccurrence('mem_ctrl', 1), isEmpty); + }); + }); + + group('regex quantifiers', () { + test('{n,m} repetition', () { + final q = RegexQuery('SoC/**/d[0-9]{1,2}'); + expect(q.matchSignal('d0', 2), isTrue); + expect(q.matchSignal('d15', 2), isTrue); + // 'd123' has 3 digits → no match (anchored) + expect(q.matchSignal('d123', 2), isFalse); + }); + + test('+ one or more', () { + final q = RegexQuery('SoC/**/irq[0-9]+'); + expect(q.matchSignal('irq0', 2), isTrue); + expect(q.matchSignal('irq1', 2), isTrue); + expect(q.matchSignal('irq', 2), isFalse); + }); + }); + + group('matchSignal', () { + test('exact signal name', () { + final q = RegexQuery('SoC/cpu0/clk'); + expect(q.matchSignal('clk', 2), isTrue); + expect(q.matchSignal('reset', 2), isFalse); + }); + + test('glob * on signal', () { + final q = RegexQuery('SoC/cpu0/alu/*'); + expect(q.matchSignal('a', 3), isTrue); + expect(q.matchSignal('result', 3), isTrue); + }); + + test('glob * prefix on signal', () { + final q = RegexQuery('SoC/**/carry_*'); + expect(q.matchSignal('carry_out', 2), isTrue); + expect(q.matchSignal('overflow', 2), isFalse); + }); + + test('** then signal matches all signals when past segments', () { + final q = RegexQuery('SoC/**'); + // At state 1 (**), isComplete is true → match all signals + expect(q.matchSignal('clk', 1), isTrue); + expect(q.matchSignal('anything', 1), isTrue); + }); + + test('signal does not match non-terminal segment', () { + // SoC/cpu0/alu/result — 'result' is segment index 3, last segment + final q = RegexQuery('SoC/cpu0/alu/result'); + // At state 2, there's still 'result' to match → not last-terminal + expect(q.matchSignal('result', 2), isFalse); + // At state 3 it is the last segment + expect(q.matchSignal('result', 3), isTrue); + }); + }); + + group('isComplete', () { + test('complete when past all segments', () { + final q = RegexQuery('SoC/cpu0'); + expect(q.isComplete(0), isFalse); + expect(q.isComplete(1), isFalse); + expect(q.isComplete(2), isTrue); + }); + + test('complete with trailing glob-stars', () { + final q = RegexQuery('SoC/**'); + expect(q.isComplete(0), isFalse); + expect(q.isComplete(1), isTrue); // ** matches zero + }); + + test('not complete with remaining regex segments', () { + final q = RegexQuery('SoC/**/alu'); + expect(q.isComplete(1), isFalse); // ** then 'alu' remains + }); + }); + + group('target property', () { + test('defaults to signals', () { + expect(RegexQuery('x').target, equals(SearchTarget.signals)); + }); + + test('can be set to both', () { + final q = RegexQuery('x', target: SearchTarget.both); + expect(q.target, equals(SearchTarget.both)); + }); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // Factory constructors on HierarchyQuery + // ═══════════════════════════════════════════════════════════════ + + group('HierarchyQuery factories', () { + test('.prefix creates PrefixQuery', () { + final q = HierarchyQuery.prefix('cpu/clk'); + expect(q, isA()); + expect(q.segmentCount, equals(2)); + }); + + test('.regex creates RegexQuery', () { + final q = HierarchyQuery.regex('SoC/**/clk'); + expect(q, isA()); + expect(q.segmentCount, equals(3)); + }); + + test('.prefix with target', () { + final q = HierarchyQuery.prefix('x', target: SearchTarget.occurrences); + expect(q.target, equals(SearchTarget.occurrences)); + }); + + test('.regex with target', () { + final q = HierarchyQuery.regex('x', target: SearchTarget.both); + expect(q.target, equals(SearchTarget.both)); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // Edge cases + // ═══════════════════════════════════════════════════════════════ + + group('Edge cases', () { + test('empty query is isEmpty', () { + expect(HierarchyQuery.prefix('').isEmpty, isTrue); + expect(HierarchyQuery.regex('').isEmpty, isTrue); + expect(HierarchyQuery.prefix(' ').isEmpty, isTrue); + expect(HierarchyQuery.regex(' ').isEmpty, isTrue); + }); + + test('PrefixQuery with only separators', () { + final q = PrefixQuery('///'); + expect(q.segmentCount, equals(0)); + expect(q.isEmpty, isFalse); // raw string isn't blank + expect(q.isComplete(0), isTrue); // no segments to match + }); + + test('RegexQuery single segment', () { + final q = RegexQuery('clk'); + expect(q.segmentCount, equals(1)); + expect(q.matchSignal('clk', 0), isTrue); + expect(q.matchSignal('reset', 0), isFalse); + }); + + test('RegexQuery multiple consecutive glob-stars', () { + final q = RegexQuery('SoC/**/**/clk'); + // Should still work — multiple **'s just redundantly match zero+ + expect(q.isComplete(1), isFalse); // **/** then clk + final states = q.matchOccurrence('cpu0', 1); + expect(states, contains(1)); // stay at first ** + }); + + test('RegexQuery with .* explicit regex', () { + final q = RegexQuery('SoC/**/.*mux.*'); + expect(q.matchOccurrence('io_mux', 2), equals({3})); + expect(q.matchSignal('data_muxed', 2), isTrue); + expect(q.matchSignal('valid_muxed', 2), isTrue); + expect(q.matchSignal('clk', 2), isFalse); + }); + + test('PrefixQuery crossesBoundaries is false', () { + expect(PrefixQuery('x').crossesBoundaries, isFalse); + }); + + test('RegexQuery crossesBoundaries is false (uses ** explicitly)', () { + expect(RegexQuery('SoC/**/clk').crossesBoundaries, isFalse); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // Integration: queries against the real hierarchy via + // HierarchyService (to verify the contract makes sense) + // ═══════════════════════════════════════════════════════════════ + + group('Integration with hierarchy (signal path search)', () { + test('PrefixQuery segments match existing search', () { + // Verify PrefixQuery produces the same segments as + // the existing searchSignalPaths logic. + final q = PrefixQuery('cpu/alu/res'); + final paths = svc.searchSignalPaths('cpu/alu/res'); + // Both cpu0 and cpu1 have ALU with 'result' + expect(paths.length, equals(2)); + for (final p in paths) { + expect(p, contains('result')); + } + // Verify the query matches the same way + expect(q.matchOccurrence('cpu0', 0), isNotEmpty); + expect(q.matchOccurrence('alu', 1), isNotEmpty); + expect(q.matchSignal('result', 2), isTrue); + }); + + test('RegexQuery glob matches existing regex search', () { + // SoC/**/clk should find clk at many levels + final paths = svc.searchSignalPathsRegex('SoC/**/clk'); + // clk exists at: SoC, cpu0, cpu1, cpu0/regfile, cpu1/regfile, + // mem_ctrl, ch0, ch1, ch2, io_mux, uart0, uart1 = 12 total + expect(paths.length, equals(12)); + for (final p in paths) { + expect(p, endsWith('/clk')); + } + }); + + test('RegexQuery character class matches indexed signals', () { + final paths = svc.searchSignalPathsRegex('SoC/**/d[0-9]+'); + // d0, d1, d2, d15 in cpu0/regfile and cpu1/regfile = 8 total + expect(paths.length, equals(8)); + for (final p in paths) { + expect(p, matches(RegExp(r'/d\d+$'))); + } + }); + + test('RegexQuery alternation matches specific signals', () { + final paths = svc.searchSignalPathsRegex('SoC/**/(tx|rx)'); + // tx and rx in uart0 and uart1 = 4 total + expect(paths.length, equals(4)); + }); + + test('RegexQuery ch[0-2] matches channel occurrences', () { + final paths = svc.searchOccurrencePathsRegex('SoC/mem_ctrl/ch[0-2]'); + expect(paths.length, equals(3)); + expect( + paths, + containsAll([ + 'SoC/mem_ctrl/ch0', + 'SoC/mem_ctrl/ch1', + 'SoC/mem_ctrl/ch2', + ])); + }); + + test('RegexQuery *_mux matches occurrence by suffix', () { + final paths = svc.searchOccurrencePathsRegex('SoC/*_mux'); + expect(paths.length, equals(1)); + expect(paths.first, equals('SoC/io_mux')); + }); + + test('RegexQuery .*mux.* matches signals containing mux', () { + final paths = svc.searchSignalPathsRegex('SoC/**/.*mux.*'); + expect(paths, contains('SoC/io_mux/data_muxed')); + expect(paths, contains('SoC/io_mux/valid_muxed')); + }); + + test('PrefixQuery finds irq signals at root', () { + final paths = svc.searchSignalPaths('SoC/irq'); + expect(paths.length, equals(2)); + expect(paths, contains('SoC/irq0')); + expect(paths, contains('SoC/irq1')); + }); + + test('RegexQuery baud_sel across both UARTs', () { + final paths = svc.searchSignalPathsRegex('SoC/**/baud_sel'); + expect(paths.length, equals(2)); + expect(paths, contains('SoC/io_mux/uart0/baud_sel')); + expect(paths, contains('SoC/io_mux/uart1/baud_sel')); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/hierarchy_search_controller_test.dart b/packages/rohd_hierarchy/test/hierarchy_search_controller_test.dart new file mode 100644 index 000000000..bd7801f2a --- /dev/null +++ b/packages/rohd_hierarchy/test/hierarchy_search_controller_test.dart @@ -0,0 +1,505 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_search_controller_test.dart +// Tests for HierarchySearchController. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +/// Minimal hierarchy for testing the controller with a real +/// HierarchyService. +HierarchyOccurrence _buildTestTree() => HierarchyOccurrence( + name: 'Top', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'rst', width: 1), + ], + children: [ + HierarchyOccurrence( + name: 'cpu', + signals: [ + SignalOccurrence(name: 'data_in', width: 8), + SignalOccurrence(name: 'data_out', width: 8), + ], + children: [ + HierarchyOccurrence( + name: 'alu', + signals: [ + SignalOccurrence(name: 'a', width: 16), + SignalOccurrence(name: 'b', width: 16), + SignalOccurrence(name: 'result', width: 16), + ], + ), + ], + ), + HierarchyOccurrence( + name: 'mem', + signals: [ + SignalOccurrence(name: 'addr', width: 32), + ], + ), + ], + ); + +void main() { + late BaseHierarchyAdapter hierarchy; + late HierarchySearchController signalCtrl; + late HierarchySearchController moduleCtrl; + + setUp(() { + hierarchy = BaseHierarchyAdapter.fromTree(_buildTestTree()); + signalCtrl = HierarchySearchController.forSignals(hierarchy); + moduleCtrl = HierarchySearchController.forOccurrences(hierarchy); + }); + + group('HierarchySearchController — signal search', () { + test('starts with empty state', () { + expect(signalCtrl.results, isEmpty); + expect(signalCtrl.selectedIndex, 0); + expect(signalCtrl.hasResults, isFalse); + expect(signalCtrl.counterText, isEmpty); + expect(signalCtrl.currentSelection, isNull); + }); + + test('updateQuery populates results', () { + signalCtrl.updateQuery('clk'); + expect(signalCtrl.hasResults, isTrue); + expect(signalCtrl.results.first.name, 'clk'); + expect(signalCtrl.selectedIndex, 0); + }); + + test('updateQuery with empty string clears results', () { + signalCtrl.updateQuery('clk'); + expect(signalCtrl.hasResults, isTrue); + + signalCtrl.updateQuery(''); + expect(signalCtrl.hasResults, isFalse); + expect(signalCtrl.selectedIndex, 0); + }); + + test('updateQuery resets selectedIndex', () { + signalCtrl + ..updateQuery('data') + ..selectNext(); // index 1 + expect(signalCtrl.selectedIndex, 1); + + signalCtrl.updateQuery('data'); // re-search + expect(signalCtrl.selectedIndex, 0); // reset + }); + + test('normalise converts dots to slashes', () { + signalCtrl.updateQuery('cpu.alu.a'); + expect(signalCtrl.hasResults, isTrue); + expect(signalCtrl.results.first.name, 'a'); + }); + + test('counterText is correct', () { + signalCtrl.updateQuery('data'); + expect(signalCtrl.counterText, '1/${signalCtrl.results.length}'); + + signalCtrl.selectNext(); + expect(signalCtrl.counterText, '2/${signalCtrl.results.length}'); + }); + + test('currentSelection returns the highlighted result', () { + signalCtrl.updateQuery('data'); + final first = signalCtrl.currentSelection; + expect(first, isNotNull); + expect(first!.name, 'data_in'); + + signalCtrl.selectNext(); + expect(signalCtrl.currentSelection!.name, 'data_out'); + }); + + test('selectNext wraps around', () { + signalCtrl.updateQuery('data'); + final count = signalCtrl.results.length; + expect(count, greaterThan(1)); + + for (var i = 0; i < count; i++) { + signalCtrl.selectNext(); + } + expect(signalCtrl.selectedIndex, 0); // wrapped + }); + + test('selectPrevious wraps around', () { + signalCtrl + ..updateQuery('data') + ..selectPrevious(); // wraps from 0 → last + expect(signalCtrl.selectedIndex, signalCtrl.results.length - 1); + }); + + test('selectNext/selectPrevious no-op when empty', () { + signalCtrl.selectNext(); + expect(signalCtrl.selectedIndex, 0); + signalCtrl.selectPrevious(); + expect(signalCtrl.selectedIndex, 0); + }); + + test('clear resets everything', () { + signalCtrl + ..updateQuery('data') + ..selectNext(); + expect(signalCtrl.hasResults, isTrue); + expect(signalCtrl.selectedIndex, greaterThan(0)); + + signalCtrl.clear(); + expect(signalCtrl.results, isEmpty); + expect(signalCtrl.selectedIndex, 0); + expect(signalCtrl.currentSelection, isNull); + }); + + test('no results for non-matching query', () { + signalCtrl.updateQuery('xyz_no_match'); + expect(signalCtrl.hasResults, isFalse); + expect(signalCtrl.counterText, isEmpty); + }); + + test('plain query uses prefix match, not substring', () { + // 'a' should match signals starting with 'a' (addr, a), + // but NOT signals that merely contain 'a' (data_in, data_out). + signalCtrl.updateQuery('a'); + final names = signalCtrl.results.map((r) => r.name).toList(); + expect(names, contains('a')); // Top/cpu/alu/a + expect(names, contains('addr')); // Top/mem/addr + expect(names, isNot(contains('data_in'))); // 'a' is not a prefix + expect(names, isNot(contains('data_out'))); + }); + + test('glob * pattern routes to regex search', () { + // 'cpu/*_out' should match signals ending in '_out' under cpu + // (single-segment globs like '*_out' only search root-level; + // use a path segment to target a child module). + signalCtrl.updateQuery('cpu/*_out'); + expect(signalCtrl.hasResults, isTrue); + final names = signalCtrl.results.map((r) => r.name).toList(); + expect(names, contains('data_out')); + expect(names, isNot(contains('data_in'))); + }); + + test('glob * at end matches prefix', () { + signalCtrl.updateQuery('cpu/data*'); + final names = signalCtrl.results.map((r) => r.name).toList(); + expect(names, containsAll(['data_in', 'data_out'])); + }); + + test('glob * at root matches top-level signals', () { + // Single-segment glob only searches root module signals. + signalCtrl.updateQuery('*st'); + expect(signalCtrl.hasResults, isTrue); + final names = signalCtrl.results.map((r) => r.name).toList(); + expect(names, contains('rst')); + }); + }); + + group('HierarchySearchController — module search', () { + test('finds modules by name', () { + moduleCtrl.updateQuery('cpu'); + expect(moduleCtrl.hasResults, isTrue); + expect(moduleCtrl.results.first.occurrence.name, 'cpu'); + }); + + test('finds nested modules', () { + moduleCtrl.updateQuery('alu'); + expect(moduleCtrl.hasResults, isTrue); + expect(moduleCtrl.results.first.occurrence.name, 'alu'); + }); + + test('counterText and selection work for modules', () { + moduleCtrl.updateQuery('m'); // matches 'mem', possibly others + expect(moduleCtrl.hasResults, isTrue); + expect(moduleCtrl.counterText, isNotEmpty); + expect(moduleCtrl.currentSelection, isNotNull); + }); + }); + + group('scrollOffsetToReveal', () { + test('returns null when item is visible', () { + final offset = HierarchySearchController.scrollOffsetToReveal( + selectedIndex: 2, + itemHeight: 48, + viewportHeight: 300, + currentOffset: 0, + ); + // item at 96..144, viewport 0..300 → visible + expect(offset, isNull); + }); + + test('scrolls up when item is above viewport', () { + final offset = HierarchySearchController.scrollOffsetToReveal( + selectedIndex: 0, + itemHeight: 48, + viewportHeight: 300, + currentOffset: 100, + ); + // item at 0..48, viewport starts at 100 → need to scroll to 0 + expect(offset, 0.0); + }); + + test('scrolls down when item is below viewport', () { + final offset = HierarchySearchController.scrollOffsetToReveal( + selectedIndex: 10, + itemHeight: 48, + viewportHeight: 200, + currentOffset: 0, + ); + // item at 480..528, viewport 0..200 → scroll to 528-200 = 328 + expect(offset, 328.0); + }); + + test('returns null when item is at bottom edge', () { + final offset = HierarchySearchController.scrollOffsetToReveal( + selectedIndex: 4, + itemHeight: 50, + viewportHeight: 250, + currentOffset: 0, + ); + // item at 200..250, viewport 0..250 → exactly visible + expect(offset, isNull); + }); + }); + + // ------------------------------------------------------------------ + // VCD-style dot-separated paths + // ------------------------------------------------------------------ + group('VCD dot-separated paths', () { + late BaseHierarchyAdapter vcdHierarchy; + + setUp(() { + // VCD/FST files produce dot-separated IDs like "testbench.childA.clk" + vcdHierarchy = BaseHierarchyAdapter.fromTree( + HierarchyOccurrence( + name: 'testbench', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'rst', width: 1), + ], + children: [ + HierarchyOccurrence( + name: 'childA', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'data', width: 8), + ], + children: [ + HierarchyOccurrence( + name: 'sub', + signals: [ + SignalOccurrence(name: 'out', width: 4), + ], + ), + ], + ), + HierarchyOccurrence( + name: 'childB', + signals: [ + SignalOccurrence(name: 'enable', width: 1), + ], + ), + ], + ), + ); + }); + + test('searchSignalPaths with slash query finds dot-separated signal', () { + // User types "childA/clk" — walker normalises to hierarchySeparator + final results = vcdHierarchy.searchSignalPaths('childA/clk'); + expect(results, isNotEmpty); + expect(results, contains('testbench/childA/clk')); + }); + + test('searchSignalPaths with slash query finds deep signal', () { + final results = vcdHierarchy.searchSignalPaths('childA/sub/out'); + expect(results, isNotEmpty); + expect(results, contains('testbench/childA/sub/out')); + }); + + test('searchSignals with slash query finds dot-separated signal', () { + final results = vcdHierarchy.searchSignals('childA/clk'); + expect(results, isNotEmpty); + expect(results.first.signal!.path(), 'testbench/childA/clk'); + }); + + test('searchModules with slash query finds dot-separated module', () { + final results = vcdHierarchy.searchOccurrences('childA'); + expect(results, isNotEmpty); + expect(results.first.occurrence.path(), 'testbench/childA'); + }); + + test('searchSignalPaths with dot query still works', () { + // Dots in query are treated as separators too + final results = vcdHierarchy.searchSignalPaths('childA.clk'); + expect(results, isNotEmpty); + expect(results, contains('testbench/childA/clk')); + }); + + test('searchSignals with glob on dot-separated paths', () { + // Glob wildcard should work across dot-separated IDs + final results = vcdHierarchy.searchSignals('**/clk'); + expect(results.length, greaterThanOrEqualTo(2)); + final ids = results.map((r) => r.signal!.path()).toSet(); + expect(ids, contains('testbench/clk')); + expect(ids, contains('testbench/childA/clk')); + }); + + test('searchSignals with single segment on dot-separated paths', () { + // Single segment search should use startsWith + final results = vcdHierarchy.searchSignals('ena'); + expect(results, isNotEmpty); + expect(results.first.signal!.path(), 'testbench/childB/enable'); + }); + + test('controller forSignals works with dot-separated hierarchy', () { + final ctrl = + HierarchySearchController.forSignals(vcdHierarchy) + ..updateQuery('childA/clk'); + expect(ctrl.results, isNotEmpty); + expect(ctrl.results.first.signal!.path(), 'testbench/childA/clk'); + }); + }); + + // ------------------------------------------------------------------ + // DevTools flow — hierarchy with local signal IDs + // ------------------------------------------------------------------ + group('DevTools flow — local signal IDs → BaseHierarchyAdapter.fromTree', () { + late BaseHierarchyAdapter rohdHierarchy; + late HierarchySearchController rohdSignalCtrl; + late HierarchySearchController rohdModuleCtrl; + + setUp(() { + // Build a tree with local signal IDs (not full paths) — this is the key + // difference from the VCD path where IDs are full paths. + final alu = HierarchyOccurrence( + name: 'alu', + signals: [ + SignalOccurrence( + name: 'a', + width: 16, + direction: 'input', + ), + SignalOccurrence( + name: 'b', + width: 16, + direction: 'input', + ), + SignalOccurrence( + name: 'result', + width: 16, + direction: 'output', + ), + ], + ); + final cpu = HierarchyOccurrence( + name: 'cpu', + children: [alu], + signals: [ + SignalOccurrence( + name: 'data_in', + width: 8, + direction: 'input', + ), + SignalOccurrence( + name: 'data_out', + width: 8, + direction: 'output', + ), + ], + ); + final mem = HierarchyOccurrence( + name: 'mem', + signals: [ + SignalOccurrence( + name: 'addr', + width: 32, + direction: 'input', + ), + ], + ); + final root = HierarchyOccurrence( + name: 'Top', + children: [cpu, mem], + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'rst', + width: 1, + direction: 'input', + ), + ], + )..buildAddresses(); + rohdHierarchy = BaseHierarchyAdapter.fromTree(root); + rohdSignalCtrl = HierarchySearchController.forSignals(rohdHierarchy); + rohdModuleCtrl = HierarchySearchController.forOccurrences(rohdHierarchy); + }); + + test('signal IDs are local (not full paths)', () { + final rootSigs = rohdHierarchy.root.signals; + final clk = rootSigs.firstWhere((s) => s.name == 'clk'); + expect(clk.name, 'clk'); + expect(clk.path(), 'Top/clk'); + }); + + test('updateQuery finds signals despite local IDs', () { + rohdSignalCtrl.updateQuery('clk'); + expect(rohdSignalCtrl.hasResults, isTrue); + expect(rohdSignalCtrl.results.first.name, 'clk'); + }); + + test('signalByAddress works with full path', () { + final addr = + OccurrenceAddress.tryFromPathname('Top/clk', rohdHierarchy.root); + final result = rohdHierarchy.signalByAddress(addr!); + expect(result, isNotNull); + expect(result!.name, 'clk'); + }); + + test('signalByAddress works with nested path', () { + final addr = OccurrenceAddress.tryFromPathname( + 'Top/cpu/alu/a', rohdHierarchy.root); + final result = rohdHierarchy.signalByAddress(addr!); + expect(result, isNotNull); + expect(result!.name, 'a'); + }); + + test('path-based search narrows to module', () { + rohdSignalCtrl.updateQuery('cpu/data'); + expect(rohdSignalCtrl.hasResults, isTrue); + final names = rohdSignalCtrl.results.map((r) => r.name).toSet(); + expect(names, containsAll(['data_in', 'data_out'])); + }); + + test('glob search works', () { + rohdSignalCtrl.updateQuery('**/a'); + expect(rohdSignalCtrl.hasResults, isTrue); + final names = rohdSignalCtrl.results.map((r) => r.name).toSet(); + expect(names, contains('a')); + }); + + test('module search works', () { + rohdModuleCtrl.updateQuery('alu'); + expect(rohdModuleCtrl.hasResults, isTrue); + expect(rohdModuleCtrl.results.first.occurrence.name, 'alu'); + }); + + test('search results match VCD-style tree results', () { + // The SAME queries should produce the same signal NAMES as + // the manually-built tree (VCD path), even though signal IDs differ. + rohdSignalCtrl.updateQuery('data'); + final rohdNames = rohdSignalCtrl.results.map((r) => r.name).toSet(); + + signalCtrl.updateQuery('data'); + final vcdNames = signalCtrl.results.map((r) => r.name).toSet(); + + expect(rohdNames, vcdNames, + reason: 'Same query should find same signals regardless of source'); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/module_search_test.dart b/packages/rohd_hierarchy/test/module_search_test.dart new file mode 100644 index 000000000..9339492cf --- /dev/null +++ b/packages/rohd_hierarchy/test/module_search_test.dart @@ -0,0 +1,325 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// module_search_test.dart +// Tests for module tree search functionality using hierarchy API. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + group('Module Tree Search - HierarchyService', () { + late HierarchyOccurrence root; + late HierarchyService hierarchy; + + setUpAll(() { + // Create a test hierarchy + // Top + // CPU (2 children) + // ALU + // Decoder + // Memory + // ControlUnit + + final alu = HierarchyOccurrence( + name: 'ALU', + ); + + final decoder = HierarchyOccurrence( + name: 'Decoder', + ); + + final cpu = HierarchyOccurrence( + name: 'CPU', + children: [alu, decoder], + ); + + final memory = HierarchyOccurrence( + name: 'Memory', + ); + + final controlUnit = HierarchyOccurrence( + name: 'ControlUnit', + ); + + root = HierarchyOccurrence( + name: 'Top', + children: [cpu, memory, controlUnit], + ); + + // Use BaseHierarchyAdapter.fromTree to convert to HierarchyService + hierarchy = BaseHierarchyAdapter.fromTree(root); + }); + + test('root node is accessible', () { + expect(hierarchy.root.name, equals('Top')); + expect(hierarchy.root.isPrimitive, isFalse); + }); + + test('children of root are accessible', () { + final children = hierarchy.root.children; + expect(children, isNotEmpty); + expect(children.length, equals(3)); + expect(children.any((c) => c.name == 'CPU'), isTrue); + }); + + test('searchNodePaths finds CPU module', () { + final results = hierarchy.searchOccurrencePaths('CPU'); + expect(results, isNotEmpty, + reason: 'Should find CPU module by simple name'); + expect(results.any((path) => path.contains('CPU')), isTrue); + }); + + test('searchNodePaths finds ALU with hierarchical query', () { + final results = hierarchy.searchOccurrencePaths('CPU/ALU'); + expect(results, isNotEmpty, + reason: 'Should find ALU with hierarchical path'); + expect(results.any((path) => path.contains('ALU')), isTrue); + }); + + test('searchNodePaths works with dot notation', () { + final results = hierarchy.searchOccurrencePaths('Top.CPU.ALU'); + expect(results, isNotEmpty, reason: 'Should find ALU with dot notation'); + expect(results.any((path) => path.contains('Top/CPU/ALU')), isTrue); + }); + + test('searchNodePaths limits results', () { + final results = hierarchy.searchOccurrencePaths('', limit: 2); + expect(results.length, lessThanOrEqualTo(2), + reason: 'Should respect limit parameter'); + }); + + test('searchModules returns ModuleSearchResult objects', () { + final results = hierarchy.searchOccurrences('Memory'); + expect(results, isNotEmpty); + expect(results.first, isA()); + expect(results.first.name, equals('Memory')); + expect(results.first.isModule, isTrue); + }); + + test('searchModules result contains full metadata', () { + final results = hierarchy.searchOccurrences('Decoder'); + expect(results, isNotEmpty); + final result = results.first; + expect(result.occurrenceId, contains('Decoder')); + expect(result.path, isNotEmpty); + expect(result.path.last, equals('Decoder')); + expect(result.occurrence, isNotNull); + }); + + test('searchNodePaths returns empty for non-matching query', () { + final results = hierarchy.searchOccurrencePaths('nonexistent'); + expect(results, isEmpty, + reason: 'Should return empty list for non-matching query'); + }); + + test('searchNodePaths returns empty for empty query', () { + final results = hierarchy.searchOccurrencePaths(''); + expect(results, isEmpty, + reason: 'Should return empty list for empty query'); + }); + + test('searchModules finds modules at different depths', () { + // Should find both Top and Top/CPU + final results = hierarchy.searchOccurrences('Top'); + expect(results.length, greaterThanOrEqualTo(1)); + expect(results.any((r) => r.name == 'Top'), isTrue); + }); + }); + + group('Module Search - Hierarchical Matching', () { + late HierarchyService hierarchy; + + setUpAll(() { + // Create a deeper hierarchy to test matching + // Design + // ProcessingUnit + // DataPath + // Multiplier + // Adder + // Controller + // Memory + // RAM + // Cache + + final multiplier = HierarchyOccurrence( + name: 'Multiplier', + ); + + final adder = HierarchyOccurrence( + name: 'Adder', + ); + + final dataPath = HierarchyOccurrence( + name: 'DataPath', + children: [multiplier, adder], + ); + + final controller = HierarchyOccurrence( + name: 'Controller', + ); + + final processingUnit = HierarchyOccurrence( + name: 'ProcessingUnit', + children: [dataPath, controller], + ); + + final ram = HierarchyOccurrence( + name: 'RAM', + ); + + final cache = HierarchyOccurrence( + name: 'Cache', + ); + + final memory = HierarchyOccurrence( + name: 'Memory', + children: [ram, cache], + ); + + final root = HierarchyOccurrence( + name: 'Design', + children: [processingUnit, memory], + ); + + hierarchy = BaseHierarchyAdapter.fromTree(root); + }); + + test('single segment matches at any level', () { + final results = hierarchy.searchOccurrencePaths('Multiplier'); + expect(results, isNotEmpty, + reason: 'Should find Multiplier even without full path'); + expect(results.any((r) => r.endsWith('Multiplier')), isTrue); + }); + + test('two segment path matches correctly', () { + final results = hierarchy.searchOccurrencePaths('DataPath/Multiplier'); + expect(results.any((r) => r.contains('DataPath/Multiplier')), isTrue, + reason: 'Should find Multiplier under DataPath'); + }); + + test('full hierarchical path matches precisely', () { + final results = + hierarchy.searchOccurrencePaths('ProcessingUnit/DataPath/Adder'); + expect(results.any((r) => r.contains('ProcessingUnit/DataPath/Adder')), + isTrue, + reason: 'Should find Adder with full hierarchical path'); + }); + + test('partial name matching works', () { + final results1 = hierarchy.searchOccurrencePaths('Path'); + expect(results1.any((r) => r.contains('DataPath')), isTrue, + reason: 'Should match partial "path" in DataPath'); + + final results2 = hierarchy.searchOccurrencePaths('Unit'); + expect(results2.any((r) => r.contains('ProcessingUnit')), isTrue, + reason: 'Should match partial "unit" in ProcessingUnit'); + }); + }); + + group('Module Search - Integration with Tree Filtering', () { + late HierarchyOccurrence root; + + setUpAll(() { + final alu = HierarchyOccurrence( + name: 'ALU', + ); + + final cpu = HierarchyOccurrence( + name: 'CPU', + children: [alu], + ); + + final memory = HierarchyOccurrence( + name: 'Memory', + ); + + root = HierarchyOccurrence( + name: 'Top', + children: [cpu, memory], + ); + }); + + test('hierarchical filtering shows root when descendant matches', () { + final matchesSearch = _filterNodeRecursive(root, 'alu'); + expect(matchesSearch, isTrue, + reason: 'Root should be shown because descendant matches'); + }); + + test('hierarchical filtering shows parent of matching child', () { + final cpuNode = root.children.first; + final cpuMatches = _filterNodeRecursive(cpuNode, 'alu'); + expect(cpuMatches, isTrue, + reason: 'CPU should be shown because child ALU matches'); + }); + + test('hierarchical filtering hides node without matching descendants', () { + final memoryNode = root.children.last; + final memoryMatches = _filterNodeRecursive(memoryNode, 'alu'); + expect(memoryMatches, isFalse, + reason: 'Memory should be hidden because no ALU descendant'); + }); + + test('path separator search shows root for hierarchical match', () { + final matchesSearch = _filterNodeRecursive(root, 'cpu/alu'); + expect(matchesSearch, isTrue, + reason: 'Root should be shown for hierarchical search'); + }); + + test('path separator search shows matching parent', () { + final cpuNode = root.children.first; + final cpuMatches = _filterNodeRecursive(cpuNode, 'cpu/alu'); + expect(cpuMatches, isTrue, + reason: 'CPU should be shown for hierarchical search'); + }); + + test('path separator search hides non-matching subtree', () { + final memoryNode = root.children.last; + final memoryMatches = _filterNodeRecursive(memoryNode, 'cpu/alu'); + expect(memoryMatches, isFalse, + reason: 'Memory should be hidden for non-matching path'); + }); + }); +} + +/// Helper function to simulate tree filtering with hierarchical search. +/// Matches query against node name using hierarchical logic. +bool _filterNodeRecursive(HierarchyOccurrence node, String query) { + final queryParts = query + .replaceAll('.', '/') + .toLowerCase() + .split('/') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + + return _matchesHierarchicalQuery(node, queryParts, 0); +} + +bool _matchesHierarchicalQuery( + HierarchyOccurrence node, List queryParts, int queryIdx) { + if (queryIdx >= queryParts.length) { + return true; + } + + final currentQueryPart = queryParts[queryIdx].toLowerCase(); + final nodeName = node.name.toLowerCase(); + + final matched = nodeName.contains(currentQueryPart); + final nextQueryIdx = matched ? queryIdx + 1 : queryIdx; + + if (nextQueryIdx >= queryParts.length) { + return true; + } + + for (final child in node.children) { + if (_matchesHierarchicalQuery(child, queryParts, nextQueryIdx)) { + return true; + } + } + + return false; +} diff --git a/packages/rohd_hierarchy/test/occurrence_address_test.dart b/packages/rohd_hierarchy/test/occurrence_address_test.dart new file mode 100644 index 000000000..aafde9050 --- /dev/null +++ b/packages/rohd_hierarchy/test/occurrence_address_test.dart @@ -0,0 +1,335 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// occurrence_address_test.dart +// Unit tests for OccurrenceAddress class. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + group('OccurrenceAddress', () { + test('child() appends module index', () { + final addr = OccurrenceAddress.root.child(0).child(2).child(4); + expect(addr.path, equals([0, 2, 4])); + }); + + test('signal() appends signal index', () { + final addr = const OccurrenceAddress([0, 1]).signal(5); + expect(addr.path, equals([0, 1, 5])); + }); + + test('equality and hashcode work correctly', () { + const addr1 = OccurrenceAddress([0, 2, 4]); + const addr2 = OccurrenceAddress([0, 2, 4]); + const addr3 = OccurrenceAddress([0, 2, 5]); + + expect(addr1, equals(addr2)); + expect(addr1.hashCode, equals(addr2.hashCode)); + expect(addr1, isNot(equals(addr3))); + expect(addr1.hashCode, isNot(equals(addr3.hashCode))); + }); + + test('toString() returns debug string', () { + expect(OccurrenceAddress.root.toString(), equals('[ROOT]')); + expect(const OccurrenceAddress([0, 2, 4]).toString(), equals('[0.2.4]')); + }); + + test('toDotString() returns dot-separated path', () { + expect(OccurrenceAddress.root.toDotString(), equals('')); + expect(const OccurrenceAddress([0]).toDotString(), equals('0')); + expect(const OccurrenceAddress([0, 2, 4]).toDotString(), equals('0.2.4')); + expect( + const OccurrenceAddress([10, 200]).toDotString(), equals('10.200')); + }); + + test('fromDotString() parses dot-separated path', () { + expect( + OccurrenceAddress.fromDotString(''), equals(OccurrenceAddress.root)); + expect(OccurrenceAddress.fromDotString('0'), + equals(const OccurrenceAddress([0]))); + expect(OccurrenceAddress.fromDotString('0.2.4'), + equals(const OccurrenceAddress([0, 2, 4]))); + expect(OccurrenceAddress.fromDotString('10.200'), + equals(const OccurrenceAddress([10, 200]))); + }); + + test('toDotString/fromDotString round-trip', () { + final testCases = [ + OccurrenceAddress.root, + const OccurrenceAddress([0]), + const OccurrenceAddress([5, 10, 15]), + const OccurrenceAddress([0, 0, 0]), + const OccurrenceAddress([255]), + const OccurrenceAddress([0, 1, 2, 3, 4, 5]), + ]; + for (final original in testCases) { + final dot = original.toDotString(); + final restored = OccurrenceAddress.fromDotString(dot); + expect(restored, equals(original), reason: 'Failed for $original'); + } + }); + }); + + group('OccurrenceAddress with HierarchyNode integration', () { + late HierarchyOccurrence root; + + setUp(() { + // Build a simple tree structure + final child0 = HierarchyOccurrence( + name: 'child_0', + signals: [ + SignalOccurrence( + name: 'sig0', + width: 1, + ), + SignalOccurrence( + name: 'sig1', + width: 8, + ), + ], + ); + + final grandchild = HierarchyOccurrence( + name: 'grandchild_0', + signals: [ + SignalOccurrence( + name: 'sig0', + width: 1, + ), + ], + ); + + final child1 = HierarchyOccurrence( + name: 'child_1', + signals: [ + SignalOccurrence( + name: 'sig0', + width: 4, + ), + ], + ); + + child0.children.add(grandchild); + + root = HierarchyOccurrence( + name: 'root', + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + ), + ], + children: [child0, child1], + ) + // Build addresses for all nodes + ..buildAddresses(); + }); + + test('buildAddresses assigns address to root', () { + expect(root.address, equals(OccurrenceAddress.root)); + }); + + test('buildAddresses assigns addresses to all nodes', () { + expect(root.children[0].address, equals(const OccurrenceAddress([0]))); + expect(root.children[1].address, equals(const OccurrenceAddress([1]))); + expect(root.children[0].children[0].address, + equals(const OccurrenceAddress([0, 0]))); + }); + + test('buildAddresses assigns addresses to all signals', () { + // Root signals + expect(root.signals[0].address, equals(const OccurrenceAddress([0]))); + + // Child signals + expect(root.children[0].signals[0].address, + equals(const OccurrenceAddress([0, 0]))); + expect(root.children[0].signals[1].address, + equals(const OccurrenceAddress([0, 1]))); + + // Grandchild signals + expect(root.children[0].children[0].signals[0].address, + equals(const OccurrenceAddress([0, 0, 0]))); + }); + }); + + group('HierarchyOccurrence.parent', () { + test('parent is null for root', () { + final root = HierarchyOccurrence(name: 'Top')..buildAddresses(); + expect(root.parent, isNull); + }); + + test('parent is set for child nodes after buildAddresses', () { + final child = HierarchyOccurrence(name: 'sub'); + final root = HierarchyOccurrence(name: 'Top', children: [child]) + ..buildAddresses(); + expect(child.parent, same(root)); + expect(child.path(), 'Top/sub'); + }); + }); + + group('HierarchyOccurrence.definition', () { + test('type is null when not provided', () { + final n = HierarchyOccurrence(name: 'a'); + expect(n.definition, isNull); + }); + + test('type is stored when provided', () { + final n = HierarchyOccurrence(name: 'a', definition: 'Counter'); + expect(n.definition, 'Counter'); + }); + }); + + group('isPrimitive on nodes', () { + test('default isPrimitive is false', () { + final n = HierarchyOccurrence(name: 'sub'); + expect(n.isPrimitive, isFalse); + }); + }); + + group('buildAddresses ports-first ordering', () { + test('ports get lower signal indices than internal signals', () { + final root = HierarchyOccurrence( + name: 'Top', + signals: [ + SignalOccurrence(name: 'internal_a', width: 8), + SignalOccurrence( + name: 'clk', width: 1, direction: 'input', portIndex: 0), + SignalOccurrence(name: 'internal_b', width: 4), + SignalOccurrence( + name: 'out', width: 8, direction: 'output', portIndex: 1), + ], + )..buildAddresses(); + + final byName = {for (final s in root.signals) s.name: s}; + + // Ports should get indices 0 and 1 + expect(byName['clk']!.address, equals(const OccurrenceAddress([0]))); + expect(byName['out']!.address, equals(const OccurrenceAddress([1]))); + + // Internal signals get indices 2 and 3 + expect( + byName['internal_a']!.address, equals(const OccurrenceAddress([2]))); + expect( + byName['internal_b']!.address, equals(const OccurrenceAddress([3]))); + }); + + test('portIndex matches signal address index', () { + final root = HierarchyOccurrence( + name: 'Mod', + signals: [ + SignalOccurrence( + name: 'a', width: 1, direction: 'input', portIndex: 0), + SignalOccurrence( + name: 'b', width: 1, direction: 'input', portIndex: 1), + SignalOccurrence( + name: 'y', width: 1, direction: 'output', portIndex: 2), + SignalOccurrence(name: 'net0', width: 1), + ], + )..buildAddresses(); + + for (final s in root.signals) { + if (s.isPort) { + // portIndex should equal the last element of the address path + expect(s.address!.path.last, equals(s.portIndex), + reason: '${s.name}: portIndex=${s.portIndex} ' + 'but address index=${s.address!.path.last}'); + } + } + }); + + test('portCount returns correct count', () { + final occ = HierarchyOccurrence( + name: 'X', + signals: [ + SignalOccurrence( + name: 'a', width: 1, direction: 'input', portIndex: 0), + SignalOccurrence(name: 'b', width: 1), + SignalOccurrence( + name: 'c', width: 1, direction: 'output', portIndex: 1), + ], + ); + expect(occ.portCount, equals(2)); + }); + + test('all-ports occurrence: indices match list order', () { + final occ = HierarchyOccurrence( + name: 'Buf', + signals: [ + SignalOccurrence( + name: 'in', width: 8, direction: 'input', portIndex: 0), + SignalOccurrence( + name: 'out', width: 8, direction: 'output', portIndex: 1), + ], + )..buildAddresses(); + + expect(occ.signals[0].address, equals(const OccurrenceAddress([0]))); + expect(occ.signals[1].address, equals(const OccurrenceAddress([1]))); + }); + + test('all-internal occurrence: indices unchanged', () { + final occ = HierarchyOccurrence( + name: 'Internal', + signals: [ + SignalOccurrence(name: 'x', width: 1), + SignalOccurrence(name: 'y', width: 1), + ], + )..buildAddresses(); + + expect(occ.signals[0].address, equals(const OccurrenceAddress([0]))); + expect(occ.signals[1].address, equals(const OccurrenceAddress([1]))); + }); + + test('nested: ports-first ordering applies at every level', () { + final child = HierarchyOccurrence( + name: 'sub', + signals: [ + SignalOccurrence(name: 'net', width: 1), + SignalOccurrence( + name: 'p', width: 1, direction: 'input', portIndex: 0), + ], + ); + final root = HierarchyOccurrence( + name: 'Top', + children: [child], + signals: [ + SignalOccurrence(name: 'net_top', width: 1), + SignalOccurrence( + name: 'clk', width: 1, direction: 'input', portIndex: 0), + ], + )..buildAddresses(); + + // Root: clk (port) at 0, net_top (internal) at 1 + final rootByName = {for (final s in root.signals) s.name: s}; + expect(rootByName['clk']!.address!.path.last, equals(0)); + expect(rootByName['net_top']!.address!.path.last, equals(1)); + + // Child: p (port) at 0, net (internal) at 1 + final childByName = {for (final s in child.signals) s.name: s}; + expect(childByName['p']!.address!.path.last, equals(0)); + expect(childByName['net']!.address!.path.last, equals(1)); + }); + }); + + group('SignalOccurrence.portIndex', () { + test('portIndex is null for internal signals', () { + final s = SignalOccurrence(name: 'net', width: 1); + expect(s.portIndex, isNull); + expect(s.isPort, isFalse); + }); + + test('portIndex is set for port signals', () { + final s = SignalOccurrence( + name: 'clk', + width: 1, + direction: 'input', + portIndex: 3, + ); + expect(s.portIndex, equals(3)); + expect(s.isPort, isTrue); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/regex_search_test.dart b/packages/rohd_hierarchy/test/regex_search_test.dart new file mode 100644 index 000000000..51f134f21 --- /dev/null +++ b/packages/rohd_hierarchy/test/regex_search_test.dart @@ -0,0 +1,546 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// regex_search_test.dart +// Tests for regex-based hierarchy search. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + group('Regex search - HierarchyService', () { + late HierarchyService hierarchy; + + setUpAll(() { + // Build a test hierarchy: + // + // Top + // CPU + // ALU signals: [a, b, result, carry_out] + // Decoder signals: [opcode, enable] + // RegFile signals: [clk, reset, d0, d1, d2, d15] + // Memory + // Cache signals: [clk, addr, data, hit] + // DRAM signals: [clk, cas, ras] + // IO + // UART signals: [clk, tx, rx] + // signals (Top): [clk, reset] + + final alu = HierarchyOccurrence( + name: 'ALU', + signals: [ + SignalOccurrence(name: 'a', width: 8), + SignalOccurrence(name: 'b', width: 8), + SignalOccurrence(name: 'result', width: 8), + SignalOccurrence(name: 'carry_out', width: 1), + ], + ); + + final decoder = HierarchyOccurrence( + name: 'Decoder', + signals: [ + SignalOccurrence(name: 'opcode', width: 4), + SignalOccurrence(name: 'enable', width: 1), + ], + ); + + final regFile = HierarchyOccurrence( + name: 'RegFile', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'd0', width: 8), + SignalOccurrence(name: 'd1', width: 8), + SignalOccurrence(name: 'd2', width: 8), + SignalOccurrence(name: 'd15', width: 8), + ], + ); + + final cpu = HierarchyOccurrence( + name: 'CPU', + children: [alu, decoder, regFile], + ); + + final cache = HierarchyOccurrence( + name: 'Cache', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'addr', width: 16), + SignalOccurrence(name: 'data', width: 32), + SignalOccurrence(name: 'hit', width: 1), + ], + ); + + final dram = HierarchyOccurrence( + name: 'DRAM', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'cas', width: 1), + SignalOccurrence(name: 'ras', width: 1), + ], + ); + + final memory = HierarchyOccurrence( + name: 'Memory', + children: [cache, dram], + ); + + final uart = HierarchyOccurrence( + name: 'UART', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'tx', width: 1), + SignalOccurrence(name: 'rx', width: 1), + ], + ); + + final io = HierarchyOccurrence( + name: 'IO', + children: [uart], + ); + + final root = HierarchyOccurrence( + name: 'Top', + children: [cpu, memory, io], + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'data_m', width: 8), + SignalOccurrence(name: 'addr_m', width: 16), + SignalOccurrence(name: 'flag_m', width: 1), + ], + ); + + hierarchy = BaseHierarchyAdapter.fromTree(root); + }); + + // ── Exact match ── + + test('exact path matches single signal', () { + final results = hierarchy.searchSignalPathsRegex('Top/CPU/ALU/result'); + expect(results, contains('Top/CPU/ALU/result')); + expect(results.length, 1); + }); + + test('dot in regex pattern is treated as regex metachar, not separator', + () { + // In regex mode, `.` is NOT a hierarchy separator — only `/` is. + // `Top.CPU` is a single segment meaning "Top" + any char + "CPU". + final results = hierarchy.searchSignalPathsRegex('Top.CPU.ALU.result'); + // No match because the hierarchy root is "Top", not "Top.CPU.ALU" + expect(results, isEmpty); + }); + + // ── Wildcard at one level ── + + test('.* matches all signals in a module', () { + final results = hierarchy.searchSignalPathsRegex('Top/CPU/ALU/.*'); + expect( + results, + containsAll([ + 'Top/CPU/ALU/a', + 'Top/CPU/ALU/b', + 'Top/CPU/ALU/result', + 'Top/CPU/ALU/carry_out', + ])); + expect(results.length, 4); + }); + + test('.* matches all children at a module level', () { + final results = hierarchy.searchSignalPathsRegex('Top/.*/clk'); + // Should match CPU/RegFile/clk but not deeper (** would be needed + // for that). .* represents any single-level child of Top. + // Top has children CPU, Memory, IO — none of them have clk directly + // (Top's own signals aren't "children"). Actually let's check: + // Top/.*/clk means: Top / (any child) / clk as signal + // That doesn't match because clk is in deeper modules. + // This should return empty for signals one level below Top. + expect(results, isEmpty); + }); + + test('.* matches modules at one level for signal search', () { + // Top/CPU/.*/clk — matches ALU, Decoder, RegFile; only RegFile has clk + final results = hierarchy.searchSignalPathsRegex('Top/CPU/.*/clk'); + expect(results, contains('Top/CPU/RegFile/clk')); + expect(results.length, 1); + }); + + // ── Glob-star ** ── + + test('** matches signals at any depth', () { + final results = hierarchy.searchSignalPathsRegex('Top/**/clk'); + expect( + results, + containsAll([ + 'Top/CPU/RegFile/clk', + 'Top/Memory/Cache/clk', + 'Top/Memory/DRAM/clk', + 'Top/IO/UART/clk', + ])); + // Top's own clk is also accessible through ** matching zero levels + expect(results, contains('Top/clk')); + }); + + test('** at beginning matches everything', () { + final results = hierarchy.searchSignalPathsRegex('**/clk'); + // All clk signals anywhere + expect(results.length, greaterThanOrEqualTo(5)); + expect( + results, + containsAll([ + 'Top/clk', + 'Top/CPU/RegFile/clk', + 'Top/Memory/Cache/clk', + 'Top/Memory/DRAM/clk', + 'Top/IO/UART/clk', + ])); + }); + + test('** between levels matches across boundaries', () { + final results = hierarchy.searchSignalPathsRegex('Top/CPU/**/d0'); + expect(results, contains('Top/CPU/RegFile/d0')); + expect(results.length, 1); + }); + + test('** with regex signal pattern', () { + final results = hierarchy.searchSignalPathsRegex('Top/**/d[0-9]+'); + expect( + results, + containsAll([ + 'Top/CPU/RegFile/d0', + 'Top/CPU/RegFile/d1', + 'Top/CPU/RegFile/d2', + 'Top/CPU/RegFile/d15', + ])); + expect(results.length, 4); + }); + + // ── Regex character classes ── + + test('character class in signal name', () { + final results = + hierarchy.searchSignalPathsRegex('Top/CPU/RegFile/d[0-2]'); + expect( + results, + containsAll([ + 'Top/CPU/RegFile/d0', + 'Top/CPU/RegFile/d1', + 'Top/CPU/RegFile/d2', + ])); + expect(results, isNot(contains('Top/CPU/RegFile/d15'))); + }); + + // ── Alternation ── + + test('alternation in signal name', () { + final results = hierarchy.searchSignalPathsRegex('Top/**/(?:clk|reset)'); + expect( + results, + containsAll([ + 'Top/clk', + 'Top/reset', + 'Top/CPU/RegFile/clk', + 'Top/CPU/RegFile/reset', + 'Top/Memory/Cache/clk', + 'Top/Memory/DRAM/clk', + 'Top/IO/UART/clk', + ])); + expect(results.length, 7); + }); + + test('alternation in module name', () { + final results = hierarchy.searchSignalPathsRegex('Top/(CPU|IO)/.*/clk'); + expect( + results, + containsAll([ + 'Top/CPU/RegFile/clk', + 'Top/IO/UART/clk', + ])); + }); + + // ── Module search ── + + test('searchOccurrencePathsRegex finds modules', () { + final results = hierarchy.searchOccurrencePathsRegex('Top/CPU/.*'); + expect( + results, + containsAll([ + 'Top/CPU/ALU', + 'Top/CPU/Decoder', + 'Top/CPU/RegFile', + ])); + }); + + test('searchOccurrencePathsRegex with **', () { + final results = hierarchy.searchOccurrencePathsRegex('Top/**/DRAM'); + expect(results, contains('Top/Memory/DRAM')); + }); + + // ── Enriched results ── + + test('searchSignalsRegex returns SignalSearchResult objects', () { + final results = hierarchy.searchSignalsRegex('Top/CPU/ALU/result'); + expect(results.length, 1); + // signalId uses the normalised hierarchySeparator ('/') format + // from the tree walker — findSignalById normalises both '.' and '/'. + expect(results.first.signalId, 'Top/CPU/ALU/result'); + expect(results.first.signal, isNotNull); + expect(results.first.signal!.name, 'result'); + }); + + test('searchSignalsRegex returns results with SignalOccurrence objects', + () { + final results = hierarchy.searchSignalsRegex('Top/**/carry_out'); + expect(results.length, 1); + expect(results.first.signal, isNotNull); + expect(results.first.signal!.name, 'carry_out'); + expect(results.first.signal!.width, 1); + }); + + test('searchOccurrencesRegex returns OccurrenceSearchResult objects', () { + final results = hierarchy.searchOccurrencesRegex('Top/**/Cache'); + expect(results.length, 1); + expect(results.first.occurrenceId, 'Top/Memory/Cache'); + }); + + // ── Limit ── + + test('limit controls maximum results', () { + final results = hierarchy.searchSignalPathsRegex('Top/**/.+', limit: 3); + expect(results.length, 3); + }); + + // ── Glob-style wildcards ── + + test('glob * at start matches suffix pattern', () { + // User's scenario: "*m" should match signals ending in "m". + final results = hierarchy.searchSignalPathsRegex('Top/*_m'); + expect( + results, + containsAll([ + 'Top/data_m', + 'Top/addr_m', + 'Top/flag_m', + ])); + expect(results.length, 3); + }); + + test('glob * at end matches prefix pattern', () { + final results = hierarchy.searchSignalPathsRegex('Top/CPU/RegFile/d*'); + expect( + results, + containsAll([ + 'Top/CPU/RegFile/d0', + 'Top/CPU/RegFile/d1', + 'Top/CPU/RegFile/d2', + 'Top/CPU/RegFile/d15', + ])); + expect(results.length, 4); + }); + + test('glob * in the middle matches infix pattern', () { + // *d*a* should match names containing 'd' followed eventually by 'a' + final results = + hierarchy.searchSignalPathsRegex('Top/Memory/Cache/*d*a*'); + expect(results, contains('Top/Memory/Cache/data')); + }); + + test('glob * matches all signals (like .*)', () { + final results = hierarchy.searchSignalPathsRegex('Top/CPU/ALU/*'); + expect( + results, + containsAll([ + 'Top/CPU/ALU/a', + 'Top/CPU/ALU/b', + 'Top/CPU/ALU/result', + 'Top/CPU/ALU/carry_out', + ])); + expect(results.length, 4); + }); + + test('glob * in module level matches any child', () { + final results = hierarchy.searchSignalPathsRegex('Top/*/clk'); + // Top's immediate module-children are CPU, Memory, IO — none of + // them have a direct clk signal, so this is empty. + expect(results, isEmpty); + }); + + test('glob * combined with ** for deep search', () { + final results = hierarchy.searchSignalPathsRegex('Top/**/*_m'); + expect( + results, + containsAll([ + 'Top/data_m', + 'Top/addr_m', + 'Top/flag_m', + ])); + expect(results.length, 3); + }); + + // ── Empty / no match ── + + test('empty pattern returns nothing', () { + expect(hierarchy.searchSignalPathsRegex(''), isEmpty); + expect(hierarchy.searchOccurrencePathsRegex(''), isEmpty); + }); + + test('non-matching pattern returns nothing', () { + expect(hierarchy.searchSignalPathsRegex('Top/NonExistent/foo'), isEmpty); + }); + + // ── ** at various positions ── + + test('trailing ** collects all signals below', () { + final results = hierarchy.searchSignalPathsRegex('Top/Memory/**'); + // Should collect all signals in Memory subtree + expect( + results, + containsAll([ + 'Top/Memory/Cache/clk', + 'Top/Memory/Cache/addr', + 'Top/Memory/Cache/data', + 'Top/Memory/Cache/hit', + 'Top/Memory/DRAM/clk', + 'Top/Memory/DRAM/cas', + 'Top/Memory/DRAM/ras', + ])); + expect(results.length, 7); + }); + + test('multiple ** segments work', () { + final results = + hierarchy.searchSignalPathsRegex('**/(CPU|Memory)/**/clk'); + expect( + results, + containsAll([ + 'Top/CPU/RegFile/clk', + 'Top/Memory/Cache/clk', + 'Top/Memory/DRAM/clk', + ])); + }); + }); + + group('searchOccurrences dispatches to regex', () { + late HierarchyService hierarchy; + + setUpAll(() { + // Build hierarchy: + // Top + // CPU + // ALU + // Decoder + // MuxUnit + // Memory + // Cache + // DRAM + // IO + // UART + + final alu = HierarchyOccurrence( + name: 'ALU', + ); + + final decoder = HierarchyOccurrence( + name: 'Decoder', + ); + + final muxUnit = HierarchyOccurrence( + name: 'MuxUnit', + ); + + final cpu = HierarchyOccurrence( + name: 'CPU', + children: [alu, decoder, muxUnit], + ); + + final cache = HierarchyOccurrence( + name: 'Cache', + ); + + final dram = HierarchyOccurrence( + name: 'DRAM', + ); + + final memory = HierarchyOccurrence( + name: 'Memory', + children: [cache, dram], + ); + + final uart = HierarchyOccurrence( + name: 'UART', + ); + + final io = HierarchyOccurrence( + name: 'IO', + children: [uart], + ); + + final root = HierarchyOccurrence( + name: 'Top', + children: [cpu, memory, io], + ); + + hierarchy = BaseHierarchyAdapter.fromTree(root); + }); + + test('searchOccurrences with glob pattern finds modules', () { + // Pattern: *Mux* should find MuxUnit (auto-prepended with **/) + final results = hierarchy.searchOccurrences('*Mux*'); + expect(results, isNotEmpty, + reason: + 'searchOccurrences should dispatch to regex for glob patterns'); + expect(results.any((r) => r.name == 'MuxUnit'), isTrue); + }); + + test('searchOccurrences with ** finds deep modules', () { + final results = hierarchy.searchOccurrences('**/*Mux*'); + expect(results, isNotEmpty); + expect(results.any((r) => r.name == 'MuxUnit'), isTrue); + }); + + test('searchOccurrences with .* matches at one level', () { + // */.* matches any child one level below root + final results = hierarchy.searchOccurrences('*/.*/.*'); + expect(results.length, greaterThanOrEqualTo(3), + reason: 'Should match ALU, Decoder, MuxUnit, Cache, DRAM, UART'); + }); + + test('searchOccurrences with explicit path pattern', () { + // */CPU/.* matches children of CPU + final results = hierarchy.searchOccurrences('*/CPU/.*'); + expect(results.length, 3); + expect(results.any((r) => r.name == 'ALU'), isTrue); + expect(results.any((r) => r.name == 'Decoder'), isTrue); + expect(results.any((r) => r.name == 'MuxUnit'), isTrue); + }); + + test('searchOccurrences with alternation', () { + final results = hierarchy.searchOccurrences('**/(ALU|DRAM)'); + expect(results.length, 2); + expect(results.any((r) => r.name == 'ALU'), isTrue); + expect(results.any((r) => r.name == 'DRAM'), isTrue); + }); + + test('searchOccurrences without regex uses plain matching', () { + // Plain query without glob chars uses substring matching + final results = hierarchy.searchOccurrences('Mux'); + expect(results, isNotEmpty); + expect(results.any((r) => r.name == 'MuxUnit'), isTrue); + }); + + test('searchOccurrences with leading **/ is not double-prepended', () { + final results = hierarchy.searchOccurrences('**/UART'); + expect(results.length, 1); + expect(results.first.name, 'UART'); + }); + + test('searchOccurrences with leading */ is not double-prepended', () { + final results = hierarchy.searchOccurrences('*/CPU'); + expect(results.length, 1); + expect(results.first.name, 'CPU'); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/rohd_signal_resolve_test.dart b/packages/rohd_hierarchy/test/rohd_signal_resolve_test.dart new file mode 100644 index 000000000..fa22a21b4 --- /dev/null +++ b/packages/rohd_hierarchy/test/rohd_signal_resolve_test.dart @@ -0,0 +1,72 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// rohd_signal_resolve_test.dart +// Tests for resolving ROHD dot-separated signal IDs. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + late HierarchyOccurrence root; + late BaseHierarchyAdapter adapter; + + setUpAll(() { + root = HierarchyOccurrence( + name: 'abcd', + signals: [ + SignalOccurrence(name: 'clk', width: 1, direction: 'input'), + SignalOccurrence(name: 'resetn', width: 1, direction: 'input'), + SignalOccurrence(name: 'arvalid_s', width: 1, direction: 'input'), + ], + children: [ + HierarchyOccurrence( + name: 'sub', + signals: [ + SignalOccurrence(name: 'data', width: 8, direction: 'output'), + ], + ), + ], + ); + + adapter = BaseHierarchyAdapter.fromTree(root); + root.buildAddresses(); + }); + + SignalOccurrence? resolve(String dotPath) { + final addr = OccurrenceAddress.tryFromPathname(dotPath, root); + if (addr == null) { + return null; + } + return adapter.signalByAddress(addr); + } + + group('findSignalById resolves ROHD dot-separated signal IDs', () { + test('resolves top-level clk', () { + final sig = resolve('abcd.clk'); + expect(sig, isNotNull); + expect(sig!.path(), 'abcd/clk'); + }); + + test('resolves top-level resetn', () { + final sig = resolve('abcd.resetn'); + expect(sig, isNotNull); + expect(sig!.path(), 'abcd/resetn'); + }); + + test('resolves top-level arvalid_s', () { + final sig = resolve('abcd.arvalid_s'); + expect(sig, isNotNull); + expect(sig!.path(), 'abcd/arvalid_s'); + }); + + test('resolves nested sub.data', () { + final sig = resolve('abcd.sub.data'); + expect(sig, isNotNull); + expect(sig!.path(), 'abcd/sub/data'); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/signal_search_result_test.dart b/packages/rohd_hierarchy/test/signal_search_result_test.dart new file mode 100644 index 000000000..2669c859f --- /dev/null +++ b/packages/rohd_hierarchy/test/signal_search_result_test.dart @@ -0,0 +1,236 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_search_result_test.dart +// Tests for SignalSearchResult and ModuleSearchResult display helpers. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + group('SignalSearchResult display helpers', () { + test('displayPath strips top module', () { + const result = SignalSearchResult( + signalId: 'Top/counter/clk', + path: ['Top', 'counter', 'clk'], + ); + expect(result.displayPath, equals('counter/clk')); + }); + + test('displayPath for top-level signal', () { + const result = SignalSearchResult( + signalId: 'Top/clk', + path: ['Top', 'clk'], + ); + expect(result.displayPath, equals('clk')); + }); + + test('displayPath for single-segment path', () { + const result = SignalSearchResult( + signalId: 'clk', + path: ['clk'], + ); + expect(result.displayPath, equals('clk')); + }); + + test('displaySegments strips top module', () { + const result = SignalSearchResult( + signalId: 'Top/sub1/sub2/clk', + path: ['Top', 'sub1', 'sub2', 'clk'], + ); + expect(result.displaySegments, equals(['sub1', 'sub2', 'clk'])); + }); + + test('intermediateOccurrenceNames extracts middle segments', () { + const result = SignalSearchResult( + signalId: 'Top/sub1/sub2/clk', + path: ['Top', 'sub1', 'sub2', 'clk'], + ); + expect(result.intermediateOccurrenceNames, equals(['sub1', 'sub2'])); + }); + + test('intermediateOccurrenceNames empty for top-level signal', () { + const result = SignalSearchResult( + signalId: 'Top/clk', + path: ['Top', 'clk'], + ); + expect(result.intermediateOccurrenceNames, isEmpty); + }); + + test('intermediateOccurrenceNames empty for single-level nesting', () { + const result = SignalSearchResult( + signalId: 'Top/sub1/clk', + path: ['Top', 'sub1', 'clk'], + ); + // sub1 is both the containing block and an intermediate instance + expect(result.intermediateOccurrenceNames, equals(['sub1'])); + }); + + test('name returns last path segment', () { + const result = SignalSearchResult( + signalId: 'Top/counter/clk', + path: ['Top', 'counter', 'clk'], + ); + expect(result.name, equals('clk')); + }); + + test('equality based on signalId', () { + const a = SignalSearchResult( + signalId: 'Top/clk', + path: ['Top', 'clk'], + ); + const b = SignalSearchResult( + signalId: 'Top/clk', + path: ['Top', 'clk'], + ); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + }); + + group('HierarchySearchResult.normalizeQuery', () { + test('converts dots to slashes', () { + expect( + HierarchySearchResult.normalizeQuery('top.cpu.clk'), + equals('top/cpu/clk'), + ); + }); + + test('preserves slashes', () { + expect( + HierarchySearchResult.normalizeQuery('top/cpu/clk'), + equals('top/cpu/clk'), + ); + }); + + test('handles mixed separators', () { + expect( + HierarchySearchResult.normalizeQuery('top.cpu/clk'), + equals('top/cpu/clk'), + ); + }); + + test('handles empty query', () { + expect(HierarchySearchResult.normalizeQuery(''), equals('')); + }); + }); + + group('ModuleSearchResult display helpers', () { + late HierarchyOccurrence aluNode; + + setUp(() { + aluNode = HierarchyOccurrence( + name: 'ALU', + ); + }); + + test('displayPath strips top module', () { + final result = OccurrenceSearchResult( + occurrenceId: 'Top/CPU/ALU', + path: const ['Top', 'CPU', 'ALU'], + occurrence: aluNode, + ); + expect(result.displayPath, equals('CPU/ALU')); + }); + + test('displaySegments strips top module', () { + final result = OccurrenceSearchResult( + occurrenceId: 'Top/CPU/ALU', + path: const ['Top', 'CPU', 'ALU'], + occurrence: aluNode, + ); + expect(result.displaySegments, equals(['CPU', 'ALU'])); + }); + + test('displayPath for single-segment path', () { + final topNode = HierarchyOccurrence( + name: 'Top', + ); + final result = OccurrenceSearchResult( + occurrenceId: 'Top', + path: const ['Top'], + occurrence: topNode, + ); + expect(result.displayPath, equals('Top')); + }); + + test('equality based on moduleId', () { + final a = OccurrenceSearchResult( + occurrenceId: 'Top/CPU/ALU', + path: const ['Top', 'CPU', 'ALU'], + occurrence: aluNode, + ); + final b = OccurrenceSearchResult( + occurrenceId: 'Top/CPU/ALU', + path: const ['Top', 'CPU', 'ALU'], + occurrence: aluNode, + ); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + }); + + group('ModuleSearchResult.normalizeQuery', () { + test('converts dots to slashes', () { + expect( + HierarchySearchResult.normalizeQuery('top.cpu'), + equals('top/cpu'), + ); + }); + }); + + group('searchSignals integration with display helpers', () { + late HierarchyService hierarchy; + + setUpAll(() { + // Build: Top -> counter (with clk, data[8] signals) + final counter = HierarchyOccurrence( + name: 'counter', + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + ), + SignalOccurrence( + name: 'data', + width: 8, + ), + ], + ); + + final root = HierarchyOccurrence( + name: 'Top', + children: [counter], + signals: [ + SignalOccurrence( + name: 'reset', + width: 1, + direction: 'input', + ), + ], + ); + + hierarchy = BaseHierarchyAdapter.fromTree(root); + }); + + test('searchSignals returns enriched results', () { + final results = hierarchy.searchSignals('clk'); + expect(results, isNotEmpty); + final result = results.first; + expect(result.signalId, contains('clk')); + expect(result.displayPath, equals('counter/clk')); + expect(result.intermediateOccurrenceNames, equals(['counter'])); + }); + + test('searchSignals for top-level port', () { + final results = hierarchy.searchSignals('reset'); + expect(results, isNotEmpty); + final result = results.first; + expect(result.displayPath, equals('reset')); + expect(result.intermediateOccurrenceNames, isEmpty); + }); + }); +} diff --git a/packages/rohd_waveform/.gitignore b/packages/rohd_waveform/.gitignore new file mode 100644 index 000000000..3cceda557 --- /dev/null +++ b/packages/rohd_waveform/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/packages/rohd_waveform/README.md b/packages/rohd_waveform/README.md new file mode 100644 index 000000000..842f6ffb6 --- /dev/null +++ b/packages/rohd_waveform/README.md @@ -0,0 +1,29 @@ +# rohd_waveform + +Pure-Dart waveform data models and APIs for ROHD wave viewers. + +This package provides waveform-specific data models that build on top of +[`rohd_hierarchy`](../rohd_hierarchy): + +- `ModuleStructure` — top-level waveform structure (metadata + hierarchy roots). +- `SignalWaveform` — time-series waveform data with a backpointer to signal + metadata in `rohd_hierarchy`. +- `WaveformData` — transfer object for incremental waveform updates. +- `Data`, `WaveFormat`, and `MetaData` — waveform data primitives. + +For hierarchy types such as `HierarchyOccurrence` and `SignalOccurrence`, +import `package:rohd_hierarchy/rohd_hierarchy.dart` directly. + +## Status + +This is a small, dependency-light starting package. It depends only on +`equatable` and `rohd_hierarchy`, and does **not** depend on any waveform +backend (such as a Wellen/FFI reader). Backend integrations live in separate +packages that depend on `rohd_waveform`. + +## Testing + +```sh +dart pub get +dart test +``` diff --git a/packages/rohd_waveform/analysis_options.yaml b/packages/rohd_waveform/analysis_options.yaml new file mode 100644 index 000000000..f04c6cf0f --- /dev/null +++ b/packages/rohd_waveform/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/rohd_waveform/lib/rohd_waveform.dart b/packages/rohd_waveform/lib/rohd_waveform.dart new file mode 100644 index 000000000..4ef7c33a9 --- /dev/null +++ b/packages/rohd_waveform/lib/rohd_waveform.dart @@ -0,0 +1,23 @@ +// Copyright (C) 2024 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// rohd_waveform.dart +// Waveform data models and APIs for wave viewers. +// +// 2024 +// Author: Yao Jing Quek + +/// Waveform data models and APIs for wave viewers. +/// +/// This library provides waveform-specific data models: +/// - `ModuleStructure` - top-level waveform structure +/// - `SignalWaveform` - waveform data with backpointer to signal metadata +/// - `Data`, `WaveFormat`, and `MetaData` - waveform data primitives +/// +/// For hierarchy types such as `HierarchyOccurrence` and `SignalOccurrence`, +/// import 'package:rohd_hierarchy/rohd_hierarchy.dart' directly. +library; + +export 'src/models/models.dart'; +export 'src/waveform_api.dart'; +export 'src/waveform_repository.dart'; diff --git a/packages/rohd_waveform/lib/src/models/data.dart b/packages/rohd_waveform/lib/src/models/data.dart new file mode 100644 index 000000000..2476746e7 --- /dev/null +++ b/packages/rohd_waveform/lib/src/models/data.dart @@ -0,0 +1,34 @@ +// Copyright (C) 2024 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// data.dart +// An entity that describes the data of a signal. +// +// 2024 January 29 +// Author: Yao Jing Quek + +/// A class that represents the data of a signal. +/// +/// It contains a time and a value. +class Data { + /// The time of the data. + int time; + + /// The value of the data. + String value; + + /// Creates a new instance of [Data]. + /// + /// Requires [time] and [value] as parameters. + Data({required this.time, required this.value}); + + /// Converts the [Data] instance into a JSON Map. + Map toJson() => {'time': time, 'value': value}; + + /// Creates a new instance of [Data] from a JSON Map. + factory Data.fromJson(Map json) => + Data(time: json['time'] as int, value: json['value'] as String); + + /// Creates an empty data point at time zero with value `0`. + factory Data.empty() => Data(time: 0, value: '0'); +} diff --git a/packages/rohd_waveform/lib/src/models/metadata.dart b/packages/rohd_waveform/lib/src/models/metadata.dart new file mode 100644 index 000000000..58bb0783b --- /dev/null +++ b/packages/rohd_waveform/lib/src/models/metadata.dart @@ -0,0 +1,107 @@ +// Copyright (C) 2024 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// metadata.dart +// An entity that describes the metadata of a module structure. +// +// 2024 January 29 +// Author: Yao Jing Quek + +import 'package:equatable/equatable.dart'; +import 'package:rohd_waveform/src/models/wave_format.dart'; + +/// A class that represents the metadata of a module structure. +/// +/// It contains source, timescale, date, and time range information. +class MetaData extends Equatable { + /// The source of the metadata. + final String source; + + /// The timescale of the metadata (e.g., "1ns", "100ps"). + final String timescale; + + /// The timescale factor (e.g., 1, 10, 100). + /// + /// This is optional and populated when loading from waveform files. + final int? timescaleFactor; + + /// The date of the metadata. + final String date; + + /// The version string (if available). + /// + /// This is optional and populated when loading from waveform files. + final String? version; + + /// The file format. + /// + /// This is optional and populated when loading from waveform files. + final WaveFormat? format; + + /// The start time of the waveform in timescale units. + final int startTime; + + /// The end time of the waveform in timescale units. + final int endTime; + + /// Creates a new instance of [MetaData]. + /// + /// Requires [source], [timescale], and [date] as parameters. + /// [startTime] and [endTime] default to 0 if not provided. + /// [timescaleFactor], [version], and [format] are optional. + const MetaData({ + required this.source, + required this.timescale, + required this.date, + this.startTime = 0, + this.endTime = 0, + this.timescaleFactor, + this.version, + this.format, + }); + + /// Converts the [MetaData] instance into a JSON Map. + Map toJson() => { + 'source': source, + 'timescale': timescale, + 'date': date, + 'startTime': startTime, + 'endTime': endTime, + if (timescaleFactor != null) 'timescaleFactor': timescaleFactor, + if (version != null) 'version': version, + if (format != null) 'format': format!.name, + }; + + /// Creates a new instance of [MetaData] from a JSON Map. + factory MetaData.fromJson(Map json) => MetaData( + source: json['source'] as String, + timescale: json['timescale'] as String, + date: json['date'] as String, + startTime: (json['startTime'] ?? 0) as int, + endTime: (json['endTime'] ?? 0) as int, + timescaleFactor: json['timescaleFactor'] as int?, + version: json['version'] as String?, + format: json['format'] != null + ? WaveFormat.fromString(json['format'] as String) + : null, + ); + + /// Creates an empty metadata object. + factory MetaData.empty() => const MetaData( + source: '', + timescale: '', + date: '', + ); + + @override + List get props => [ + source, + timescale, + timescaleFactor, + date, + version, + format, + startTime, + endTime, + ]; +} diff --git a/packages/rohd_waveform/lib/src/models/models.dart b/packages/rohd_waveform/lib/src/models/models.dart new file mode 100644 index 000000000..443998791 --- /dev/null +++ b/packages/rohd_waveform/lib/src/models/models.dart @@ -0,0 +1,15 @@ +// Copyright (C) 2024 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause + +// Note: For hierarchy types (HierarchyOccurrence, SignalOccurrence, Port), +// import 'package:rohd_hierarchy/rohd_hierarchy.dart' directly. + +// Waveform-specific models +export 'data.dart'; +export 'metadata.dart'; +export 'module_structure.dart'; +export 'signal_data_service.dart'; +export 'signal_waveform.dart'; +export 'wave_format.dart'; +export 'waveform_data.dart'; +export 'waveform_update_event.dart'; diff --git a/packages/rohd_waveform/lib/src/models/module_structure.dart b/packages/rohd_waveform/lib/src/models/module_structure.dart new file mode 100644 index 000000000..d0832088b --- /dev/null +++ b/packages/rohd_waveform/lib/src/models/module_structure.dart @@ -0,0 +1,114 @@ +// Copyright (C) 2024 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// module_structure.dart +// An entity that describe the module structure of signals simulation. +// +// 2024 January 29 +// Author: Yao Jing Quek + +import 'package:equatable/equatable.dart'; +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:rohd_waveform/rohd_waveform.dart'; + +/// A class that represents the structure of a module hierarchy. +/// +/// It contains metadata and a list of root modules +/// (HierarchyOccurrence objects). +/// This unified representation works with all data sources (waveform files, +/// ROHD inspector JSON, Yosys, etc.) through the adapter pattern. +class ModuleStructure extends Equatable { + /// The metadata of the module structure. + final MetaData metadata; + + /// The root modules in the hierarchy tree. + /// Each HierarchyOccurrence contains its children and ports with waveform + /// data. + final List modules; + + /// Optional pre-built [HierarchyService] for this structure. + /// + /// When set (e.g. from an external hierarchy source like DevTools), this + /// service is used directly for search and navigation instead of + /// re-wrapping the raw [modules] nodes. This preserves the original + /// adapter's internal data (flat maps, connectivity, etc.) that may not + /// be present in the [HierarchyOccurrence] objects themselves. + final HierarchyService? hierarchyService; + + /// Creates a new instance of [ModuleStructure]. + /// + /// Requires [metadata] and [modules] as parameters. + const ModuleStructure({ + required this.metadata, + required this.modules, + this.hierarchyService, + }); + + /// Get all signal IDs in the structure (flattened from all signals). + /// + /// Uses [SignalOccurrence.path()] to produce unique identifiers. + /// Includes both ports and internal signals so that the waveform viewer + /// can select any signal in the hierarchy, not just ports. + List get allSignalIds { + final ids = []; + void traverse(HierarchyOccurrence node) { + for (final signal in node.signals) { + ids.add(signal.path()); + } + node.children.forEach(traverse); + } + + modules.forEach(traverse); + return ids; + } + + /// Creates an empty module structure. + factory ModuleStructure.empty() => + ModuleStructure(metadata: MetaData.empty(), modules: const []); + + /// Finds the first module that has signals (directly or in descendants). + /// + /// This is useful for GHW files where standard libraries may be listed + /// as top-level modules but contain no actual signals. This method helps + /// skip empty modules and find the actual design hierarchy. + /// + /// Returns null if no module with signals is found. + HierarchyOccurrence? get firstModuleWithSignals { + if (modules.isEmpty) { + return null; + } + for (final module in modules) { + if (module.signals.isNotEmpty) { + return module; + } + } + return null; + } + + /// Creates a copy of this ModuleStructure with only the first real module + /// with signals. + /// + /// This wraps the found module as the single root in the module list, + /// effectively skipping empty standard library modules. + /// + /// Returns the original structure if no module with signals is found. + ModuleStructure withFirstRealModule() { + final realModule = firstModuleWithSignals; + if (realModule == null) { + return this; + } + // If the first module is already the real one, return as-is + if (modules.first == realModule) { + return this; + } + // Otherwise, wrap the real module as the single root + return ModuleStructure( + metadata: metadata, + modules: [realModule], + hierarchyService: hierarchyService, + ); + } + + @override + List get props => [metadata, modules, hierarchyService]; +} diff --git a/packages/rohd_waveform/lib/src/models/signal_data_service.dart b/packages/rohd_waveform/lib/src/models/signal_data_service.dart new file mode 100644 index 000000000..898d52b2b --- /dev/null +++ b/packages/rohd_waveform/lib/src/models/signal_data_service.dart @@ -0,0 +1,102 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_data_service.dart +// Abstraction layer for fetching signal waveform data. +// +// This service decouples the wave display layer from the data source +// (repository, debugger, simulator, etc.) while respecting shared models. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:rohd_waveform/rohd_waveform.dart'; + +/// Abstraction for fetching signal waveform data by signal occurrence. +/// +/// Signals come from the shared module hierarchy and are never modified. +/// This service fetches waveform data for a given signal without knowing the +/// data source (VCD file, debugger, simulator, etc.). +/// +/// Usage: +/// ```dart +/// final service = RepositorySignalDataService(repository); +/// final port = hierarchy.modules[0].signals[0]; +/// final waveData = await service.getSignalData(port); +/// print('SignalOccurrence: ${waveData.signalName}, Points: +/// ${waveData.data.length}'); +/// ``` +abstract class SignalDataService { + /// Fetch waveform data for a signal occurrence. + /// + /// [port] is the signal definition from the loaded module structure. + /// Returns `WaveData` combining that shared model with its waveform data. + /// + /// This method can be implemented differently in each app: + /// - Wave Viewer: Fetch from cached VCD waveform data + /// - Debugger: Fetch from live debugger state + /// - Simulator: Fetch from running simulation + Future getSignalData(SignalOccurrence port); + + /// Get all signals for a module. + /// + /// [module] is a HierarchyOccurrence from ModuleStructure + /// Returns the module's signals. + /// This method allows for caching/optimization per app. + /// + /// Default implementation returns module.signals directly. + List getSignalsForModule(HierarchyOccurrence module) => + module.signals; + + /// Get all ports (signals with direction) for a module. + /// + /// [module] is a HierarchyOccurrence from ModuleStructure + /// Returns only ports from the module's signals. + /// + /// Default implementation filters signals by isPort. + List getPortsForModule(HierarchyOccurrence module) => + module.signals.where((s) => s.isPort).toList(); +} + +/// Combined data model: signal definition + waveform data. +/// +/// This is app-specific (not shared across apps). Each app wraps +/// the shared signal occurrence with its own `WaveData` representation. +/// +/// The signal occurrence is immutable, while data points are fetched +/// from the app's data source. +class WaveData { + /// The signal definition from the shared module structure. + /// This SignalOccurrence is never modified by the service. + final SignalOccurrence port; + + /// The waveform data points [time, value] pairs. + /// Data type and structure depends on the source (VCD, debugger, etc.). + final List data; + + /// Optional metadata specific to this waveform. + /// Can include timing info, source hints, flags, etc. + final Map? metadata; + + /// Creates a combined signal definition and waveform payload. + WaveData({required this.port, required this.data, this.metadata}); + + /// Get signal name from the shared signal model. + String get signalName => port.name; + + /// Get signal direction from the shared signal model. + String get signalDirection => port.direction ?? 'inout'; + + /// Get signal width from the shared signal model. + int get signalWidth => port.width; + + /// Get signal type for VCD rendering. Defaults to 'wire'. + String get signalType => 'wire'; + + /// Check if waveform has any data points. + bool get hasData => data.isNotEmpty; + + /// Get number of data points. + int get dataPointCount => data.length; +} diff --git a/packages/rohd_waveform/lib/src/models/signal_waveform.dart b/packages/rohd_waveform/lib/src/models/signal_waveform.dart new file mode 100644 index 000000000..a237f348f --- /dev/null +++ b/packages/rohd_waveform/lib/src/models/signal_waveform.dart @@ -0,0 +1,325 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_waveform.dart +// Waveform data for a signal, indexed by signal ID. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; + +import 'package:rohd_waveform/src/models/data.dart'; +import 'package:rohd_waveform/src/models/waveform_data.dart'; + +/// Function type for looking up signal metadata by ID. +typedef SignalLookup = SignalOccurrence? Function(String signalId); + +/// Waveform data for a signal. +/// +/// This class holds the time-series waveform data for a signal and provides +/// efficient lookup methods. The signal's structural metadata (name, type, +/// width, etc.) is stored in `rohd_hierarchy.SignalOccurrence` and can be +/// accessed via the `signal` property if a lookup function is registered. +/// +/// Pattern: Similar to SchematicPortData in vscode-schematic-viewer, this +/// class maintains waveform data with a backpointer (`signalId`) to the +/// corresponding metadata in the hierarchy. +/// +/// Usage: +/// ```dart +/// // Register the signal lookup function (typically done once by repository) +/// SignalWaveform.signalLookup = (id) => repository.getSignalById(id); +/// +/// // Now all SignalWaveform instances can access their metadata +/// final waveform = SignalWaveform(signalId: 'clk'); +/// print(waveform.name); // Uses lookup to get SignalOccurrence.name +/// ``` +class SignalWaveform { + /// The ID of the signal this waveform data belongs to. + /// Use this to look up SignalOccurrence metadata from the hierarchy. + final String signalId; + + /// The waveform data points (time, value pairs). + final List data; + + /// Static signal lookup function for resolving signal metadata. + /// Set this via [signalLookup] before accessing metadata properties. + static SignalLookup? signalLookup; + + /// Clears the signal lookup function. + static void clearSignalLookup() { + signalLookup = null; + } + + /// Whether this waveform was computed/synthesized (e.g. gate evaluation) + /// rather than directly fetched from the VM service. + bool isComputed; + + /// Override width for computed sub-field waveforms whose signal metadata + /// is not available via the hierarchy lookup. + int? overrideWidth; + + /// Override display name for computed sub-field waveforms. + String? overrideName; + + /// Creates a new SignalWaveform. + SignalWaveform({ + required this.signalId, + List? data, + this.isComputed = false, + this.overrideWidth, + this.overrideName, + }) : data = data ?? []; + + /// Creates an empty SignalWaveform for a signal. + factory SignalWaveform.empty(String signalId) => + SignalWaveform(signalId: signalId); + + /// Creates a SignalWaveform from WaveformData. + factory SignalWaveform.fromWaveformData(WaveformData waveformData) => + SignalWaveform( + signalId: waveformData.signalId, + data: List.from(waveformData.data), + isComputed: waveformData.isComputed, + ); + + /// Creates a copy of an existing SignalWaveform. + /// + /// This is needed when the same signal is added to the monitor list multiple + /// times (e.g., for performance studies or side-by-side comparison). Each + /// monitor entry must have its own SignalWaveform instance to avoid sharing + /// state between rows in the waveform panel. + factory SignalWaveform.copyFrom(SignalWaveform other) => SignalWaveform( + signalId: other.signalId, + data: List.from(other.data), + isComputed: other.isComputed, + overrideWidth: other.overrideWidth, + overrideName: other.overrideName, + ); + + // ───────────────────────────────────────────────────────────────────────── + // Metadata accessors (via backpointer lookup) + // ───────────────────────────────────────────────────────────────────────── + + /// The corresponding SignalOccurrence metadata, if available. + /// Returns null if no lookup function is registered or signal not found. + SignalOccurrence? get signal => signalLookup?.call(signalId); + + /// Alias for signalId for convenience. + String get id => signalId; + + /// The canonical hierarchical path for waveform lookup. Delegates to + /// [SignalOccurrence.path] when available, falls back to [signalId]. + String get hierarchyPath => signal?.path() ?? signalId; + + /// The signal name (from metadata). Returns overrideName or signalId if + /// lookup fails. + String get name => signal?.name ?? overrideName ?? signalId; + + /// The signal type for VCD rendering. Always 'wire' in post-synthesis. + String get type => 'wire'; + + /// The signal width in bits (from metadata). Returns overrideWidth or 1 if + /// lookup fails. + int get width => signal?.width ?? overrideWidth ?? 1; + + /// The signal direction (from metadata). Returns null for internal signals. + String? get direction => signal?.direction; + + /// The full hierarchical path (from metadata). + String? get fullPath => signal?.path(); + + /// Whether this signal is a port (has direction). + bool get isPort => signal?.isPort ?? false; + + // ───────────────────────────────────────────────────────────────────────── + // Waveform data properties + // ───────────────────────────────────────────────────────────────────────── + + /// Whether this waveform has any data points. + bool get isEmpty => data.isEmpty; + + /// Whether this waveform has data points. + bool get isNotEmpty => data.isNotEmpty; + + /// The number of data points in this waveform. + int get length => data.length; + + /// Appends waveform data points. + /// + /// If [sortByTime] is true, the data will be sorted by time after appending. + void appendData(List newData, {bool sortByTime = false}) { + data.addAll(newData); + if (sortByTime) { + data.sort((a, b) => a.time.compareTo(b.time)); + if (data.length > 1) { + // Keep the last value for duplicate timestamps to reflect most recent + // updates when overlapping windows are appended. + final deduped = []; + for (final point in data) { + if (deduped.isNotEmpty && deduped.last.time == point.time) { + deduped[deduped.length - 1] = point; + } else { + deduped.add(point); + } + } + data + ..clear() + ..addAll(deduped); + } + } + } + + /// Appends waveform data from a [WaveformData] object. + void appendWaveformData( + WaveformData waveformData, { + bool sortByTime = false, + }) { + appendData(waveformData.data, sortByTime: sortByTime); + if (waveformData.isComputed) { + isComputed = true; + } + } + + /// Clears all waveform data. + void clearData() { + data.clear(); + } + + /// Gets the value at a specific time using binary search. + /// + /// Returns the value of the last data point at or before the given time. + /// If no data point exists at or before the time, returns the first value. + String getValueByTime(int time) { + if (data.isEmpty) { + return ''; + } + + var low = 0; + var high = data.length - 1; + var res = -1; + + while (low <= high) { + final mid = (low + high) >> 1; + if (data[mid].time <= time) { + res = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + + if (res != -1) { + return data[res].value; + } else { + return data.first.value; + } + } + + /// Finds the index of the data point at or after the given time. + /// Returns -1 if no data point is at or after the given time. + /// O(log n) complexity. + int getNextDataPointIndexAfter(int time) { + if (data.isEmpty) { + return -1; + } + + var low = 0; + var high = data.length - 1; + var resultIndex = -1; + + while (low <= high) { + final mid = low + (high - low) ~/ 2; + final midData = data[mid]; + + if (midData.time >= time) { + resultIndex = mid; + high = mid - 1; + } else { + low = mid + 1; + } + } + + return resultIndex; + } + + /// Finds the index of the data point at or before the given time. + /// Returns -1 if no data point is at or before the given time. + /// O(log n) complexity. + int getPreviousDataPointIndexBefore(int time) { + if (data.isEmpty) { + return -1; + } + + var low = 0; + var high = data.length - 1; + var resultIndex = -1; + + while (low <= high) { + final mid = low + (high - low) ~/ 2; + final midData = data[mid]; + + if (midData.time <= time) { + resultIndex = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + + return resultIndex; + } + + /// Gets the next data point index from the current time. + /// If there is a data point at exactly the current time, returns the index + /// of the first data point at a strictly later time. + /// Handles duplicate timestamps correctly. + /// Returns -1 if there is no next data point. + int getNextDataPointIndex(int currentTime) { + var idx = getNextDataPointIndexAfter(currentTime); + if (idx == -1) { + return -1; + } + // Skip past all entries at currentTime to find a strictly later one. + while (idx < data.length && data[idx].time == currentTime) { + idx++; + } + return idx < data.length ? idx : -1; + } + + /// Gets the previous data point index from the current time. + /// If there is a data point at exactly the current time, returns the index + /// of the last data point at a strictly earlier time. + /// Handles duplicate timestamps correctly. + /// Returns -1 if there is no previous data point. + int getPreviousDataPointIndex(int currentTime) { + var idx = getPreviousDataPointIndexBefore(currentTime); + if (idx == -1) { + return -1; + } + // Skip back past all entries at currentTime to find a strictly earlier one. + while (idx >= 0 && data[idx].time == currentTime) { + idx--; + } + return idx >= 0 ? idx : -1; + } + + @override + String toString() => 'SignalWaveform($signalId, ${data.length} points)'; + + /// Converts to JSON. + Map toJson() => { + 'signalId': signalId, + 'data': data.map((e) => e.toJson()).toList(), + }; + + /// Creates from JSON. + factory SignalWaveform.fromJson(Map json) => SignalWaveform( + signalId: json['signalId'] as String, + data: (json['data'] as List?) + ?.map((e) => Data.fromJson(e as Map)) + .toList() ?? + [], + ); +} diff --git a/packages/rohd_waveform/lib/src/models/wave_format.dart b/packages/rohd_waveform/lib/src/models/wave_format.dart new file mode 100644 index 000000000..3146c7e74 --- /dev/null +++ b/packages/rohd_waveform/lib/src/models/wave_format.dart @@ -0,0 +1,100 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// wave_format.dart +// Waveform file format enumeration. +// +// 2026 January 03 +// Author: YDesmond Kirkpatrick + +/// Supported waveform file formats. +enum WaveFormat { + /// Value Change Dump format (IEEE 1364). + /// + /// VCD is the most widely supported format, but can produce very large + /// files for complex simulations. + vcd, + + /// Fast Signal Trace format (GTKWave). + /// + /// FST is a compressed binary format that is much more efficient than VCD. + /// It supports random access and is the recommended format for large + /// simulations. + fst, + + /// GHDL Waveform format. + /// + /// GHW is the native format for GHDL simulations. Reading is supported, + /// but writing is not. + ghw, + + /// Unknown or unsupported format. + unknown; + + /// Returns the file extension for this format. + String get extension { + switch (this) { + case WaveFormat.vcd: + return '.vcd'; + case WaveFormat.fst: + return '.fst'; + case WaveFormat.ghw: + return '.ghw'; + case WaveFormat.unknown: + return ''; + } + } + + /// Parses a format from a file path or extension. + static WaveFormat fromPath(String path) { + final lower = path.toLowerCase(); + if (lower.endsWith('.vcd')) { + return WaveFormat.vcd; + } + if (lower.endsWith('.fst')) { + return WaveFormat.fst; + } + if (lower.endsWith('.ghw')) { + return WaveFormat.ghw; + } + return WaveFormat.unknown; + } + + /// Parses a format from a string name. + static WaveFormat fromString(String name) { + switch (name.toLowerCase()) { + case 'vcd': + return WaveFormat.vcd; + case 'fst': + return WaveFormat.fst; + case 'ghw': + return WaveFormat.ghw; + default: + return WaveFormat.unknown; + } + } + + /// Whether this format supports writing. + bool get supportsWriting { + switch (this) { + case WaveFormat.vcd: + case WaveFormat.fst: + return true; + case WaveFormat.ghw: + case WaveFormat.unknown: + return false; + } + } + + /// Whether this format supports reading. + bool get supportsReading { + switch (this) { + case WaveFormat.vcd: + case WaveFormat.fst: + case WaveFormat.ghw: + return true; + case WaveFormat.unknown: + return false; + } + } +} diff --git a/packages/rohd_waveform/lib/src/models/waveform_data.dart b/packages/rohd_waveform/lib/src/models/waveform_data.dart new file mode 100644 index 000000000..41ff3b565 --- /dev/null +++ b/packages/rohd_waveform/lib/src/models/waveform_data.dart @@ -0,0 +1,71 @@ +// Copyright (C) 2024 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// waveform_data.dart +// An entity that represents waveform data for a signal. +// +// 2024 December 30 +// Author: Yao Jing Quek + +import 'package:rohd_waveform/src/models/data.dart'; + +/// A class that represents waveform data for a specific signal. +/// +/// This class is used to transfer waveform data separately from the +/// signal structure, enabling incremental data loading and updates. +class WaveformData { + /// The unique identifier of the signal this waveform data belongs to. + final String signalId; + + /// The list of data points in the waveform. + final List data; + + /// Whether this waveform was computed/synthesized (e.g. gate evaluation) + /// rather than directly fetched from the VM service. + final bool isComputed; + + /// Creates a new instance of [WaveformData]. + /// + /// Requires [signalId] and [data] as parameters. + WaveformData({ + required this.signalId, + required this.data, + this.isComputed = false, + }); + + /// Converts the [WaveformData] instance into a JSON Map. + Map toJson() => { + 'signalId': signalId, + 'data': data.map((e) => e.toJson()).toList(), + }; + + /// Creates a new instance of [WaveformData] from a JSON Map. + factory WaveformData.fromJson(Map json) => WaveformData( + signalId: json['signalId'] as String, + data: (json['data'] as List) + .map((e) => Data.fromJson(e as Map)) + .toList(), + ); + + /// Creates an empty waveform payload for [signalId]. + factory WaveformData.empty(String signalId) => + WaveformData(signalId: signalId, data: []); + + /// Returns the number of data points in the waveform. + int get length => data.length; + + /// Returns true if the waveform has no data points. + bool get isEmpty => data.isEmpty; + + /// Returns true if the waveform has data points. + bool get isNotEmpty => data.isNotEmpty; + + /// Returns the time of the first data point, or null if empty. + int? get startTime => data.isEmpty ? null : data.first.time; + + /// Returns the time of the last data point, or null if empty. + int? get endTime => data.isEmpty ? null : data.last.time; + + @override + String toString() => 'WaveformData($signalId, ${data.length} points)'; +} diff --git a/packages/rohd_waveform/lib/src/models/waveform_update_event.dart b/packages/rohd_waveform/lib/src/models/waveform_update_event.dart new file mode 100644 index 000000000..81d4eacfd --- /dev/null +++ b/packages/rohd_waveform/lib/src/models/waveform_update_event.dart @@ -0,0 +1,60 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// waveform_update_event.dart +// Event model for incremental waveform updates from live simulations. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'package:rohd_waveform/src/models/waveform_data.dart'; + +/// Reasons for a waveform update event. +enum WaveformUpdateReason { + /// Update triggered by hitting a breakpoint. + breakpoint, + + /// Manual refresh requested by user. + manual, + + /// Periodic update during continuous simulation. + periodic, + + /// Final update when simulation completes. + simulationComplete, + + /// Initial data load. + initial, + + /// Waveform structure (signal dictionary) has become available. + /// + /// Emitted when a debug-pause probe discovers the ROHD WaveformService + /// for the first time. Listeners should re-fetch the module structure + /// so the signal tree appears in the UI. + structureAvailable, +} + +/// A waveform update event containing incremental data. +/// +/// Used to communicate incremental waveform data from live simulations +/// to the waveform viewer UI. +class WaveformUpdateEvent { + /// The new waveform data since the last update. + final List incrementalData; + + /// Reason for this update. + final WaveformUpdateReason reason; + + /// The simulation time up to which data is included. + final int upToTime; + + /// Creates a waveform update event. + WaveformUpdateEvent({ + required this.incrementalData, + required this.reason, + required this.upToTime, + }); + + /// Whether this update contains any new data. + bool get hasData => incrementalData.isNotEmpty; +} diff --git a/packages/rohd_waveform/lib/src/signal_data_service_impl.dart b/packages/rohd_waveform/lib/src/signal_data_service_impl.dart new file mode 100644 index 000000000..89dfe8260 --- /dev/null +++ b/packages/rohd_waveform/lib/src/signal_data_service_impl.dart @@ -0,0 +1,87 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_data_service_impl.dart +// Concrete implementation of SignalDataService using the repository. +// +// This implementation fetches signal waveform data from the cached +// SignalWaveform objects in the SignalWaveformRepository, wrapping them with +// Port (shared model) +// to create WaveData objects. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:rohd_waveform/rohd_waveform.dart'; + +/// Concrete implementation of SignalDataService using +/// SignalWaveformRepository. +/// +/// The repository caches SignalOccurrence metadata and SignalWaveform objects +/// (with waveform data). +/// This implementation: +/// 1. Takes a Port (shared model) as input +/// 2. Uses Port.id to look up the cached SignalWaveform +/// 3. Wraps SignalWaveform.data with the Port to create WaveData +/// +/// This decouples the wave display from the repository by providing a +/// clean service interface that takes shared models as input. +class RepositorySignalDataService implements SignalDataService { + /// Reference to the repository containing cached signal waveform data. + final SignalWaveformRepository _repository; + + /// Creates a new repository-backed signal data service. + /// + /// The repository should already have signal data loaded. + RepositorySignalDataService(this._repository); + + /// Fetch waveform data for a Port using the repository cache. + /// + /// This method: + /// 1. Takes a Port (shared model from ModuleStructure) + /// 2. Uses signal.path() to look up the cached SignalWaveform + /// 3. Wraps SignalWaveform.data with the Port in a WaveData object + /// + /// Returns WaveData with the Port and its cached waveform data. + /// If the waveform is not found in cache, returns WaveData with empty data. + @override + Future getSignalData(SignalOccurrence port) async { + // Look up the waveform in the repository's cache using the address. + final addr = port.address; + final waveform = addr != null ? _repository.getWaveform(addr) : null; + final signal = addr != null ? _repository.getSignal(addr) : null; + + if (waveform == null) { + // Waveform not cached - return empty WaveData + return WaveData( + port: port, // ◄─ Shared model, immutable + data: [], // Empty data + metadata: {'source': 'repository', 'cached': false}, + ); + } + + // Waveform found - wrap its data with the Port (shared model) + return WaveData( + port: port, // ◄─ Shared model, immutable + data: waveform.data, // Waveform data from cached SignalWaveform + metadata: { + 'source': 'repository', + 'cached': true, + 'path': signal?.path(), + }, + ); + } + + /// Get all signals for a module. + @override + List getSignalsForModule(HierarchyOccurrence module) => + module.signals; + + /// Get all ports for a module. + /// + /// This implementation returns the module's ports (signals with direction). + @override + List getPortsForModule(HierarchyOccurrence module) => + module.signals.where((s) => s.isPort).toList(); +} diff --git a/packages/rohd_waveform/lib/src/waveform_api.dart b/packages/rohd_waveform/lib/src/waveform_api.dart new file mode 100644 index 000000000..54260bfea --- /dev/null +++ b/packages/rohd_waveform/lib/src/waveform_api.dart @@ -0,0 +1,102 @@ +// Copyright (C) 2024 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// rohd_waveform.dart +// An abstract class that defines the API for module structure. +// +// 2024 January 29 +// Author: Yao Jing Quek + +import 'package:rohd_waveform/rohd_waveform.dart'; + +/// An abstract class that defines the API for module structure. +/// An abstract class that defines the API for waveform data retrieval. +/// +/// Module structure (hierarchy, signals) is provided separately through +/// the rohd_hierarchy path. This API handles only waveform *values*. +abstract class SignalWaveformApi { + /// Creates a new instance of [SignalWaveformApi]. + const SignalWaveformApi(); + + /// Whether the underlying waveform source is ready for data access. + /// + /// Implementations that have an asynchronous load phase override this. + /// The default assumes the API is ready immediately. + bool get isLoaded => true; + + /// Retrieves waveform data for specific signals. + /// + /// [signalIds] is a list of signal IDs for which to retrieve data. + /// [startTime] and [endTime] optionally specify a time range for the data. + /// + /// Returns a [Future] that completes with a list of [WaveformData] objects. + Future> getWaveformData({ + required List signalIds, + int? startTime, + int? endTime, + }) async { + // Base implementation: must be overridden by concrete implementations + // Port no longer contains data - implementations must fetch from + // their source. + throw UnimplementedError( + 'getWaveformData must be implemented by subclasses', + ); + } + + /// Streams waveform data incrementally for specific signals. + /// + /// [signalIds] is a list of signal IDs for which to stream data. + /// [startTime] optionally specifies the starting time for the data stream. + /// + /// Returns a [Stream] of [WaveformData] objects that can be used to + /// incrementally update the waveform display. + Stream streamWaveformData({ + required List signalIds, + int? startTime, + }) async* { + // Default implementation: get all data at once and yield + final waveformDataList = await getWaveformData( + signalIds: signalIds, + startTime: startTime, + ); + for (final waveformData in waveformDataList) { + yield waveformData; + } + } + + /// Retrieves the current time of the active ROHD application. + /// + /// This method polls the current simulation time, which is useful for + /// dynamically updating the waveform display end time as a simulation + /// progresses. + /// + /// Returns a [Future] that completes with the current time as an integer, + /// or null if the time cannot be determined. + Future getCurrentTime() async { + // Default implementation: must be overridden by concrete implementations + throw UnimplementedError( + 'getCurrentTime must be implemented by subclasses', + ); + } + + /// Retrieves a snapshot of all signal values at the given [time]. + /// + /// Returns a map of signal ID to a map containing: + /// - `value`: the signal value at that time (String) + /// - `name`: signal display name + /// - `width`: signal bit width + /// - `direction`: signal direction (if port) + /// + /// Returns null if the snapshot could not be retrieved. + Future>?> getSnapshot(int time) async { + throw UnimplementedError('getSnapshot must be implemented by subclasses'); + } + + /// Proactively expand all slim module definitions so the client-side + /// evaluator can compute internal signals immediately. + /// + /// Called when the user enables "internal signals" in the wave viewer. + /// Default implementation is a no-op; overridden by implementations + /// that support client-side synthesis. + Future expandAllSlimModules() async {} +} diff --git a/packages/rohd_waveform/lib/src/waveform_repository.dart b/packages/rohd_waveform/lib/src/waveform_repository.dart new file mode 100644 index 000000000..23d1ed6a3 --- /dev/null +++ b/packages/rohd_waveform/lib/src/waveform_repository.dart @@ -0,0 +1,722 @@ +// Copyright (C) 2024 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// waveform_repository.dart +// Domain layer that manages the retrieval of signal waveforms. +// +// 2024 January 29 +// Author: Yao Jing Quek + +import 'dart:async'; + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:rohd_waveform/rohd_waveform.dart'; + +export 'signal_data_service_impl.dart'; + +/// A class that manages the retrieval of signal waveforms. +/// +/// It uses an instance of [SignalWaveformApi] to retrieve the data. +/// Maintains a cache of [SignalOccurrence] metadata and a separate cache of +/// [SignalWaveform] objects for waveform data. +class SignalWaveformRepository { + /// Currently selected module, if any. + HierarchyOccurrence? selectedModule; + + /// The [SignalWaveformApi] instance used to retrieve the data. + SignalWaveformApi _signalWaveformApi; + + /// Hierarchy service for tree-walk pathname ↔ address conversions. + /// Set via [hierarchyService] after loading a hierarchy. + HierarchyService? hierarchyService; + + /// Optional future that completes when the underlying API is ready. + /// + /// On web, the Wellen WASM may need to finish loading the waveform bytes + /// before calls to the underlying API succeed. Passing a readiness future + /// allows repository methods to wait for that event. Tests and native + /// usage may leave this null. + Future? _apiReady; + + /// A cache of signal metadata keyed by [OccurrenceAddress]. + final Map _signalCache = {}; + + /// A cache of signal waveform data keyed by [OccurrenceAddress]. + final Map _waveformCache = {}; + + /// Fallback cache for computed sub-field waveforms (e.g. bit-slices of + /// arrays/structs) that don't have a real [OccurrenceAddress] in the + /// hierarchy tree. Keyed by the raw signal ID string. + final Map _subFieldWaveformCache = {}; + + /// Expose the API for file loading operations + SignalWaveformApi get api => _signalWaveformApi; + + /// Creates a new instance of [SignalWaveformRepository]. + /// + /// Requires [signalWaveformApi] as a parameter. + SignalWaveformRepository({ + required SignalWaveformApi signalWaveformApi, + Future? apiReady, + }) : _signalWaveformApi = signalWaveformApi, + _apiReady = apiReady { + // Register the signal lookup function so SignalWaveform can resolve + // metadata. + // Uses tree-walk to convert signalId strings to addresses for cache lookup. + SignalWaveform.signalLookup = _lookupSignalByPath; + } + + /// Look up a [SignalOccurrence] by its pathname string. + /// Used as the [SignalWaveform] backpointer lookup function. + SignalOccurrence? _lookupSignalByPath(String signalId) { + final addr = hierarchyService?.pathnameToAddress(signalId); + return addr != null ? _signalCache[addr] : null; + } + + /// Replace the underlying API at runtime (e.g., switch from mock to Wellen + /// when the user picks a file). Clears cached signals and waveform data. + void setSignalWaveformApi( + SignalWaveformApi signalWaveformApi, { + Future? apiReady, + }) { + _signalWaveformApi = signalWaveformApi; + _apiReady = apiReady; + clearSignalCache(); + clearAllWaveformData(); + } + + Future? _waitForApiLoaded({ + Duration timeout = const Duration(seconds: 10), + }) { + final api = _signalWaveformApi; + if (api.isLoaded) { + return Future.value(); + } + + final completer = Completer(); + final deadline = DateTime.now().add(timeout); + + void poll() { + if (api.isLoaded) { + completer.complete(); + return; + } + + if (DateTime.now().isAfter(deadline)) { + completer.complete(); + return; + } + + Future.delayed(const Duration(milliseconds: 50), poll); + } + + poll(); + return completer.future; + } + + /// Internal helper to wait for API readiness if provided. + Future _ensureReady() async { + if (_apiReady != null) { + final loadedFuture = _waitForApiLoaded(); + if (loadedFuture != null) { + await Future.any([_apiReady!, loadedFuture]); + } else { + await _apiReady; + } + } + } + + /// Proactively expand all slim module definitions for client-side + /// evaluation. Delegates to [SignalWaveformApi.expandAllSlimModules]. + Future expandAllSlimModules() => + _signalWaveformApi.expandAllSlimModules(); + + /// Build the signal cache from the given module hierarchy. + /// + /// Ensures occurrence addresses are assigned via `buildAddresses()` and + /// auto-creates a hierarchy service if one hasn't been set explicitly. + /// Call this after receiving the hierarchy from the external + /// tree-data-source path, via `setExternalHierarchy` in the bloc. + void buildSignalCacheFromHierarchy(List modules) { + // Ensure every node and signal has an address. + for (final m in modules) { + if (m.address == null) { + m.buildAddresses(); + } + } + // Auto-create a HierarchyService if the caller hasn't set one. + if (hierarchyService == null && modules.isNotEmpty) { + hierarchyService = BaseHierarchyAdapter.fromTree(modules.first); + } + _buildSignalCache(modules); + } + + /// Get the current simulation time from the waveform API. + Future getCurrentTime() async { + await _ensureReady(); + return _signalWaveformApi.getCurrentTime(); + } + + /// Retrieves waveform data for specific signals. + /// + /// [signalIds] is a list of signal IDs for which to retrieve data. + /// [startTime] and [endTime] optionally specify a time range for the data. + /// + /// Sub-field IDs (containing `#`) are intercepted and synthesized via + /// bit-slicing the parent signal's waveform data. This enables sub-field + /// expansion to work with any API backend (including Wellen/VCD). + /// + /// Returns a [Future] that completes with a list of [WaveformData] objects. + Future> getWaveformData({ + required List signalIds, + int? startTime, + int? endTime, + }) => + () async { + await _ensureReady(); + + // Separate plain IDs from sub-field IDs. + final plainIds = []; + final subFieldIds = []; + for (final id in signalIds) { + if (id.contains(_subFieldSeparator)) { + subFieldIds.add(id); + } else { + plainIds.add(id); + } + } + + // Fetch plain signals from the API. + final results = []; + if (plainIds.isNotEmpty) { + results.addAll(await _signalWaveformApi.getWaveformData( + signalIds: plainIds, + startTime: startTime, + endTime: endTime, + )); + } + + // Synthesize sub-field bit-slices at the repository level. + if (subFieldIds.isNotEmpty) { + for (final sfId in subFieldIds) { + final synthesized = await _synthesizeBitSlice( + sfId, + startTime: startTime, + endTime: endTime, + ); + if (synthesized != null) { + results.add(synthesized); + } + } + } + + return results; + }(); + + /// Loads waveform data for specific signals and appends it to the cached + /// signals. + /// + /// [signalIds] is a list of signal IDs for which to load data. + /// [startTime] and [endTime] optionally specify a time range for the data. + /// [sortByTime] if true, sorts the data by time after appending. + /// + /// Returns a [Future] that completes with the list of [WaveformData] loaded. + Future> loadAndAppendWaveformData({ + required List signalIds, + int? startTime, + int? endTime, + bool sortByTime = true, + }) async { + final waveformDataList = await getWaveformData( + signalIds: signalIds, + startTime: startTime, + endTime: endTime, + ); + for (final waveformData in waveformDataList) { + // Resolve the waveform service signal ID to a OccurrenceAddress + // via O(depth) tree walk (no maps needed). + final addr = _resolveWaveformAddress(waveformData.signalId); + + // A null/null range means "full fetch". Replace cached waveform to + // avoid stale/duplicated points causing misplaced transitions. + final isFullFetch = startTime == null && endTime == null; + + if (addr == null) { + // Sub-field / computed waveform — no real address in the tree. + // Cache by raw string ID. + final id = waveformData.signalId; + if (isFullFetch || !_subFieldWaveformCache.containsKey(id)) { + _subFieldWaveformCache[id] = + SignalWaveform.fromWaveformData(waveformData); + } else { + _subFieldWaveformCache[id]! + .appendWaveformData(waveformData, sortByTime: sortByTime); + } + continue; + } + + if (isFullFetch || !_waveformCache.containsKey(addr)) { + _waveformCache[addr] = SignalWaveform.fromWaveformData(waveformData); + } else { + _waveformCache[addr]! + .appendWaveformData(waveformData, sortByTime: sortByTime); + } + } + return waveformDataList; + } + + /// Resolve a waveform service signal ID string to a [OccurrenceAddress] + /// using an O(depth) tree walk. Returns null when the hierarchy service + /// is not set or the path doesn't exist in the tree. + OccurrenceAddress? _resolveWaveformAddress(String waveformSignalId) => + hierarchyService?.waveformIdToAddress(waveformSignalId); + + /// Streams waveform data incrementally for specific signals. + /// + /// [signalIds] is a list of signal IDs for which to stream data. + /// [startTime] optionally specifies the starting time for the data stream. + /// [appendToSignals] if true, automatically appends streamed data to + /// cached waveforms. + /// + /// Returns a [Stream] of [WaveformData] objects. + Stream streamWaveformData({ + required List signalIds, + int? startTime, + bool appendToSignals = true, + }) async* { + await for (final waveformData in _signalWaveformApi.streamWaveformData( + signalIds: signalIds, + startTime: startTime, + )) { + if (appendToSignals) { + final addr = _resolveWaveformAddress(waveformData.signalId); + if (addr != null) { + final waveform = _waveformCache[addr]; + if (waveform != null) { + waveform.appendWaveformData(waveformData); + } else { + _waveformCache[addr] = SignalWaveform.fromWaveformData( + waveformData, + ); + } + } else { + // Sub-field / computed waveform — cache by string ID. + final id = waveformData.signalId; + final waveform = _subFieldWaveformCache[id]; + if (waveform != null) { + waveform.appendWaveformData(waveformData); + } else { + _subFieldWaveformCache[id] = + SignalWaveform.fromWaveformData(waveformData); + } + } + } + yield waveformData; + } + } + + /// Appends waveform data to a specific signal. + /// + /// [signalId] is the ID of the signal to append data to. + /// [data] is the list of data points to append. + /// [sortByTime] if true, sorts the data by time after appending. + /// + /// Returns true if data was appended (creates waveform if not exists). + bool appendDataToSignal( + String signalId, + List data, { + bool sortByTime = false, + }) { + final addr = _resolveWaveformAddress(signalId); + if (addr == null) { + // Sub-field / computed waveform — cache by string ID. + var waveform = _subFieldWaveformCache[signalId]; + if (waveform == null) { + waveform = SignalWaveform.empty(signalId); + _subFieldWaveformCache[signalId] = waveform; + } + waveform.appendData(data, sortByTime: sortByTime); + return true; + } + + var waveform = _waveformCache[addr]; + if (waveform == null) { + waveform = SignalWaveform.empty(signalId); + _waveformCache[addr] = waveform; + } + waveform.appendData(data, sortByTime: sortByTime); + return true; + } + + /// Clears waveform data for a specific signal. + /// + /// [address] is the address of the signal whose data should be cleared. + /// + /// Returns true if the waveform was found and data was cleared. + bool clearWaveformData(OccurrenceAddress address) { + final waveform = _waveformCache[address]; + if (waveform != null) { + waveform.clearData(); + return true; + } + return false; + } + + /// Clears all waveform data from all cached signals. + void clearAllWaveformData() { + _waveformCache.clear(); + _subFieldWaveformCache.clear(); + } + + /// Clears the entire signal cache (used when loading a new file). + void clearSignalCache() { + _signalCache.clear(); + _waveformCache.clear(); + _subFieldWaveformCache.clear(); + } + + // ───────────── Address-based cache accessors ───────────────── + + /// Gets a signal by its [OccurrenceAddress]. O(1). + SignalOccurrence? getSignal(OccurrenceAddress address) => + _signalCache[address]; + + /// Gets a signal waveform by [OccurrenceAddress]. O(1). + SignalWaveform? getWaveform(OccurrenceAddress address) => + _waveformCache[address]; + + /// Gets all cached signal addresses. + List get cachedSignalAddresses => + _signalCache.keys.toList(); + + // ───────────── String convenience accessors (tree-walk) ────────── + + /// Gets a signal by pathname string. O(depth) tree walk. + SignalOccurrence? getSignalById(String signalId) => + _lookupSignalByPath(signalId); + + /// Gets a signal waveform by pathname string. O(depth) tree walk. + /// Falls back to the sub-field cache for computed waveforms (bit-slices). + SignalWaveform? getWaveformById(String signalId) { + final addr = hierarchyService?.pathnameToAddress(signalId); + if (addr != null) { + return _waveformCache[addr]; + } + // Fallback: check sub-field cache for computed waveforms. + return _subFieldWaveformCache[signalId]; + } + + /// Gets all cached signal IDs as pathname strings. + List get cachedSignalIds => + _signalCache.values.map((s) => s.path()).toList(); + + /// Gets signals with their waveform data for the selected module. + List getWaveformsBySelectedModule( + HierarchyOccurrence module, + ) { + final waveforms = []; + for (final signal in module.signals) { + final addr = signal.address; + if (addr == null) { + continue; + } + var waveform = _waveformCache[addr]; + if (waveform == null) { + waveform = SignalWaveform.empty(signal.path()); + _waveformCache[addr] = waveform; + } + waveforms.add(waveform); + } + return waveforms; + } + + /// Gets signal metadata for the selected module. + /// + /// Use [getWaveformsBySelectedModule] to get waveform data. + List getSignalsBySelectedModule( + HierarchyOccurrence module) { + final signals = []; + for (final signal in module.signals) { + final addr = signal.address; + if (addr == null) { + continue; + } + if (!_signalCache.containsKey(addr)) { + _signalCache[addr] = signal; + } + signals.add(signal); + } + return signals; + } + + /// Builds the signal cache from the signal hierarchy. + void _buildSignalCache(List modules) { + void collectSignals(List nodes) { + for (final module in nodes) { + for (final signal in module.signals) { + final addr = signal.address; + if (addr == null) { + continue; + } + if (!_signalCache.containsKey(addr)) { + _signalCache[addr] = signal; + } + if (!_waveformCache.containsKey(addr)) { + _waveformCache[addr] = SignalWaveform.empty(signal.path()); + } + } + collectSignals(module.children); + } + } + + collectSignals(modules); + } + + // ───────────────────────────────────────────────────────────────────────── + // Sub-field bit-slice synthesis (API-agnostic) + // ───────────────────────────────────────────────────────────────────────── + + static const String _subFieldSeparator = '#'; + + /// Synthesize waveform data for a sub-field by extracting a bit range + /// from the parent signal's waveform data. + /// + /// Works with any API backend (DevTools, Wellen/VCD) because it fetches the + /// parent waveform via the API and resolves bit positions from the + /// hierarchy metadata. + Future _synthesizeBitSlice( + String signalId, { + int? startTime, + int? endTime, + }) async { + final idx = signalId.indexOf(_subFieldSeparator); + if (idx < 0) { + return null; + } + + final parentPath = signalId.substring(0, idx); + final fieldPath = signalId.substring(idx + 1); + + // Resolve parent signal metadata for logicType and width. + final parentSig = _lookupSignalByPath(parentPath); + if (parentSig == null) { + return null; + } + final parentWidth = parentSig.width; + + // Handle flat bit-slice patterns: b[N] or b[high:low]. + // These don't require logicType — they are pure bitvector access. + final bitSliceMatch = + RegExp(r'^b\[(\d+)(?::(\d+))?\]$').firstMatch(fieldPath); + int? bitSliceLo; + int? bitSliceWidth; + if (bitSliceMatch != null) { + final hi = int.parse(bitSliceMatch.group(1)!); + final lo = bitSliceMatch.group(2) != null + ? int.parse(bitSliceMatch.group(2)!) + : hi; + bitSliceLo = lo < hi ? lo : hi; + bitSliceWidth = (hi - lo).abs() + 1; + } else if (parentSig.logicType == null) { + return null; + } + + int lo; + int fieldWidth; + if (bitSliceLo != null) { + lo = bitSliceLo; + fieldWidth = bitSliceWidth!; + } else { + final resolved = _resolveFieldBits(parentSig.logicType!, fieldPath); + if (resolved == null) { + return null; + } + lo = resolved.startBit; + fieldWidth = resolved.width; + } + + // Fetch parent waveform data from the API. + final parentData = await _signalWaveformApi.getWaveformData( + signalIds: [parentPath], + startTime: startTime, + endTime: endTime, + ); + + if (parentData.isEmpty || parentData.first.data.isEmpty) { + return WaveformData(signalId: signalId, data: const []); + } + + final pData = parentData.first.data; + + // Extract bits at each parent timepoint, deduplicating consecutive values. + final outputData = []; + String? lastValue; + + for (final point in pData) { + final sliced = _extractBits(point.value, parentWidth, lo, fieldWidth); + if (sliced != lastValue) { + outputData.add(Data(time: point.time, value: sliced)); + lastValue = sliced; + } + } + + return WaveformData( + signalId: signalId, + data: outputData, + isComputed: true, + ); + } + + /// Recursively resolve a dot-separated field path within a [logicType] map + /// to its absolute bit position and width within the parent signal. + static ({int startBit, int width})? _resolveFieldBits( + Map logicType, + String fieldPath, + ) { + final dotIdx = fieldPath.indexOf('.'); + final String segment; + final String? remainder; + if (dotIdx >= 0) { + segment = fieldPath.substring(0, dotIdx); + remainder = fieldPath.substring(dotIdx + 1); + } else { + segment = fieldPath; + remainder = null; + } + + // Struct case: look up named field. + final fields = logicType['fields'] as List?; + if (fields != null) { + for (final fieldRaw in fields) { + final field = fieldRaw as Map; + final name = field['name'] as String? ?? ''; + if (name != segment) { + continue; + } + + final bits = field['bits'] as List?; + final width = field['width'] as int? ?? 1; + final startBit = bits != null && bits.isNotEmpty + ? (bits.cast().reduce((a, b) => a < b ? a : b)) + : 0; + + if (remainder == null) { + return (startBit: startBit, width: width); + } + final nestedType = field['type'] as Map?; + if (nestedType == null) { + return null; + } + final inner = _resolveFieldBits(nestedType, remainder); + if (inner == null) { + return null; + } + return (startBit: startBit + inner.startBit, width: inner.width); + } + return null; + } + + // Array case: look up by index [N]. + final arrayDims = logicType['arrayDims'] as List?; + if (arrayDims != null) { + final leafWidth = (logicType['elementWidth'] as int?) ?? 1; + final remainingDims = + arrayDims.length > 1 ? arrayDims.sublist(1).cast() : []; + final perElementWidth = remainingDims.isEmpty + ? leafWidth + : remainingDims.fold(leafWidth, (acc, d) => acc * d); + final elementType = logicType['elementType'] as Map?; + + final match = RegExp(r'^\[(\d+)\]$').firstMatch(segment); + if (match == null) { + return null; + } + final index = int.parse(match.group(1)!); + final startBit = index * perElementWidth; + + if (remainder == null) { + return (startBit: startBit, width: perElementWidth); + } + final subType = elementType ?? + (remainingDims.isNotEmpty + ? { + 'arrayDims': remainingDims, + 'elementWidth': leafWidth, + 'width': perElementWidth, + } + : null); + if (subType == null) { + return null; + } + final inner = _resolveFieldBits(subType, remainder); + if (inner == null) { + return null; + } + return (startBit: startBit + inner.startBit, width: inner.width); + } + + return null; + } + + /// Extract [width] bits starting at [startBit] from a value string. + /// + /// Handles multiple value formats: + /// - ROHD LogicValue format: `16'hFF00`, `8'b10101010` + /// - Raw hex: `ff00`, `0xff00` + /// - Raw binary: `10101010` + /// - x/z states + /// + /// Returns a hex-formatted string for the extracted slice. + static String _extractBits( + String value, + int parentWidth, + int startBit, + int width, + ) { + final lower = value.toLowerCase().trim(); + + // Handle pure x/z values. + if (lower.replaceAll('x', '').replaceAll('z', '').isEmpty && + lower.isNotEmpty) { + return width == 1 ? 'x' : 'x' * ((width + 3) ~/ 4); + } + + BigInt? bi; + + // Try ROHD radix format: 'h or 'b + final rohdMatch = RegExp(r"^(\d+)'([hb])(.+)$").firstMatch(lower); + if (rohdMatch != null) { + final radixChar = rohdMatch.group(2)!; + final digits = rohdMatch.group(3)!; + // Check for x/z in the value portion. + if (digits.contains('x') || digits.contains('z')) { + return width == 1 ? 'x' : 'x' * ((width + 3) ~/ 4); + } + final radix = radixChar == 'h' ? 16 : 2; + bi = BigInt.tryParse(digits, radix: radix); + } else if (lower.startsWith('0x')) { + bi = BigInt.tryParse(value.substring(2), radix: 16); + } else if (RegExp(r'^[01]+$').hasMatch(lower)) { + bi = BigInt.tryParse(value, radix: 2); + } else { + // Default: try hex parse. + bi = BigInt.tryParse(value, radix: 16); + } + + if (bi == null) { + return width == 1 ? 'x' : 'x' * ((width + 3) ~/ 4); + } + + // Extract the bit range. + final mask = (BigInt.one << width) - BigInt.one; + final sliced = (bi >> startBit) & mask; + + // Format output using ROHD radixString style: width'hHEX + if (width == 1) { + return sliced == BigInt.one ? '1' : '0'; + } + final hexDigits = (width + 3) ~/ 4; + final hex = sliced.toRadixString(16).padLeft(hexDigits, '0'); + return "$width'h$hex"; + } +} diff --git a/packages/rohd_waveform/pubspec.yaml b/packages/rohd_waveform/pubspec.yaml new file mode 100644 index 000000000..f349385c3 --- /dev/null +++ b/packages/rohd_waveform/pubspec.yaml @@ -0,0 +1,20 @@ +name: rohd_waveform +description: "Waveform data models and APIs for wave viewers - ModuleStructure, SignalWaveform, and waveform primitives." +homepage: https://intel.github.io/rohd-website/ +repository: https://github.com/intel/rohd +version: 0.0.2 +issue_tracker: https://github.com/intel/rohd/issues + +publish_to: none + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + equatable: ^2.0.5 + rohd_hierarchy: + path: ../rohd_hierarchy + +dev_dependencies: + lints: ^3.0.0 + test: ^1.17.3 diff --git a/packages/rohd_waveform/test/metadata_test.dart b/packages/rohd_waveform/test/metadata_test.dart new file mode 100644 index 000000000..768487794 --- /dev/null +++ b/packages/rohd_waveform/test/metadata_test.dart @@ -0,0 +1,73 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// metadata_test.dart +// Unit tests for the MetaData and Data models. +// +// 2026 + +import 'package:rohd_waveform/rohd_waveform.dart'; +import 'package:test/test.dart'; + +void main() { + group('Data', () { + test('round-trips through JSON', () { + final data = Data(time: 42, value: '1010'); + final restored = Data.fromJson(data.toJson()); + expect(restored.time, 42); + expect(restored.value, '1010'); + }); + + test('empty starts at time zero with value 0', () { + final data = Data.empty(); + expect(data.time, 0); + expect(data.value, '0'); + }); + }); + + group('MetaData', () { + test('value equality via Equatable', () { + const a = MetaData(source: 'a.vcd', timescale: '1ns', date: 'today'); + const b = MetaData(source: 'a.vcd', timescale: '1ns', date: 'today'); + const c = MetaData(source: 'b.vcd', timescale: '1ns', date: 'today'); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + + test('empty has blank fields and zero range', () { + final meta = MetaData.empty(); + expect(meta.source, isEmpty); + expect(meta.timescale, isEmpty); + expect(meta.date, isEmpty); + expect(meta.startTime, 0); + expect(meta.endTime, 0); + expect(meta.format, isNull); + }); + + test('round-trips full payload through JSON', () { + const meta = MetaData( + source: 'dump.fst', + timescale: '100ps', + date: '2026-01-01', + startTime: 5, + endTime: 500, + timescaleFactor: 100, + version: 'sim-1.2', + format: WaveFormat.fst, + ); + final restored = MetaData.fromJson(meta.toJson()); + expect(restored, equals(meta)); + expect(restored.format, WaveFormat.fst); + expect(restored.timescaleFactor, 100); + expect(restored.version, 'sim-1.2'); + }); + + test('omits optional fields from JSON when null', () { + const meta = MetaData(source: 's', timescale: 't', date: 'd'); + final json = meta.toJson(); + expect(json.containsKey('timescaleFactor'), isFalse); + expect(json.containsKey('version'), isFalse); + expect(json.containsKey('format'), isFalse); + }); + }); +} diff --git a/packages/rohd_waveform/test/module_structure_test.dart b/packages/rohd_waveform/test/module_structure_test.dart new file mode 100644 index 000000000..860966fdf --- /dev/null +++ b/packages/rohd_waveform/test/module_structure_test.dart @@ -0,0 +1,38 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// module_structure_test.dart +// Unit tests for the ModuleStructure model (empty / no-signal cases). +// +// 2026 + +import 'package:rohd_waveform/rohd_waveform.dart'; +import 'package:test/test.dart'; + +void main() { + group('ModuleStructure', () { + test('empty has blank metadata and no modules', () { + final structure = ModuleStructure.empty(); + expect(structure.modules, isEmpty); + expect(structure.metadata, equals(MetaData.empty())); + expect(structure.allSignalIds, isEmpty); + expect(structure.firstModuleWithSignals, isNull); + }); + + test('value equality via Equatable', () { + final a = ModuleStructure.empty(); + final b = ModuleStructure.empty(); + const c = ModuleStructure( + metadata: MetaData(source: 'x', timescale: '1ns', date: 'd'), + modules: [], + ); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + + test('withFirstRealModule returns the structure unchanged when empty', () { + final structure = ModuleStructure.empty(); + expect(structure.withFirstRealModule(), same(structure)); + }); + }); +} diff --git a/packages/rohd_waveform/test/signal_waveform_test.dart b/packages/rohd_waveform/test/signal_waveform_test.dart new file mode 100644 index 000000000..8667a4b39 --- /dev/null +++ b/packages/rohd_waveform/test/signal_waveform_test.dart @@ -0,0 +1,184 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_waveform_test.dart +// Unit tests for SignalWaveform and WaveformData (no signal lookup required). +// +// 2026 + +import 'package:rohd_waveform/rohd_waveform.dart'; +import 'package:test/test.dart'; + +void main() { + // Ensure a clean static lookup state for these pure-data tests. + setUp(SignalWaveform.clearSignalLookup); + tearDown(SignalWaveform.clearSignalLookup); + + group('SignalWaveform metadata fallbacks (no lookup)', () { + test('name/width fall back to overrides then signalId', () { + final wf = SignalWaveform(signalId: 'clk'); + expect(wf.signal, isNull); + expect(wf.name, 'clk'); + expect(wf.width, 1); + expect(wf.hierarchyPath, 'clk'); + expect(wf.id, 'clk'); + expect(wf.isPort, isFalse); + + final overridden = SignalWaveform( + signalId: 'sig0', + overrideName: 'bus', + overrideWidth: 8, + ); + expect(overridden.name, 'bus'); + expect(overridden.width, 8); + }); + + test('empty and length reflect data', () { + final wf = SignalWaveform.empty('s'); + expect(wf.isEmpty, isTrue); + expect(wf.isNotEmpty, isFalse); + expect(wf.length, 0); + + wf.appendData([Data(time: 0, value: '0')]); + expect(wf.isEmpty, isFalse); + expect(wf.length, 1); + + wf.clearData(); + expect(wf.isEmpty, isTrue); + }); + }); + + group('SignalWaveform.appendData', () { + test('sortByTime sorts and dedups keeping the latest value', () { + final wf = SignalWaveform(signalId: 's') + ..appendData([ + Data(time: 10, value: 'a'), + Data(time: 0, value: 'x'), + Data(time: 10, value: 'b'), + ], sortByTime: true); + + expect(wf.data.map((d) => d.time).toList(), [0, 10]); + // Last value at duplicate timestamp 10 wins. + expect(wf.getValueByTime(10), 'b'); + expect(wf.getValueByTime(0), 'x'); + }); + }); + + group('SignalWaveform.getValueByTime (binary search)', () { + final wf = SignalWaveform(signalId: 's', data: [ + Data(time: 0, value: '0'), + Data(time: 10, value: '1'), + Data(time: 20, value: '0'), + ]); + + test('returns the value at or before a time', () { + expect(wf.getValueByTime(0), '0'); + expect(wf.getValueByTime(5), '0'); + expect(wf.getValueByTime(10), '1'); + expect(wf.getValueByTime(15), '1'); + expect(wf.getValueByTime(100), '0'); + }); + + test('returns first value before the first sample', () { + expect(wf.getValueByTime(-5), '0'); + }); + + test('empty waveform returns empty string', () { + expect(SignalWaveform.empty('e').getValueByTime(3), ''); + }); + }); + + group('SignalWaveform navigation indices', () { + final wf = SignalWaveform(signalId: 's', data: [ + Data(time: 0, value: '0'), + Data(time: 10, value: '1'), + Data(time: 10, value: '1'), + Data(time: 20, value: '0'), + ]); + + test('getNextDataPointIndex skips duplicates at current time', () { + expect(wf.getNextDataPointIndex(0), 1); + expect(wf.getNextDataPointIndex(10), 3); + expect(wf.getNextDataPointIndex(20), -1); + }); + + test('getPreviousDataPointIndex skips duplicates at current time', () { + expect(wf.getPreviousDataPointIndex(20), 2); + expect(wf.getPreviousDataPointIndex(10), 0); + expect(wf.getPreviousDataPointIndex(0), -1); + }); + }); + + group('SignalWaveform copy/serialization', () { + test('copyFrom produces an independent data list', () { + final original = SignalWaveform( + signalId: 's', + data: [Data(time: 0, value: '0')], + overrideName: 'n', + overrideWidth: 4, + ); + final copy = SignalWaveform.copyFrom(original); + expect(copy.signalId, 's'); + expect(copy.overrideName, 'n'); + expect(copy.overrideWidth, 4); + + copy.appendData([Data(time: 5, value: '1')]); + expect(original.length, 1, reason: 'copy must not mutate the original'); + expect(copy.length, 2); + }); + + test('round-trips through JSON', () { + final wf = SignalWaveform(signalId: 'sig', data: [ + Data(time: 0, value: '0'), + Data(time: 4, value: '1'), + ]); + final restored = SignalWaveform.fromJson(wf.toJson()); + expect(restored.signalId, 'sig'); + expect(restored.length, 2); + expect(restored.getValueByTime(4), '1'); + }); + }); + + group('WaveformData', () { + test('empty payload reports no data and null bounds', () { + final wd = WaveformData.empty('s'); + expect(wd.isEmpty, isTrue); + expect(wd.length, 0); + expect(wd.startTime, isNull); + expect(wd.endTime, isNull); + }); + + test('reports start/end times from its samples', () { + final wd = WaveformData(signalId: 's', data: [ + Data(time: 3, value: '0'), + Data(time: 9, value: '1'), + ]); + expect(wd.startTime, 3); + expect(wd.endTime, 9); + expect(wd.isNotEmpty, isTrue); + }); + + test('round-trips through JSON', () { + final wd = WaveformData( + signalId: 's', + data: [Data(time: 1, value: '1')], + isComputed: true, + ); + final restored = WaveformData.fromJson(wd.toJson()); + expect(restored.signalId, 's'); + expect(restored.length, 1); + }); + + test('SignalWaveform.fromWaveformData copies data and flags', () { + final wd = WaveformData( + signalId: 's', + data: [Data(time: 0, value: '0')], + isComputed: true, + ); + final wf = SignalWaveform.fromWaveformData(wd); + expect(wf.signalId, 's'); + expect(wf.length, 1); + expect(wf.isComputed, isTrue); + }); + }); +} diff --git a/packages/rohd_waveform/test/wave_format_test.dart b/packages/rohd_waveform/test/wave_format_test.dart new file mode 100644 index 000000000..28bdacae7 --- /dev/null +++ b/packages/rohd_waveform/test/wave_format_test.dart @@ -0,0 +1,58 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// wave_format_test.dart +// Unit tests for the WaveFormat enumeration. +// +// 2026 + +import 'package:rohd_waveform/rohd_waveform.dart'; +import 'package:test/test.dart'; + +void main() { + group('WaveFormat', () { + test('extension returns the expected file suffix', () { + expect(WaveFormat.vcd.extension, '.vcd'); + expect(WaveFormat.fst.extension, '.fst'); + expect(WaveFormat.ghw.extension, '.ghw'); + expect(WaveFormat.unknown.extension, ''); + }); + + test('fromPath detects format from a file path', () { + expect(WaveFormat.fromPath('sim/out.VCD'), WaveFormat.vcd); + expect(WaveFormat.fromPath('/tmp/dump.fst'), WaveFormat.fst); + expect(WaveFormat.fromPath('design.ghw'), WaveFormat.ghw); + expect(WaveFormat.fromPath('notes.txt'), WaveFormat.unknown); + }); + + test('fromString parses a format name case-insensitively', () { + expect(WaveFormat.fromString('VCD'), WaveFormat.vcd); + expect(WaveFormat.fromString('fst'), WaveFormat.fst); + expect(WaveFormat.fromString('Ghw'), WaveFormat.ghw); + expect(WaveFormat.fromString('mystery'), WaveFormat.unknown); + }); + + test('round-trips through name and fromString', () { + for (final format in WaveFormat.values) { + if (format == WaveFormat.unknown) { + continue; + } + expect(WaveFormat.fromString(format.name), format); + } + }); + + test('supportsWriting matches the documented formats', () { + expect(WaveFormat.vcd.supportsWriting, isTrue); + expect(WaveFormat.fst.supportsWriting, isTrue); + expect(WaveFormat.ghw.supportsWriting, isFalse); + expect(WaveFormat.unknown.supportsWriting, isFalse); + }); + + test('supportsReading matches the documented formats', () { + expect(WaveFormat.vcd.supportsReading, isTrue); + expect(WaveFormat.fst.supportsReading, isTrue); + expect(WaveFormat.ghw.supportsReading, isTrue); + expect(WaveFormat.unknown.supportsReading, isFalse); + }); + }); +} diff --git a/pubspec.yaml b/pubspec.yaml index 01f1ac72f..9712a3ad3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,7 @@ environment: dependencies: collection: ^1.15.0 + equatable: ^2.0.5 logging: ^1.0.1 meta: ^1.9.0 test: ^1.17.3 diff --git a/test/wave_dumper_test.dart b/test/wave_dumper_test.dart index 07aafc8c8..ca6a2a5c6 100644 --- a/test/wave_dumper_test.dart +++ b/test/wave_dumper_test.dart @@ -74,14 +74,17 @@ void main() { final vcdContents = File(temporaryDumpPath(dumpName)).readAsStringSync(); expect( - VcdParser.confirmValue(vcdContents, 'a', 0, LogicValue.ofString('1')), - equals(true)); + VcdParser.confirmValue(vcdContents, 'a', 0, LogicValue.ofString('1')), + equals(true), + ); expect( - VcdParser.confirmValue(vcdContents, 'a', 5, LogicValue.ofString('1')), - equals(true)); + VcdParser.confirmValue(vcdContents, 'a', 5, LogicValue.ofString('1')), + equals(true), + ); expect( - VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.ofString('0')), - equals(true)); + VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.ofString('0')), + equals(true), + ); deleteTemporaryDump(dumpName); }); @@ -103,17 +106,21 @@ void main() { final vcdContents = File(temporaryDumpPath(dumpName)).readAsStringSync(); expect( - VcdParser.confirmValue(vcdContents, 'a', 0, LogicValue.ofString('1')), - equals(true)); + VcdParser.confirmValue(vcdContents, 'a', 0, LogicValue.ofString('1')), + equals(true), + ); expect( - VcdParser.confirmValue(vcdContents, 'a', 1, LogicValue.ofString('1')), - equals(true)); + VcdParser.confirmValue(vcdContents, 'a', 1, LogicValue.ofString('1')), + equals(true), + ); expect( - VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.ofString('0')), - equals(true)); + VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.ofString('0')), + equals(true), + ); expect( - VcdParser.confirmValue(vcdContents, 'a', 20, LogicValue.ofString('1')), - equals(true)); + VcdParser.confirmValue(vcdContents, 'a', 20, LogicValue.ofString('1')), + equals(true), + ); deleteTemporaryDump(dumpName); }); @@ -145,17 +152,21 @@ void main() { final vcdContents = File(temporaryDumpPath(dumpName)).readAsStringSync(); expect( - VcdParser.confirmValue(vcdContents, 'a', 0, LogicValue.ofString('0')), - equals(true)); + VcdParser.confirmValue(vcdContents, 'a', 0, LogicValue.ofString('0')), + equals(true), + ); expect( - VcdParser.confirmValue(vcdContents, 'a', 5, LogicValue.ofString('1')), - equals(true)); + VcdParser.confirmValue(vcdContents, 'a', 5, LogicValue.ofString('1')), + equals(true), + ); expect( - VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.ofString('0')), - equals(true)); + VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.ofString('0')), + equals(true), + ); expect( - VcdParser.confirmValue(vcdContents, 'a', 35, LogicValue.ofString('0')), - equals(true)); + VcdParser.confirmValue(vcdContents, 'a', 35, LogicValue.ofString('0')), + equals(true), + ); deleteTemporaryDump(dumpName); }); @@ -176,11 +187,13 @@ void main() { final vcdContents = File(temporaryDumpPath(dumpName)).readAsStringSync(); expect( - VcdParser.confirmValue(vcdContents, 'a', 0, LogicValue.ofInt(0x5a, 8)), - equals(true)); + VcdParser.confirmValue(vcdContents, 'a', 0, LogicValue.ofInt(0x5a, 8)), + equals(true), + ); expect( - VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.ofInt(0xa5, 8)), - equals(true)); + VcdParser.confirmValue(vcdContents, 'a', 10, LogicValue.ofInt(0xa5, 8)), + equals(true), + ); deleteTemporaryDump(dumpName); }); @@ -201,13 +214,23 @@ void main() { final vcdContents = File(temporaryDumpPath(dumpName)).readAsStringSync(); expect( - VcdParser.confirmValue( - vcdContents, 'a', 0, LogicValue.ofString('01xzzx10')), - equals(true)); + VcdParser.confirmValue( + vcdContents, + 'a', + 0, + LogicValue.ofString('01xzzx10'), + ), + equals(true), + ); expect( - VcdParser.confirmValue( - vcdContents, 'a', 10, LogicValue.ofString('0x0x1z1z')), - equals(true)); + VcdParser.confirmValue( + vcdContents, + 'a', + 10, + LogicValue.ofString('0x0x1z1z'), + ), + equals(true), + ); deleteTemporaryDump(dumpName); }); @@ -245,6 +268,8 @@ void main() { expect(File(waveDumper.outputPath).existsSync(), equals(true)); + await Simulator.run(); + if (File(waveDumper.outputPath).existsSync()) { File(dir1Path).deleteSync(recursive: true); } @@ -277,16 +302,21 @@ void main() { // reset is 0 initially expect( - VcdParser.confirmValue(vcdContents, 'asyncReset', 1, LogicValue.zero), - equals(true)); + VcdParser.confirmValue(vcdContents, 'asyncReset', 1, LogicValue.zero), + equals(true), + ); // 1 after first clock edge - expect(VcdParser.confirmValue(vcdContents, 'val', 6, LogicValue.one), - equals(true)); + expect( + VcdParser.confirmValue(vcdContents, 'val', 6, LogicValue.one), + equals(true), + ); // 0 after async reset - expect(VcdParser.confirmValue(vcdContents, 'val', 14, LogicValue.zero), - equals(true)); + expect( + VcdParser.confirmValue(vcdContents, 'val', 14, LogicValue.zero), + equals(true), + ); deleteTemporaryDump(dumpName); }); diff --git a/tool/gh_actions/analyze_source.sh b/tool/gh_actions/analyze_source.sh index 8fc260b6b..926467b9d 100755 --- a/tool/gh_actions/analyze_source.sh +++ b/tool/gh_actions/analyze_source.sh @@ -12,3 +12,15 @@ set -euo pipefail dart analyze --fatal-infos + +# Analyze sub-packages that have their own pubspec.yaml and are excluded +# from the root analysis_options.yaml. +for pkg in packages/rohd_hierarchy; do + if [ -f "$pkg/pubspec.yaml" ]; then + echo "Analyzing sub-package: $pkg" + pushd "$pkg" > /dev/null + dart pub get + dart analyze --fatal-infos + popd > /dev/null + fi +done diff --git a/tool/gh_codespaces/install_dart.sh b/tool/gh_codespaces/install_dart.sh index abbe39a0c..d0bfdfe91 100755 --- a/tool/gh_codespaces/install_dart.sh +++ b/tool/gh_codespaces/install_dart.sh @@ -8,24 +8,93 @@ # # 2023 February 5 # Author: Chykon +# +# 2026 June 21 +# Updated to add fallback logic for fetching the latest Dart repository key from Google if the locally cached key fails verification (e.g. due to key rotation). +# Author: Desmond A. Kirkpatrick set -euo pipefail -# Add Dart repository key. +declare -r cached_pubkey_file="$(dirname "${BASH_SOURCE[0]}")/pubkeys/dart.pub" +declare -r keyring_file='/usr/share/keyrings/dart.gpg' +declare -r dart_repository_file='/etc/apt/sources.list.d/dart_stable.list' +declare -r dart_repository_url='https://storage.googleapis.com/download.dartlang.org/linux/debian' +declare -r google_signing_key_url='https://dl-ssl.google.com/linux/linux_signing_key.pub' -declare -r input_pubkey_file='tool/gh_codespaces/pubkeys/dart.pub' -declare -r output_pubkey_file='/usr/share/keyrings/dart.gpg' +sudo apt-get update +sudo apt-get install -y wget gpg apt-transport-https -sudo gpg --output ${output_pubkey_file} --dearmor ${input_pubkey_file} +sudo mkdir -p /usr/share/keyrings # Add Dart repository. -declare -r dart_repository_url='https://storage.googleapis.com/download.dartlang.org/linux/debian' -declare -r dart_repository_file='/etc/apt/sources.list.d/dart.list' +echo "deb [signed-by=${keyring_file}] ${dart_repository_url} stable main" \ + | sudo tee "${dart_repository_file}" + +# Install the repository key from the locally cached, ASCII-armored public key. +install_key_from_file() { + sudo gpg --yes --output "${keyring_file}" --dearmor "${1}" +} + +# Install the repository key by fetching the latest key from Google. +install_key_from_google() { + wget -qO- "${google_signing_key_url}" \ + | gpg --dearmor \ + | sudo tee "${keyring_file}" >/dev/null +} + +# Emit a prominent warning that stands out in CI logs (and as a GitHub Actions +# annotation when available) without failing the build. +warn_loudly() { + local message="${1}" + { + echo '' + echo '################################################################################' + echo '## install_dart WARNING' + echo "## ${message}" + echo '################################################################################' + echo '' + } >&2 + # Surface a GitHub Actions warning annotation (non-fatal) when running in CI. + if [[ -n "${GITHUB_ACTIONS:-}" ]]; then + echo "::warning title=install_dart cached key bypassed::${message}" + fi +} + +# Verify that the installed keyring can authenticate the Dart repository by +# refreshing only the Dart sources list and checking for signature/key errors. +dart_repository_verified() { + local update_log + if ! update_log=$(sudo apt-get update \ + -o Dir::Etc::sourcelist="${dart_repository_file}" \ + -o Dir::Etc::sourceparts="-" \ + -o APT::Get::List-Cleanup="0" 2>&1); then + return 1 + fi + if echo "${update_log}" \ + | grep -Eiq 'NO_PUBKEY|EXPKEYSIG|REVKEYSIG|BADSIG|not signed|could.?n.?t be verified'; then + return 1 + fi + return 0 +} + +# Prefer the locally cached key. If it can no longer authenticate the repository +# (e.g. the key has been rotated), fall back to fetching the latest key from +# Google so the install can still proceed. +install_key_from_file "${cached_pubkey_file}" -echo "deb [signed-by=${output_pubkey_file}] ${dart_repository_url} stable main" | sudo tee ${dart_repository_file} +if dart_repository_verified; then + echo 'install_dart: using locally cached Dart repository key.' +else + install_key_from_google + if ! dart_repository_verified; then + echo 'install_dart: Dart repository key verification failed even after fetching the latest key from Google.' >&2 + exit 1 + fi + warn_loudly "Cached Dart repository key (${cached_pubkey_file}) failed verification and was bypassed; installed using the latest key fetched from Google. Please refresh the cached key." +fi # Install Dart. sudo apt-get update -sudo apt-get install dart +sudo apt-get install -y dart diff --git a/tool/gh_codespaces/pubkeys/dart.pub b/tool/gh_codespaces/pubkeys/dart.pub index 0366239cb..839f8a235 100644 --- a/tool/gh_codespaces/pubkeys/dart.pub +++ b/tool/gh_codespaces/pubkeys/dart.pub @@ -1,35 +1,4 @@ -----BEGIN PGP PUBLIC KEY BLOCK----- -Version: GnuPG v1.4.2.2 (GNU/Linux) - -mQGiBEXwb0YRBADQva2NLpYXxgjNkbuP0LnPoEXruGmvi3XMIxjEUFuGNCP4Rj/a -kv2E5VixBP1vcQFDRJ+p1puh8NU0XERlhpyZrVMzzS/RdWdyXf7E5S8oqNXsoD1z -fvmI+i9b2EhHAA19Kgw7ifV8vMa4tkwslEmcTiwiw8lyUl28Wh4Et8SxzwCggDcA -feGqtn3PP5YAdD0km4S4XeMEAJjlrqPoPv2Gf//tfznY2UyS9PUqFCPLHgFLe80u -QhI2U5jt6jUKN4fHauvR6z3seSAsh1YyzyZCKxJFEKXCCqnrFSoh4WSJsbFNc4PN -b0V0SqiTCkWADZyLT5wll8sWuQ5ylTf3z1ENoHf+G3um3/wk/+xmEHvj9HCTBEXP -78X0A/0Tqlhc2RBnEf+AqxWvM8sk8LzJI/XGjwBvKfXe+l3rnSR2kEAvGzj5Sg0X -4XmfTg4Jl8BNjWyvm2Wmjfet41LPmYJKsux3g0b8yzQxeOA4pQKKAU3Z4+rgzGmf -HdwCG5MNT2A5XxD/eDd+L4fRx0HbFkIQoAi1J3YWQSiTk15fw7RMR29vZ2xlLCBJ -bmMuIExpbnV4IFBhY2thZ2UgU2lnbmluZyBLZXkgPGxpbnV4LXBhY2thZ2VzLWtl -eW1hc3RlckBnb29nbGUuY29tPohjBBMRAgAjAhsDBgsJCAcDAgQVAggDBBYCAwEC -HgECF4AFAkYVdn8CGQEACgkQoECDD3+sWZHKSgCfdq3HtNYJLv+XZleb6HN4zOcF -AJEAniSFbuv8V5FSHxeRimHx25671az+uQINBEXwb0sQCACuA8HT2nr+FM5y/kzI -A51ZcC46KFtIDgjQJ31Q3OrkYP8LbxOpKMRIzvOZrsjOlFmDVqitiVc7qj3lYp6U -rgNVaFv6Qu4bo2/ctjNHDDBdv6nufmusJUWq/9TwieepM/cwnXd+HMxu1XBKRVk9 -XyAZ9SvfcW4EtxVgysI+XlptKFa5JCqFM3qJllVohMmr7lMwO8+sxTWTXqxsptJo -pZeKz+UBEEqPyw7CUIVYGC9ENEtIMFvAvPqnhj1GS96REMpry+5s9WKuLEaclWpd -K3krttbDlY1NaeQUCRvBYZ8iAG9YSLHUHMTuI2oea07Rh4dtIAqPwAX8xn36JAYG -2vgLAAMFB/wKqaycjWAZwIe98Yt0qHsdkpmIbarD9fGiA6kfkK/UxjL/k7tmS4Vm -CljrrDZkPSQ/19mpdRcGXtb0NI9+nyM5trweTvtPw+HPkDiJlTaiCcx+izg79Fj9 -KcofuNb3lPdXZb9tzf5oDnmm/B+4vkeTuEZJ//IFty8cmvCpzvY+DAz1Vo9rA+Zn -cpWY1n6z6oSS9AsyT/IFlWWBZZ17SpMHu+h4Bxy62+AbPHKGSujEGQhWq8ZRoJAT -G0KSObnmZ7FwFWu1e9XFoUCt0bSjiJWTIyaObMrWu/LvJ3e9I87HseSJStfw6fki -5og9qFEkMrIrBCp3QGuQWBq/rTdMuwNFiEkEGBECAAkFAkXwb0sCGwwACgkQoECD -D3+sWZF/WACfeNAu1/1hwZtUo1bR+MWiCjpvHtwAnA1R3IHqFLQ2X3xJ40XPuAyY -/FJG -=Quqp ------END PGP PUBLIC KEY BLOCK----- ------BEGIN PGP PUBLIC KEY BLOCK----- mQINBFcMjNMBEAC6Wr5QuLIFgz1V1EFPlg8ty2TsjQEl4VWftUAqWlMevJFWvYEx BOsOZ6kNFfBfjAxgJNWTkxZrHzDl74R7KW/nUx6X57bpFjUyRaB8F3/NpWKSeIGS @@ -262,6 +231,75 @@ pU5M3j2F1RFKRr95+HZT/NXNeGbFvsdKmvP4ELtDAuYVMgYR8GqjI5yP/ccVMsi/ mhT+cUxO/F7+7nixw1Go637Jqr/NF5kjjrBD8EiGy8QrGm6uBR3NGad0BnMWKa2Y oYKF1m3Fs/evBkcymR+hSwFzkXm6WSOb8hzJIayFa6kAc7uSKyR5iG00p/neibbq M1aUAQDBwV7g9wPmcdRIjJS2MtK1JXHZCR1gVKb+EObct6RJOVw8s58ES5O9wGZm -bVtIZ+JHTbuH+tg0EoRNcCbz -=JIbr +bVtIZ+JHTbuH+tg0EoRNcCbzuQINBGd9W+0BEADBFjNINSiiMRO6vCSu0G5SqJu/ +vjWJ/dhN7Lh791sas64UU/bWDQ0mqDms0D/oWjQNgapHRXAexuIynbStlSxXO0Qa +XEdq50BCVoKXj9Nwx63WWBXaR/cwAaBbKLYGUSsMEzqMXZul7VfuOyxGPcgHnz67 +dYDyUOIdUisFiBUkTwoUNXE4Qc9kA9i2jwBrY1s6+vtMX9J5uMUw78mtBG3U6TDr +7cgwlKe6nuNbt+EXpRsaKNPq5qC/9HEyRgq9i98Voo5b1gjC4adnYFZ70SKb6PrT +kkpf6b0wi4BNJxYzUBWzYdw9UKPwB4RM9zM20PSWxMuzBfn4sPN2FC0SjdZGeu92 +dZ4NcCwNJuPhFq4fz6TD6da2mEE9H0qlJIhgaNuTHyI3YXgLk4FH/+GhylO74uMh +cMa/A1nCq8Yr+4OscWxbyN6fv8Jsg2y1wQYdnIqsEH1vx99k5Xy/nF6rWqQfdy9c +UeCD00bzJyFSQQPieiP45asekajwAXph7nRby9rACbvdZUIy+RsRJoFTS+5flChr +MvofJoOEqJ58NzCNXNSq77yISZZE6aogqgp2hgQY2UFpLoslSUqvFSx6ti8ZViXf +Z7e9zKTi4I+/cpQ+RuzkBFYBgW7ysKnUWLyopPFE2GLu7E6JTRVTTL0KAiCca6KT +v8ZNe6itGuC7WmfKFQARAQABiQRyBBgBCgAmFiEE60wb/U8EL23dzOyRdyH2O9OL +R5YFAmd9W+0CGwIFCQWjmoACQAkQdyH2O9OLR5bBdCAEGQEKAB0WIQQOIlkXQUZw +9EQsJQ39UzwHwmRkjwUCZ31b7QAKCRD9UzwHwmRkj6YZD/4h1o52LhFwu7is7fs7 +7Ko5BpBpF1QKV4GRpvYdf7o5Wm9BSvvVQNSZVbs6sPUgWLsFMJBl9E1VQgnOSgMQ +2urGB9iIIHAvnTeGYwjIlKyZRBzVROn+xY4OfUk0nK/o1jnJCpz+adseMZh9JGV/ +65GfvdJX54j1L1bf4OWrp6BEA77TDmQZ9zqYMeMzlsaiuLxjLRdW4RVInjLYOQdx +OY5TXjcJpA2FdzBxrvqDGMtUxTANzkLkzs+XXg/OsRO94SvR0NwwaBEzyLs5WFz9 +KqELMFSgSOM+x40S5nwUGoFwl4/uuCxFGrpgGZVlld888WZwJOJMyb+dfrxEsWjJ +ui5eVRtfDC68792YuBM+ATK+zo2wJ8X3IK7CEw5cK8HgmAu0avX1sOVEspPd4dJD +SfAFU+ghtmufy7As7X1uI5IOyxQ1lpDCEqDf6wmkdrCX78tmoo2d98gFlJxKVmRu +vvPNdWABXZ/YNW57lix8fWe6vFY2pcyYVRXvX/DIcJNiu+uFVC+6ZzTWMZeCo9KE +wKlVRg2aDFhwnBO58ahm845/B/7p02NL7SuZPAT8rlLdA7XpfH7KY5Q5eaOVW3gU +KOnBQRM2Unea22r15rYsYS+whiqglmh2yejmE2vOVteJ3VJkSeaj3S3GGpHZdelI +/w6xbihzj67pYAG7PoZoJtav52HYD/91FDIGqsVOnn7IlotzN6c/Z07tJnCPJKSc +736L+1iDYyy7tvslUckW0vfOO92a+ikuPQRajlzUAZrWZe+23M+bIX4T8aCi3fGC +VWsr5wUK4wiBNQgAr5iQWRg2UjWNLxGuBvp+lk9w8BGp+qZWd/8TOrOHGmXz+N2W +ZBIrtTNbL0LYMxffBxcQIV+aC8jD8MfEetV9F7SsZo1Wza0wcEXyX/xUQ5pr+aks +aDtoNYKWwnJtlRqBgb6A8LPeRrzxTZVlHrOMUDHJSKNNSbspyRi8jmhJtfU17uE9 ++rpQkzv29ZRiDi4vtub6RSpcAaw+squMq7fNberxr7SNaWa7dVnJu4XHvAhS6838 +6Ng9vMhzyLE9GLyuwJ8FCv0jCiFdRFDayyEYZ0zAZz/gWjhdB8XAGJ5US0sEnD8d +qQE4JR5iLzXEZArHyGUDl45/JbxV7O5Z5D+SlBef/nHLCY/JBHc3LGGnM0Ht8GNj +d+om6kTznz3lZjxQCj0LFHYMeO3ADyk5uj8SKe9yMXHhl25Dlye1tZalTyosEIdP +UZMFqTLSQNh0nW5iJ8QYhO9bSaksUKadhHzVzoFk067OOpZLlt/SO3a9DTgBqJnm +jZzrnsTJpU2ctkX++wX6M0WSGfkQGJWbuf1tRHdl+IkfIu+kBE+iAhZoMQAysweF +p6XgWgagK7kCDQRpsHinARAAtf8XGrdD7k8bRRhCCjjJUGkGZdzSZLyQRQtQDGNP +ofM0LQ9xb03qMXN+qCPgQtNe3FwESEkonjICP+E9en32IYo9QoV9662h91MsQYpi +vlm2G/Ink2BxTJpmKwFZQwcoZ4Eq1wP5KWn2VL1qpWnyf/82/lPqEnc/xXHtks5o +YwNiRf5B/VPz+/IzzYayIxRmxaWtBVT6MAeDkEcZiZCGIXewaV2jC745ST0MsOLt +78pXFHuV3PlnaU+JzQO9gJFIgoyrXAKKkYAqtYuXUQfIZpsioor/WMrPnJ5v2miz +ygFHYzxh4ZVqOyeQu30TNlToJ/0As4cXEdBcMsdo4ZWqLRpavoN8k5wxNHiq5Xo7 +gyVvT4x2pQ4Cdc40NMS9fwx/re9aUMK+MkYX0n2nlfgMiyZUaswS0hwVXCWBwqT9 +1qzUh6JStncd6voLsAoKjpnDFelnDTUUOXqV2/CfLeeZSgdOF5jejJcqIzFd1mbN +Ui7QR+/2EBRjTvCruzA6M73SJGcnFciDVO70Z8+bTIqZNObmy2ARm6flKMsgbIN4 +e7QROdPXrEGKxRsLCEMbimGG5DYXNZPxDkt5TpTi61topkkmxKhRIAnUA1nhw+5P +aHvGxGwbqjEeRDQJLiAqE3BHh0hDCLqJbTnWqww4zSju/r8ICIOBT7W4sqBH0zVf +qscAEQEAAYkEcgQYAQoAJhYhBOtMG/1PBC9t3czskXch9jvTi0eWBQJpsHinAhsC +BQkFo5qAAkAJEHch9jvTi0eWwXQgBBkBCgAdFiEEuNvpzK8hFvhKCEvWHQnAFQBv +6rgFAmmweKcACgkQHQnAFQBv6rh54BAAo9VvH6LxBwbzUg1HQSIg/YMel80nMQzA +I3jfIPRTSC5CHcH0zfZpx6tLjU0eBD8E17jjp7NBE/cMDOGh4ocyyZTvG+rN9jtz +jk5Hd+4U+jxXF1VcYhYvKDNK2Y0BnLhcy+krXuOudP+r6CQqCMrMd70s2appU2w3 +p+p5wsCTSZV7WvxHHe6tSRUgzQz7e5CapwV0j/SQQYNJuX9konLGT6gs1Due54+U +xlBZ6BtfdTgMC7Ln7a7xntGG533oDd8J+LM+26O+Mzu/tFEZekwQqlewjT2I6N9N +0x/5u7cNMonWjiUMZZkEuts2ugjzktRviRvbDvhdIyje6+4uHicTF7pBUuLcRw8t +6onHrsjddE3I+rWw6jkm+5R5gLiriApKSzpRnSdA94GN3OCpmWjkO/XJTrmKT2/O +j6rrCyxnrfs+AQgfoev7f0B3F3UnRDQfYO3WhMYzgZ4CjVSpGyevsq5cAPYXkvyl +RH15wdJ43EToUwYheg0fvwexH41gkjbA+f1+XK1Ll5guspnUhlMTXni+pFTTFlhj +WF7lVnjcG8Ye66ymwIlMucShFssWlfCgFWh8lJx0ZYjNLrcYm1qGPH3w4c4RUH5E +YmXeb5zsREvRMaqEYTeDIWI4xvg/KsI66olxYn9fcwzuQrCmdVrzTn9LJw8C4d6U +LsuXrfChv0Cc/g//cIc2n6IuudMs7PI2f4YX0aN9HHVc/wDgS13sfJJWuXFwIttU +upMiKeiQ7083UKL84/1KhvEVFKQHpYeHS5+LpXH31F+JIVt0lJjhRuU1I5PcRE9W +uqacfqMlavkmz7q8WF6CpuGQGcHI4nSRfJYcMWHVt8swVPAiiITU+ou2mO2K31ao +p411RcZ/vFrC5BpPSKJpsD8Gvm80iVwZBeRXrzJW6B/83tnHNPsM0fGVojxDgE7i +Wp+Dv89n8BsQ5jIN8evHHe2I/T6Jd5zik7nfJbkzPCDgRPIQn6JesfpOyn6rUXYK +07+1t/yLHtMmyZTJBBFLqoJYOE2u6JoDuzCRYlZfj9Gm/uvVts9WcwMs4ymo5ttU +2+LXnOwKAVWizRmLLpywk348XAd1dEkQ5Tv4iTSKlyIQpRxKq50mFK31W1CjQgGe +M1Ctf3LXScrlVYldo5Wn0PmEfEVDB2E9j94jGsB/dBRYWAMZZe1eXX7oAdhQIedW +xDYjKzy/ZNTFLqIgwAawvxaKOLqm8pCVCa/Hkd8x7PeL/CD4q+XEuhRanIZasbaP +wOSz6cWG1532PsdUEJMr93rjh9vvcZ2Aee4BEH9ly+D/qWUJysuljMlpxQ+mG9n0 +EFRbD9Lhk5tL9ArJlsUZ3Wg/a2N+cNFSkXzUmw0Rj/iUmZcSITcM8QOSK6U= +=CkA1 -----END PGP PUBLIC KEY BLOCK-----