diff --git a/analysis_options.yaml b/analysis_options.yaml index 65d475023..46241ddcb 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -8,6 +8,7 @@ analyzer: strict-raw-types: true exclude: - doc/tutorials/chapter_9/rohd_vf_example + - packages/rohd_hierarchy - rohd_devtools_extension # keep up to date, matching https://dart.dev/tools/linter-rules/all @@ -129,7 +130,9 @@ linter: - overridden_fields - package_names - package_prefixed_library_names - - parameter_assignments + # parameter_assignments - disabled; ROHD idiomatically reassigns + # constructor parameters via addInput/addOutput. + # - parameter_assignments - prefer_adjacent_string_concatenation - prefer_asserts_in_initializer_lists - prefer_asserts_with_message diff --git a/lib/src/module.dart b/lib/src/module.dart index 0fd51eac7..92fc410e0 100644 --- a/lib/src/module.dart +++ b/lib/src/module.dart @@ -667,7 +667,6 @@ abstract class Module { } if (source is LogicStructure) { - // ignore: parameter_assignments source = source.packed; } @@ -704,7 +703,6 @@ abstract class Module { String name, LogicType source) { _checkForSafePortName(name); - // ignore: parameter_assignments source = _validateType(source, isOutput: false, name: name); if (source.isNet || (source is LogicStructure && source.hasNets)) { @@ -813,7 +811,6 @@ abstract class Module { throw PortTypeException(source, 'Typed inOuts must be nets.'); } - // ignore: parameter_assignments source = _validateType(source, isOutput: false, name: name); _inOutDrivers.add(source); diff --git a/lib/src/signals/logic.dart b/lib/src/signals/logic.dart index 88afba0d6..4c5f99e5e 100644 --- a/lib/src/signals/logic.dart +++ b/lib/src/signals/logic.dart @@ -377,7 +377,6 @@ class Logic { // If we are connecting a `LogicStructure` to this simple `Logic`, // then pack it first. if (other is LogicStructure) { - // ignore: parameter_assignments other = other.packed; } diff --git a/lib/src/signals/wire_net.dart b/lib/src/signals/wire_net.dart index 78e8b1beb..f93529b0f 100644 --- a/lib/src/signals/wire_net.dart +++ b/lib/src/signals/wire_net.dart @@ -189,7 +189,6 @@ class _WireNetBlasted extends _Wire implements _WireNet { other as _WireNet; if (other is! _WireNetBlasted) { - // ignore: parameter_assignments other = other.toBlasted(); } diff --git a/lib/src/utilities/simcompare.dart b/lib/src/utilities/simcompare.dart index 3a25f4074..d7850df4e 100644 --- a/lib/src/utilities/simcompare.dart +++ b/lib/src/utilities/simcompare.dart @@ -282,7 +282,6 @@ abstract class SimCompare { : 'logic'); if (adjust != null) { - // ignore: parameter_assignments signalName = adjust(signalName); } diff --git a/lib/src/values/logic_value.dart b/lib/src/values/logic_value.dart index 0cdc3c1df..81fc7304b 100644 --- a/lib/src/values/logic_value.dart +++ b/lib/src/values/logic_value.dart @@ -218,7 +218,6 @@ abstract class LogicValue implements Comparable { if (val.width == 1 && (!val.isValid || fill)) { if (!val.isValid) { - // ignore: parameter_assignments width ??= 1; } if (width == null) { @@ -243,7 +242,6 @@ abstract class LogicValue implements Comparable { if (val.length == 1 && (val == 'x' || val == 'z' || fill)) { if (val == 'x' || val == 'z') { - // ignore: parameter_assignments width ??= 1; } if (width == null) { @@ -269,7 +267,6 @@ abstract class LogicValue implements Comparable { if (val.length == 1 && (val.first == LogicValue.x || val.first == LogicValue.z || fill)) { if (!val.first.isValid) { - // ignore: parameter_assignments width ??= 1; } if (width == null) { diff --git a/packages/rohd_hierarchy/README.md b/packages/rohd_hierarchy/README.md new file mode 100644 index 000000000..54f49101d --- /dev/null +++ b/packages/rohd_hierarchy/README.md @@ -0,0 +1,205 @@ +# 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, instances, ports, 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: + +- **Modules** — the reusable definitions (e.g. `Counter`, `ALU`). +- **Instances** — placed copies of modules within a parent. +- **Ports** — directional signals on a module boundary (input, output, inout). +- **Signals** — internal wires and registers. + +The full "unfolded" view of a design is its **address space**: every instance, +every port, 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 module**: a module'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 module'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 + +- **`HierarchyNode`** — A tree node representing a module or instance, + with children, signals, name, kind, and a primitive flag. Call + `buildAddresses()` to assign a canonical `HierarchyAddress` to every + node and signal in O(n). +- **`HierarchyAddress`** — 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. +- **`Signal` / `Port`** — Signal metadata: name, width, type, direction, + full path. `Port` extends `Signal` with a required direction. + +### Services & adapters + +- **`HierarchyService`** — A mixin providing tree-walking search and + navigation: `searchSignals()`, `searchModules()`, + `autocompletePaths()`, glob-star regex search, and address↔pathname + conversion. +- **`BaseHierarchyAdapter`** — An abstract class wrapping a + `HierarchyNode` tree with `HierarchyService`. Use + `BaseHierarchyAdapter.fromTree()` to wrap an existing tree. +- **`NetlistHierarchyAdapter`** — A concrete adapter that parses Yosys + JSON netlists into a `HierarchyNode` tree. + +### 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 `forModules()` cover the common + cases. + +## Usage + +### Building a dictionary from a Yosys netlist + +```dart +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; + +final dict = NetlistHierarchyAdapter.fromJson(yosysJsonString); +final root = dict.root; // the top-level dictionary table +``` + +### Wrapping an existing tree + +When you already have a `HierarchyNode` 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.nodeByAddress(HierarchyAddress.fromDotString(wire)); +final pathname = dict.addressToPathname(addr!); +``` + +### Searching the dictionary + +```dart +final signals = dict.searchSignals('clk'); +final modules = dict.searchModules('counter'); +final completions = dict.autocompletePaths('top.cpu.'); +``` + +### Constructing nodes manually + +```dart +final root = HierarchyNode( + id: 'Counter', + name: 'Counter', + kind: HierarchyKind.module, + type: 'Counter', + signals: [ + Port( + id: 'Counter.clk', name: 'clk', direction: 'input', + width: 1, type: 'bin', fullPath: 'Counter.clk', scopeId: 'Counter', + ), + Port( + id: 'Counter.count', name: 'count', direction: 'output', + width: 8, type: 'bin', fullPath: 'Counter.count', scopeId: 'Counter', + ), + ], + children: [ + HierarchyNode( + id: 'Counter.adder', name: 'adder', + kind: HierarchyKind.instance, type: 'Adder', + signals: [], children: [], + ), + ], +); +``` + +### Enriching signals from another source + +Signals from one source (e.g. a VCD file) can be upgraded with metadata +from the design dictionary: + +```dart +final designSignal = dict.signalByAddress(addr); +if (designSignal is Port) { + node.signals[i] = Port( + id: vcdSignal.id, name: vcdSignal.name, + type: vcdSignal.type, width: vcdSignal.width, + direction: designSignal.direction!, + fullPath: vcdSignal.fullPath, scopeId: vcdSignal.scopeId, + ); +} +``` + +## Design principles + +| Principle | How it is achieved | +|---|---| +| **Source-agnostic** | The data model is independent of any HDL toolchain. `NetlistHierarchyAdapter` handles Yosys JSON; `BaseHierarchyAdapter.fromTree()` wraps any tree. | +| **Incremental** | Addresses are relative within each module. A remote agent expands only the subtrees it needs, one dictionary table at a time. | +| **Compact** | `HierarchyAddress` 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 module'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..140570d16 --- /dev/null +++ b/packages/rohd_hierarchy/analysis_options.yaml @@ -0,0 +1,5 @@ +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true diff --git a/packages/rohd_hierarchy/lib/rohd_hierarchy.dart b/packages/rohd_hierarchy/lib/rohd_hierarchy.dart new file mode 100644 index 000000000..febb88f4d --- /dev/null +++ b/packages/rohd_hierarchy/lib/rohd_hierarchy.dart @@ -0,0 +1,52 @@ +// 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 +/// - [HierarchyAddress] - Efficient index-based addressing for tree navigation +/// - [HierarchyNode] - A node in the module hierarchy (module or instance) +/// - [HierarchyKind] - Enum for node types (module, instance) +/// - [Signal] - A signal in the hierarchy (wire, reg, port) +/// - [Port] - An I/O port on a module (Signal subclass) +/// +/// ## Search & Navigation +/// - [SignalSearchResult] - Result of a signal search with enriched metadata +/// - [ModuleSearchResult] - Result of a module search with enriched 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 format (Yosys JSON / ROHD) +/// +/// 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 = HierarchyNode(id: 'top', name: 'top', +/// kind: HierarchyKind.module); +/// 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_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..6ec04cd68 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/base_hierarchy_adapter.dart @@ -0,0 +1,79 @@ +// 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 [HierarchyNode] tree rooted at [root] is the single source of truth. +/// Children and signals are read directly from each node's +/// [HierarchyNode.children] and [HierarchyNode.signals] lists. +/// Lookups use [HierarchyAddress]-based navigation. +/// +/// Concrete adapters should: +/// 1. Extend this class +/// 2. Build a complete [HierarchyNode] tree (with children and signals +/// populated on each node) +/// 3. Set the [root] node +/// +/// Search, autocomplete, and signal lookup are implemented by +/// [HierarchyService] via recursive tree walking. +abstract class BaseHierarchyAdapter with HierarchyService { + HierarchyNode? _root; + + /// Creates a [BaseHierarchyAdapter]. + BaseHierarchyAdapter(); + + /// Creates an adapter wrapping an existing [HierarchyNode] tree. + /// + /// The tree itself is the single source of truth — children and signals + /// are read directly from the [HierarchyNode] lists. + /// + /// Example usage: + /// ```dart + /// final treeRoot = await dataSource.evalModuleTree(); + /// final service = BaseHierarchyAdapter.fromTree(treeRoot); + /// final children = service.children(service.root.id); + /// ``` + factory BaseHierarchyAdapter.fromTree( + HierarchyNode rootNode, + ) = _TreeBackedAdapter; + + /// Sets the root node. Call this once during initialisation. + set root(HierarchyNode node) { + _root = node; + } + + // ───────────────────────────────────────────────────────────────────────── + // HierarchyService concrete accessors — all tree-walking, no flat maps + // ───────────────────────────────────────────────────────────────────────── + + @override + HierarchyNode get root { + if (_root == null) { + throw StateError( + 'Root node not set. Call setRoot() during initialization.'); + } + return _root!; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tree-backed implementation returned by BaseHierarchyAdapter.fromTree() +// ───────────────────────────────────────────────────────────────────────────── + +/// Private adapter that wraps an existing [HierarchyNode] tree. +/// +/// Children and signals are read directly from the tree nodes. +class _TreeBackedAdapter extends BaseHierarchyAdapter { + _TreeBackedAdapter(HierarchyNode rootNode) { + root = rootNode; + } +} 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..606d545f8 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_models.dart @@ -0,0 +1,576 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_models.dart +// Generic hierarchy data models for source-agnostic navigation. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; + +/// The kind of node in the hardware hierarchy. +enum HierarchyKind { + /// A module definition in the hierarchy. + module, + + /// An instance of a module in the hierarchy. + instance, +} + +/// Efficient hierarchical address using indices instead of strings. +/// +/// Format: [moduleIndex0, moduleIndex1, ..., signalIndex] or [] for root. +/// Example: [0, 2, 4] means root's 0th child, then 2nd child of that, then 4th +/// signal. +/// +/// 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 scopeId lookups with typed, semantic addressing. +@immutable +class HierarchyAddress { + /// Path through tree as indices stored as immutable list. + /// Empty list represents root node. + /// Non-empty list: all but last are module indices, last is signal index. + final List path; + + /// Create a hierarchy address from a path list. + const HierarchyAddress(this.path); + + /// Root address (empty path). + static const HierarchyAddress root = HierarchyAddress([]); + + /// Create a child address by appending module index. + /// Use this when navigating to a child module. + HierarchyAddress child(int moduleIndex) => + HierarchyAddress([...path, moduleIndex]); + + /// Create a signal address by appending signal index. + /// Use this when addressing a signal within current module. + HierarchyAddress signal(int signalIndex) => + HierarchyAddress([...path, signalIndex]); + + /// Serialize to a dot-separated string suitable for use as a JSON key. + /// + /// Examples: `""` (root), `"0"`, `"0.2.4"`. + /// Round-trips with [HierarchyAddress.fromDotString]. + String toDotString() => path.join('.'); + + /// Deserialize from a dot-separated string produced by [toDotString]. + /// + /// An empty string returns [root]. + factory HierarchyAddress.fromDotString(String s) { + if (s.isEmpty) { + return root; + } + return HierarchyAddress(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 HierarchyAddress && + 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 [HierarchyAddress] by walking [root]. + /// + /// Supports both `/` and `.` as separators. If the first segment + /// matches [root]'s name (case-insensitive), it is skipped — the root + /// node is always at the empty address. + /// + /// The last segment is first tried as a **signal** name within the + /// current module; if that fails it is tried as a **child** module 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 = HierarchyAddress.tryFromPathname('Top/cpu/clk', root); + /// if (addr != null) { + /// final signal = service.signalByAddress(addr); + /// } + /// ``` + static HierarchyAddress? tryFromPathname( + String pathname, + HierarchyNode root, + ) { + final rootAddr = root.address ?? HierarchyAddress.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.toLowerCase() == root.name.toLowerCase() + ? parts.skip(1) + : parts; + + ({HierarchyNode node, HierarchyAddress addr})? step( + ({HierarchyNode node, HierarchyAddress 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<({HierarchyNode node, HierarchyAddress addr})?>( + (node: root, addr: rootAddr), step)?.addr; + } +} + +/// A generic node representing a module or instance in the hierarchy. +/// +/// This is the core structural data model, independent of waveform data. +class HierarchyNode { + /// Unique identifier for this node. + final String id; + + /// Display name of this module or instance. + final String name; + + /// Whether this node is a module or an instance. + final HierarchyKind kind; + + /// Optional definition/type name for instances. + final String? type; + + /// Identifier of this node's parent, or null for root. + final String? parentId; + + /// Whether this node 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 node. The netlist adapter + /// sets this for cells that lack a module definition in the JSON or whose + /// type starts with `$` (Yosys built-in primitives). Tool-specific + /// primitives (e.g. ROHD's FlipFlop → `$dff`) are handled by the + /// synthesizer mapping them to `$`-prefixed types before the JSON is + /// written. + final bool isPrimitive; + + /// Signals within this module (includes both internal signals and ports). + /// Empty for instances. + final List signals; + + /// Child modules/instances. Populated from sub-modules in the hierarchy. + final List children; + + /// Hierarchical address for this node. + /// Assigned by [buildAddresses] to enable efficient navigation. + /// Format: [child0, child1, ..., childN] for nested modules. + HierarchyAddress? address; + + /// Creates a [HierarchyNode] with the given properties. + HierarchyNode({ + required this.id, + required this.name, + required this.kind, + this.type, + this.parentId, + this.isPrimitive = false, + List? signals, + List? children, + this.address, + }) : signals = signals ?? [], + children = children ?? []; + + /// Returns only signals that are ports (have a direction). + /// Returns Port instances for type safety. + List get ports => signals.whereType().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-insensitive. + /// O(1) after first call (lazily builds index). + int childIndexByName(String name) { + _childNameIndex ??= { + for (var i = 0; i < children.length; i++) + children[i].name.toLowerCase(): i, + }; + return _childNameIndex![name.toLowerCase()] ?? -1; + } + + /// Return the offset (index) of the signal with [name] in [signals], + /// or -1 if not found. Case-insensitive. + /// O(1) after first call (lazily builds index). + int signalIndexByName(String name) { + _signalNameIndex ??= { + for (var i = 0; i < signals.length; i++) signals[i].name.toLowerCase(): i, + }; + return _signalNameIndex![name.toLowerCase()] ?? -1; + } + + /// Whether [cellType] represents a Yosys built-in primitive cell type. + /// + /// Returns `true` for `$`-prefixed types (`$mux`, `$dff`, `$and`, etc.) + /// which are Yosys 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 node at construction time. + /// + /// Use this before a [HierarchyNode] exists (e.g. when deciding whether + /// to recurse into a Yosys cell definition). For an existing node, use + /// the instance getter [isPrimitiveCell] instead. + static bool isPrimitiveType(String cellType) => cellType.startsWith(r'$'); + + /// Whether this node represents a primitive cell that should be hidden + /// from the module tree. + /// + /// Checks the [isPrimitive] field (set by the adapter at construction + /// time) and falls back to [isPrimitiveType] on the node's [type]. + bool get isPrimitiveCell => + isPrimitive || (type != null && isPrimitiveType(type!)); + + /// 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(); + + /// Collect all signals under this node in canonical depth-first order. + /// + /// The traversal visits this node's [signals] first, then recurses into + /// [children] in order. This is the **single source of truth** for the + /// DFS signal ordering used by compact waveform transport — all + /// producers and consumers must use this method (or the equivalent + /// [HierarchyAddress]-based traversal) to ensure their keys agree. + /// + /// Each signal's [HierarchyAddress] (assigned by [buildAddresses]) is + /// used as the canonical key in compact JSON dictionaries (via + /// [HierarchyAddress.toDotString]). The flat iteration order here + /// matches the address assignment order. + List depthFirstSignals() => + [...signals, ...children.expand((c) => c.depthFirstSignals())]; + + /// Build hierarchical addresses for this node and all descendants. + /// + /// This performs a single O(n) tree traversal to assign [HierarchyAddress] + /// to every node and signal in the tree. Call this once after tree + /// construction to enable efficient address-based navigation. + /// + /// Example: + /// ```dart + /// root.buildAddresses(); // Assign addresses to all nodes/signals + /// final signalAddr = signals[0].address; // Now available + /// ``` + void buildAddresses([HierarchyAddress startAddr = HierarchyAddress.root]) { + address = startAddr; + for (final (i, s) in signals.indexed) { + s.address = startAddr.signal(i); + } + for (final (i, c) in children.indexed) { + c.buildAddresses(startAddr.child(i)); + } + } +} + +/// A signal in the hardware hierarchy. +/// +/// Signals are the fundamental data carriers in hardware. A signal can be: +/// - An internal wire/register within a module +/// - A port on a module interface (has direction: input/output/inout) +/// +/// This is a structural model without waveform data. Use rohd_waveform +/// to access waveform data for a signal by its ID. +class Signal { + /// Local identifier for this signal (typically the bare signal name). + /// + /// For display and local lookups within a module. + /// Not guaranteed unique across + /// the full hierarchy — use [hierarchyPath] for unique keying. + final String id; + + /// The name of the signal. + final String name; + + /// Type of the signal (e.g., "wire", "reg", "logic", "input"). + final String type; + + /// The bit width of the signal. + final int width; + + /// Full hierarchical path using '/' separator (e.g., "top/counter/clk"). + /// + /// This is the canonical unique key for this signal across the hierarchy. + /// Always set in production code; may be null in test fixtures. + final String? fullPath; + + /// ID of the scope (module) containing this signal. + final String? scopeId; + + /// Direction of the signal if it's a port. + /// Null for internal signals (wires, registers). + /// "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; + + /// Hierarchical address for this signal. + /// Assigned by [HierarchyNode.buildAddresses] to enable efficient navigation. + /// Format: [...moduleIndices, signalIndex] + HierarchyAddress? address; + + /// Creates a [Signal] with the given properties. + Signal({ + required this.id, + required this.name, + required this.type, + required this.width, + this.fullPath, + this.scopeId, + this.direction, + this.value, + this.isComputed = false, + this.address, + }); + + /// The unique hierarchical path for this signal. + /// + /// Returns [fullPath] when available (production), falls back to [id]. + /// Use this as the canonical key for caches, save/load, and waveform lookup. + String get hierarchyPath => fullPath ?? id; + + /// 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 ($type, width=$width${isPort ? ', $direction' : ''})'; +} + +/// A port is a signal on a module interface with a direction. +/// +/// This is a convenience typedef/factory for creating port signals. +/// Use [Signal] directly with a non-null direction, or use this +/// factory for clarity. +class Port extends Signal { + /// Creates a [Port] with the given properties and direction. + Port({ + required super.id, + required super.name, + required super.type, + required super.width, + required String direction, + super.fullPath, + super.scopeId, + super.isComputed, + }) : super(direction: direction); + + /// Creates a Port with minimal parameters. + factory Port.simple({ + required String name, + required String direction, + int width = 1, + String? id, + String type = 'wire', + String? fullPath, + String? scopeId, + bool isComputed = false, + }) => + Port( + id: id ?? name, + name: name, + type: type, + width: width, + direction: direction, + fullPath: fullPath, + scopeId: scopeId, + isComputed: isComputed, + ); +} + +/// Result of a signal search with enriched metadata. +/// +/// Contains the signal's full path, parsed path segments, and the full +/// [Signal] 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 { + /// The full hierarchical path (signal ID) that was found. + /// Example: "Top/counter/clk" + final String signalId; + + /// The hierarchical path segments. + /// Example: ["Top", "counter", "clk"] + final List path; + + /// The underlying [Signal] from the hierarchy service (if available). + /// Contains width, direction, type, and other signal metadata. + final Signal? signal; + + /// Creates a signal search result. + const SignalSearchResult({ + required this.signalId, + required this.path, + this.signal, + }); + + /// The signal name (last path segment). + String get name => path.isNotEmpty ? path.last : signalId; + + // ───────────────────── 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 [signalId]. + String get displayPath => displaySegments.join('/'); + + /// Path segments with the top-level module name stripped. + /// + /// For `["Top", "counter", "clk"]` this returns `["counter", "clk"]`. + List get displaySegments => path.length > 1 ? path.sublist(1) : path; + + /// Instance names that need to be expanded to reveal this signal. + /// + /// These are the intermediate path segments between the top module + /// and the signal name — i.e. everything except the first (top module) + /// and last (signal name) segments. + /// + /// For `Top/sub1/sub2/clk` this returns `["sub1", "sub2"]`. + List get intermediateInstanceNames => + path.length > 2 ? path.sublist(1, path.length - 1) : const []; + + /// Normalize a user query for hierarchy search. + /// + /// Converts common separators (`.`) to the canonical `/` separator. + static String normalizeQuery(String query) => query.replaceAll('.', '/'); + + @override + String toString() => + 'SignalSearchResult($signalId, width=${signal?.width ?? "?"})'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SignalSearchResult && signalId == other.signalId; + + @override + int get hashCode => signalId.hashCode; +} + +/// Result of a module/node search with enriched metadata. +/// +/// Contains the module's full path, parsed path segments, and the full +/// [HierarchyNode] object. This mirrors [SignalSearchResult] for modules +/// and provides a consistent search results interface. +@immutable +class ModuleSearchResult { + /// The full hierarchical path (node ID) that was found. + /// Example: "Top/CPU/ALU" + final String moduleId; + + /// The hierarchical path segments. + /// Example: ["Top", "CPU", "ALU"] + final List path; + + /// The underlying [HierarchyNode] from the hierarchy service. + /// Contains the node's name, kind, type, children, and signals. + final HierarchyNode node; + + /// Creates a module search result. + const ModuleSearchResult({ + required this.moduleId, + required this.path, + required this.node, + }); + + /// The module name (last path segment). + String get name => path.isNotEmpty ? path.last : moduleId; + + /// The kind of this node (module or instance). + HierarchyKind get kind => node.kind; + + /// Whether this is a module definition. + bool get isModule => node.kind == HierarchyKind.module; + + /// Number of direct children (sub-modules/instances). + int get childCount => node.children.length; + + // ───────────────────── Display helpers ───────────────────── + + /// Display path with the top-level module name stripped. + /// + /// For `Top/CPU/ALU` this returns `CPU/ALU`. + /// For a single-segment path returns the original [moduleId]. + String get displayPath => displaySegments.join('/'); + + /// Path segments with the top-level module name stripped. + /// + /// For `["Top", "CPU", "ALU"]` returns `["CPU", "ALU"]`. + List get displaySegments => path.length > 1 ? path.sublist(1) : path; + + /// Normalize a user query for module search. + /// + /// Converts common separators (`.`) to the canonical `/` separator. + static String normalizeQuery(String query) => query.replaceAll('.', '/'); + + @override + String toString() => 'ModuleSearchResult($moduleId, kind=${kind.name})'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ModuleSearchResult && moduleId == other.moduleId; + + @override + int get hashCode => moduleId.hashCode; +} 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..6b75dfd6b --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_search_controller.dart @@ -0,0 +1,219 @@ +// 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 [ModuleSearchResult]. +/// +/// ```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 + : SignalSearchResult.normalizeQuery(q), + ); + + /// Create a controller for **module** 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.forModules( + HierarchyService hierarchy, + ) => + HierarchySearchController( + searchFn: (q) => hierarchy.searchModules(q) as List, + normalizeFn: (q) => HierarchyService.hasRegexChars(q) + ? q + : ModuleSearchResult.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 + /// [ModuleSearchResult] 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 SignalSearchResult) { + return result.displayPath; + } + if (result is ModuleSearchResult) { + 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_service.dart b/packages/rohd_hierarchy/lib/src/hierarchy_service.dart new file mode 100644 index 000000000..f812a2c5c --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_service.dart @@ -0,0 +1,865 @@ +// 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 [HierarchyNode] tree. +/// Nodes hold their [HierarchyNode.name], [HierarchyNode.children], and +/// [HierarchyNode.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 +/// - [searchModules] — find modules/instances by name +/// - [searchNodes] — find modules/instances, returning [HierarchyNode] objects +/// - [autocompletePaths] — incremental path completion +abstract mixin class HierarchyService { + /// The root node for the hierarchy. + HierarchyNode get root; + + /// Maximum number of results returned by search methods when no explicit + /// `limit` is provided. + static const int _defaultSearchLimit = 100; + + // ───────────── Address-based node/signal lookup ────────────────── + + /// Find a node by its [HierarchyAddress]. O(depth). + HierarchyNode? nodeByAddress(HierarchyAddress address) => + address.path.fold( + root, + (node, idx) => node != null && idx >= 0 && idx < node.children.length + ? node.children[idx] + : null); + + /// Find a signal by its [HierarchyAddress]. + /// + /// The parent portion of [address] navigates to the owning module; + /// the last index selects the signal within that module. O(depth). + Signal? signalByAddress(HierarchyAddress address) { + if (address.path.isEmpty) { + return null; + } + final node = nodeByAddress( + HierarchyAddress(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 + /// [HierarchyAddress] by walking the tree. + /// + /// Delegates to [HierarchyAddress.tryFromPathname]. + HierarchyAddress? pathnameToAddress(String pathname) => + HierarchyAddress.tryFromPathname(pathname, root); + + /// Resolve a `/`-separated pathname to a [HierarchyNode]. + /// + /// Convenience that composes [pathnameToAddress] and [nodeByAddress]. + /// Returns `null` when [pathname] does not match any node in the tree. + HierarchyNode? nodeByPathname(String pathname) { + final addr = pathnameToAddress(pathname); + return addr == null ? null : nodeByAddress(addr); + } + + /// Convert a [HierarchyAddress] 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 module. For pure module 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(HierarchyAddress 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, HierarchyNode 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 [HierarchyAddress]. + /// + /// Normalises `.` → `/` then delegates to [pathnameToAddress]. + HierarchyAddress? 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 a [node] or any of its descendants match [searchTerm]. + /// + /// The search term is split on `/` or `.` into hierarchical segments. + /// Each segment is matched case-insensitively via substring containment + /// against node names at successive depths. + /// + /// Returns `true` if [searchTerm] is null/empty, or if the node (or a + /// descendant) matches all segments in order. + /// + /// This is useful for tree-view filtering: show a node only when it or + /// one of its descendants matches the user's query. + static bool isNodeMatching(HierarchyNode node, String? searchTerm) { + if (searchTerm == null || searchTerm.isEmpty) { + return true; + } + + final normalizedQuery = searchTerm.replaceAll('.', '/'); + final queryParts = normalizedQuery + .toLowerCase() + .split('/') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + + return _isNodeMatchingRecursive(node, queryParts, 0); + } + + static bool _isNodeMatchingRecursive( + HierarchyNode node, List queryParts, int queryIdx) { + if (queryIdx >= queryParts.length) { + return true; + } + + final currentQueryPart = queryParts[queryIdx]; + final nodeName = node.name.toLowerCase(); + + final matched = nodeName.contains(currentQueryPart); + final nextQueryIdx = matched ? queryIdx + 1 : queryIdx; + + if (nextQueryIdx >= queryParts.length) { + return true; + } + + return node.children.any( + (child) => _isNodeMatchingRecursive(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 module/node paths matching [query]. + /// + /// Similar to [searchSignalPaths] but for modules/instances instead of + /// signals. Walks the tree, matching name segments incrementally. When + /// the query segments match module names at or below the current node + /// the full path is returned (e.g. `Top/CPU/ALU`). + /// + /// Returns up to [limit] results. + List searchNodePaths(String query, {int? limit}) { + final effectiveLimit = limit ?? _defaultSearchLimit; + if (query.trim().isEmpty) { + return const []; + } + final parts = _splitPath(query); + final results = []; + _searchNodePathsRecursive( + root, [root.name], parts, 0, results, effectiveLimit); + return results; + } + + /// Find hierarchy nodes whose path matches [query]. + /// + /// Like [searchNodePaths] but returns the [HierarchyNode] objects + /// themselves instead of path strings. + List searchNodes(String query, {int? limit}) { + final effectiveLimit = limit ?? _defaultSearchLimit; + if (query.trim().isEmpty) { + return const []; + } + final parts = _splitPath(query); + final results = []; + _searchNodesRecursive(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.toLowerCase() == seg) { + continue; + } + final child = current.children + .where((c) => c.name.toLowerCase() == 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.toLowerCase(); + + 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.toLowerCase().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.toLowerCase().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 modules/nodes and return enriched [ModuleSearchResult] objects. + /// + /// Automatically dispatches to [searchModulesRegex] when the query + /// contains glob or regex metacharacters (`*`, `?`, `[`, `(`, `|`, + /// `+`). Otherwise uses [searchNodePaths] for prefix-based matching. + List searchModules(String query, {int? limit}) { + final effectiveLimit = limit ?? _defaultSearchLimit; + if (hasRegexChars(query)) { + final pattern = (query.startsWith('**/') || query.startsWith('*/')) + ? query + : '**/$query'; + return searchModulesRegex(pattern, limit: effectiveLimit); + } + return _toModuleResults(searchNodePaths(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 case-insensitive [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 module/node paths matching a regex [pattern]. + /// + /// Same segment syntax as [searchSignalPathsRegex] but matches module + /// nodes instead of signals. + /// + /// Returns up to [limit] full hierarchical module paths. + List searchNodePathsRegex(String pattern, {int? limit}) { + final effectiveLimit = limit ?? _defaultSearchLimit; + if (pattern.trim().isEmpty) { + return const []; + } + final segments = _splitRegexPattern(pattern); + final compiled = _compileSegments(segments); + final results = []; + _searchNodesRegex(root, [root.name], compiled, 0, results, effectiveLimit); + return results; + } + + /// Search for modules by regex pattern and return enriched results. + List searchModulesRegex(String pattern, {int? limit}) => + _toModuleResults(searchNodePathsRegex(pattern, limit: limit)); + + // ─────────────────── Utility helpers ─────────────────── + + /// Returns the longest common prefix shared by all [paths]. + /// + /// Comparison is case-insensitive. 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].toLowerCase() == s[i].toLowerCase()) + .length; + return j > 0 ? pre.substring(0, j) : null; + }); + return prefix; + } + + // ─────────────────── Private helpers ─────────────────── + + /// Split a query or path on `/` or `.` into non-empty lower-case segments. + static List _splitPath(String input) => input + .replaceAll('.', _hierarchySeparator) + .toLowerCase() + .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 = HierarchyAddress.tryFromPathname(fullPath, root); + return SignalSearchResult( + signalId: fullPath, + path: _splitPathPreserveCase(fullPath), + signal: addr != null ? signalByAddress(addr) : null, + ); + }).toList(); + + /// Enrich module paths into [ModuleSearchResult] objects. + List _toModuleResults(List paths) => + paths.map((fullPath) { + final addr = HierarchyAddress.tryFromPathname(fullPath, root); + return ModuleSearchResult( + moduleId: fullPath, + path: _splitPathPreserveCase(fullPath), + node: (addr != null ? nodeByAddress(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 [HierarchyNode.children] and [HierarchyNode.signals] directly. + void _searchSignalsRecursive( + HierarchyNode 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.toLowerCase(); + 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.toLowerCase().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 module nodes matching query parts. + /// + /// Similar to [_searchSignalsRecursive] but matches module nodes instead + /// of signals. Walks the tree maintaining the path of names. When the + /// query segments match module names, adds them to results. + void _searchNodePathsRecursive( + HierarchyNode 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.toLowerCase(); + 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; + } + _searchNodePathsRecursive( + child, + [...pathSoFar, child.name], + queryParts, + nextIdx, + results, + limit, + ); + } + } + + /// Recursively search for nodes matching query parts, returning the nodes. + void _searchNodesRecursive(HierarchyNode node, List queryParts, + int qIdx, List results, int limit) { + if (results.length >= limit) { + return; + } + + final matched = qIdx < queryParts.length && + node.name.toLowerCase().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) { + _searchNodesRecursive(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\$', caseSensitive: false)); + }).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( + HierarchyNode 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 module/node search driven by compiled regex segments. + void _searchNodesRegex( + HierarchyNode 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; + } + _searchNodesRegex( + 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..b3a58adf7 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/netlist_hierarchy_adapter.dart @@ -0,0 +1,234 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_hierarchy_adapter.dart +// Hierarchy adapter for netlist format (currently 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 Yosys 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 Yosys 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 Yosys output (e.g. `atcb` vs `Atcb`). + factory NetlistHierarchyAdapter.fromJson( + String netlistJson, { + String? rootNameOverride, + }) { + final obj = jsonDecode(netlistJson); + if (obj is! Map) { + throw const FormatException('Invalid Yosys JSON root'); + } + return NetlistHierarchyAdapter.fromMap( + obj, + rootNameOverride: rootNameOverride, + ); + } + + /// Factory to parse a pre-decoded Yosys 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('Yosys 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, + path: resolvedRootName, + parentId: null, + moduleData: modules[topName] as Map, + allModules: modules, + ); + root = rootNode; + } + + /// Parse a module and return the created [HierarchyNode]. + /// The returned node has its [HierarchyNode.children] and + /// [HierarchyNode.signals] lists populated. + HierarchyNode _parseModule({ + required String name, + required String path, + required String? parentId, + required Map moduleData, + required Map allModules, + }) { + // Ports (signals with direction) + final portsData = moduleData['ports'] as Map?; + final signalsList = [ + if (portsData != null) + ...portsData.entries.map((entry) { + final p = entry.value as Map; + final dir = p['direction']?.toString() ?? 'inout'; + final bits = (p['bits'] as List?)?.length ?? 0; + final signalPath = '$path/${entry.key}'; + return Port.simple( + id: signalPath, + name: entry.key, + direction: dir, + width: bits > 0 ? bits : 1, + fullPath: signalPath, + scopeId: path, + ); + }), + ]; + + // Netnames (internal signals without direction). + // Yosys `netnames` contains ALL named wires 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 signalPath = '$path/${entry.key}'; + return Signal( + id: signalPath, + name: entry.key, + type: 'wire', + width: bits > 0 ? bits : 1, + fullPath: signalPath, + scopeId: path, + isComputed: isComputed, + ); + })); + } + + // 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) && + !HierarchyNode.isPrimitiveType(cellType)) { + final childPath = '$path/$cellName'; + final childNode = _parseModule( + name: cellName, + path: childPath, + parentId: path, + moduleData: allModules[cellType] as Map, + allModules: allModules, + ); + childNodes.add(childNode); + } else { + // Primitive cell — create leaf node. + // Extract port signals from `port_directions` when available so + // that primitive I/O appears in signal search results. + final instId = '$path/$cellName'; + 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.map((pEntry) { + final pName = pEntry.key; + final pDir = pEntry.value.toString(); + final bits = (connections?[pName] as List?)?.length ?? + (portWidths?[pName] as int?) ?? + 1; + final signalFullPath = '$instId/$pName'; + return Port.simple( + id: signalFullPath, + name: pName, + direction: pDir, + width: bits, + fullPath: signalFullPath, + scopeId: instId, + isComputed: isCellComputed, + ); + }), + ]; + + final instNode = HierarchyNode( + id: instId, + name: cellName, + kind: HierarchyKind.instance, + type: cellType, + parentId: path, + isPrimitive: true, + signals: cellSignals, + ); + childNodes.add(instNode); + } + } + } + + // Create the module node with children and signals embedded + return HierarchyNode( + id: path, + name: name, + kind: HierarchyKind.module, + type: name, + parentId: parentId, + signals: signalsList, + children: childNodes, + ); + } +} 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..5a71b5f8f --- /dev/null +++ b/packages/rohd_hierarchy/test/adapter_search_parity_test.dart @@ -0,0 +1,287 @@ +// 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), +// Yosys JSON (NetlistHierarchyAdapter), or any other source. + +import 'dart:convert'; + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +/// Resolve a pathname to a [Signal] via [HierarchyAddress.tryFromPathname]. +Signal? _resolve(HierarchyService svc, String path) { + final addr = HierarchyAddress.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( + HierarchyNode( + id: 'Abcd', + name: 'Abcd', + kind: HierarchyKind.module, + signals: [ + Signal(id: 'Abcd/clk', name: 'clk', type: 'wire', width: 1), + Signal(id: 'Abcd/resetn', name: 'resetn', type: 'wire', width: 1), + Signal( + id: 'Abcd/arvalid_s', name: 'arvalid_s', type: 'wire', width: 1), + ], + children: [ + HierarchyNode( + id: 'Abcd/lab', + name: 'lab', + kind: HierarchyKind.instance, + parentId: 'Abcd', + signals: [ + Signal(id: 'Abcd/lab/clk', name: 'clk', type: 'wire', width: 1), + Signal( + id: 'Abcd/lab/reset', name: 'reset', type: 'wire', width: 1), + Signal( + id: 'Abcd/lab/fromUpstream_request__st', + name: 'fromUpstream_request__st', + type: 'wire', + width: 64), + ], + children: [ + HierarchyNode( + id: 'Abcd/lab/cam', + name: 'cam', + kind: HierarchyKind.instance, + parentId: 'Abcd/lab', + signals: [ + Signal( + id: 'Abcd/lab/cam/hit', + name: 'hit', + type: 'wire', + width: 1), + Signal( + id: 'Abcd/lab/cam/entry', + name: 'entry', + type: 'wire', + width: 32), + ], + ), + ], + ), + ], + ), + ); + +/// Yosys JSON-style: flat-map adapter (like what DevTools/schematic viewer +/// builds from ROHD inspector JSON or Yosys 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.searchModules('lab').map((r) => r.node.name).toSet(); + final jsonNodes = + jsonService.searchModules('lab').map((r) => r.node.name).toSet(); + expect(vcdNodes, isNotEmpty); + expect(vcdNodes, jsonNodes); + }); + + test('searchModules nested — same module names', () { + final vcdNodes = + vcdService.searchModules('cam').map((r) => r.node.name).toSet(); + final jsonNodes = + jsonService.searchModules('cam').map((r) => r.node.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'); + }); + }); +} 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..55ef44925 --- /dev/null +++ b/packages/rohd_hierarchy/test/address_conversion_test.dart @@ -0,0 +1,314 @@ +// 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 HierarchyNode 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 = HierarchyNode( + id: 'Top/cpu/alu', + name: 'alu', + kind: HierarchyKind.module, + signals: [ + Signal( + id: 'a', + name: 'a', + type: 'wire', + width: 1, + fullPath: 'Top/cpu/alu/a', + scopeId: 'Top/cpu/alu'), + Signal( + id: 'b', + name: 'b', + type: 'wire', + width: 1, + fullPath: 'Top/cpu/alu/b', + scopeId: 'Top/cpu/alu'), + Signal( + id: 'out', + name: 'out', + type: 'wire', + width: 1, + fullPath: 'Top/cpu/alu/out', + scopeId: 'Top/cpu/alu'), + ], + ); + + final cpu = HierarchyNode( + id: 'Top/cpu', + name: 'cpu', + kind: HierarchyKind.module, + signals: [ + Signal( + id: 'clk', + name: 'clk', + type: 'wire', + width: 1, + fullPath: 'Top/cpu/clk', + scopeId: 'Top/cpu'), + Signal( + id: 'rst', + name: 'rst', + type: 'wire', + width: 1, + fullPath: 'Top/cpu/rst', + scopeId: 'Top/cpu'), + ], + children: [alu], + ); + + final mem = HierarchyNode( + id: 'Top/mem', + name: 'mem', + kind: HierarchyKind.module, + signals: [ + Signal( + id: 'addr', + name: 'addr', + type: 'wire', + width: 1, + fullPath: 'Top/mem/addr', + scopeId: 'Top/mem'), + Signal( + id: 'data', + name: 'data', + type: 'wire', + width: 1, + fullPath: 'Top/mem/data', + scopeId: 'Top/mem'), + ], + ); + + root = HierarchyNode( + id: 'Top', + name: 'Top', + kind: HierarchyKind.module, + 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(HierarchyAddress.root), + equals('Top'), + ); + }); + + test('module address resolves correctly', () { + expect( + service.addressToPathname(const HierarchyAddress([0])), + equals('Top/cpu'), + ); + }); + + test('nested module address resolves correctly', () { + expect( + service.addressToPathname(const HierarchyAddress([0, 0])), + equals('Top/cpu/alu'), + ); + }); + + test('signal address resolves with asSignal flag', () { + expect( + service.addressToPathname( + const HierarchyAddress([0, 0]), + asSignal: true, + ), + equals('Top/cpu/clk'), + ); + }); + + test('nested signal address resolves with asSignal flag', () { + expect( + service.addressToPathname( + const HierarchyAddress([0, 0, 2]), + asSignal: true, + ), + equals('Top/cpu/alu/out'), + ); + }); + + test('out-of-bounds child returns null', () { + expect( + service.addressToPathname(const HierarchyAddress([5])), + isNull, + ); + }); + + test('out-of-bounds signal returns null', () { + expect( + service.addressToPathname( + const HierarchyAddress([0, 99]), + asSignal: true, + ), + isNull, + ); + }); + }); + + group('nodeByAddress', () { + test('root address returns root', () { + final node = service.nodeByAddress(HierarchyAddress.root); + expect(node?.name, equals('Top')); + }); + + test('child address returns correct child', () { + final node = service.nodeByAddress(const HierarchyAddress([0])); + expect(node?.name, equals('cpu')); + }); + + test('nested address returns correct node', () { + final node = service.nodeByAddress(const HierarchyAddress([0, 0])); + expect(node?.name, equals('alu')); + }); + + test('out-of-bounds returns null', () { + expect( + service.nodeByAddress(const HierarchyAddress([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(HierarchyAddress.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 = HierarchyAddress([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..493f4c936 --- /dev/null +++ b/packages/rohd_hierarchy/test/coverage_gaps_test.dart @@ -0,0 +1,158 @@ +// 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 +// - Port.simple factory +// - HierarchyNode.parentId +// - Signal.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('Port.simple factory', () { + test('creates a port with defaults', () { + final p = Port.simple(name: 'clk', direction: 'input'); + expect(p.name, 'clk'); + expect(p.id, 'clk'); // defaults to name + expect(p.direction, 'input'); + expect(p.width, 1); + expect(p.type, 'wire'); + expect(p.isPort, isTrue); + expect(p.isInput, isTrue); + }); + + test('creates a port with explicit overrides', () { + final p = Port.simple( + name: 'data', + direction: 'output', + width: 32, + id: 'data_out', + type: 'logic', + fullPath: 'Top/data', + scopeId: 'Top', + isComputed: true, + ); + expect(p.id, 'data_out'); + expect(p.name, 'data'); + expect(p.width, 32); + expect(p.type, 'logic'); + expect(p.direction, 'output'); + expect(p.fullPath, 'Top/data'); + expect(p.scopeId, 'Top'); + expect(p.isComputed, isTrue); + expect(p.isOutput, isTrue); + }); + }); + + group('HierarchyNode.parentId', () { + test('parentId is null for root', () { + final root = HierarchyNode( + id: 'Top', + name: 'Top', + kind: HierarchyKind.module, + ); + expect(root.parentId, isNull); + }); + + test('parentId is set for child nodes', () { + final child = HierarchyNode( + id: 'Top/sub', + name: 'sub', + kind: HierarchyKind.instance, + parentId: 'Top', + ); + final root = HierarchyNode( + id: 'Top', + name: 'Top', + kind: HierarchyKind.module, + children: [child], + ); + expect(root.children.first.parentId, 'Top'); + }); + }); + + group('Signal.value', () { + test('value is null by default', () { + final s = Signal(id: 'a', name: 'a', type: 'wire', width: 1); + expect(s.value, isNull); + }); + + test('value stores the provided runtime value', () { + final s = Signal( + id: 'a', + name: 'a', + type: 'wire', + width: 8, + value: 'ff', + ); + expect(s.value, 'ff'); + }); + }); + + group('HierarchyNode.type', () { + test('type is null when not provided', () { + final n = HierarchyNode( + id: 'a', + name: 'a', + kind: HierarchyKind.module, + ); + expect(n.type, isNull); + }); + + test('type is stored when provided', () { + final n = HierarchyNode( + id: 'a', + name: 'a', + kind: HierarchyKind.instance, + type: 'Counter', + ); + expect(n.type, 'Counter'); + }); + }); + + group('Signal.scopeId', () { + test('scopeId is null by default', () { + final s = Signal(id: 'a', name: 'a', type: 'wire', width: 1); + expect(s.scopeId, isNull); + }); + + test('scopeId is stored when provided', () { + final s = Signal( + id: 'a', + name: 'a', + type: 'wire', + width: 1, + scopeId: 'Top/sub', + ); + expect(s.scopeId, 'Top/sub'); + }); + }); + + group('HierarchyKind on instances', () { + test('instance kind is reflected correctly', () { + final n = HierarchyNode( + id: 'Top/sub', + name: 'sub', + kind: HierarchyKind.instance, + ); + expect(n.kind, HierarchyKind.instance); + }); + }); +} 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..4c0f36da9 --- /dev/null +++ b/packages/rohd_hierarchy/test/devtools_search_flow_test.dart @@ -0,0 +1,275 @@ +// 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. +HierarchyNode _buildTestHierarchy() { + final cam = HierarchyNode( + id: 'Abcd/lab/cam', + name: 'cam', + kind: HierarchyKind.module, + parentId: 'Abcd/lab', + signals: [ + Port( + id: 'clk', + name: 'clk', + type: 'wire', + width: 1, + direction: 'input', + fullPath: 'Abcd/lab/cam/clk', + scopeId: 'Abcd/lab/cam'), + Port( + id: 'hit', + name: 'hit', + type: 'wire', + width: 1, + direction: 'input', + fullPath: 'Abcd/lab/cam/hit', + scopeId: 'Abcd/lab/cam'), + Port( + id: 'entry', + name: 'entry', + type: 'wire', + width: 32, + direction: 'input', + fullPath: 'Abcd/lab/cam/entry', + scopeId: 'Abcd/lab/cam'), + Port( + id: 'match_out', + name: 'match_out', + type: 'wire', + width: 1, + direction: 'output', + fullPath: 'Abcd/lab/cam/match_out', + scopeId: 'Abcd/lab/cam'), + ], + ); + + final lab = HierarchyNode( + id: 'Abcd/lab', + name: 'lab', + kind: HierarchyKind.module, + parentId: 'Abcd', + children: [cam], + signals: [ + Port( + id: 'clk', + name: 'clk', + type: 'wire', + width: 1, + direction: 'input', + fullPath: 'Abcd/lab/clk', + scopeId: 'Abcd/lab'), + Port( + id: 'reset', + name: 'reset', + type: 'wire', + width: 1, + direction: 'input', + fullPath: 'Abcd/lab/reset', + scopeId: 'Abcd/lab'), + Port( + id: 'fromUpstream_request__st', + name: 'fromUpstream_request__st', + type: 'wire', + width: 64, + direction: 'input', + fullPath: 'Abcd/lab/fromUpstream_request__st', + scopeId: 'Abcd/lab'), + Port( + id: 'toUpstream_response__st', + name: 'toUpstream_response__st', + type: 'wire', + width: 64, + direction: 'output', + fullPath: 'Abcd/lab/toUpstream_response__st', + scopeId: 'Abcd/lab'), + ], + ); + + final dmaEngine = HierarchyNode( + id: 'Abcd/engine', + name: 'engine', + kind: HierarchyKind.module, + parentId: 'Abcd', + signals: [ + Port( + id: 'clk', + name: 'clk', + type: 'wire', + width: 1, + direction: 'input', + fullPath: 'Abcd/engine/clk', + scopeId: 'Abcd/engine'), + Port( + id: 'enable', + name: 'enable', + type: 'wire', + width: 1, + direction: 'input', + fullPath: 'Abcd/engine/enable', + scopeId: 'Abcd/engine'), + Port( + id: 'data_in', + name: 'data_in', + type: 'wire', + width: 64, + direction: 'input', + fullPath: 'Abcd/engine/data_in', + scopeId: 'Abcd/engine'), + Port( + id: 'data_out', + name: 'data_out', + type: 'wire', + width: 64, + direction: 'output', + fullPath: 'Abcd/engine/data_out', + scopeId: 'Abcd/engine'), + Port( + id: 'done', + name: 'done', + type: 'wire', + width: 1, + direction: 'output', + fullPath: 'Abcd/engine/done', + scopeId: 'Abcd/engine'), + ], + ); + + return HierarchyNode( + id: 'Abcd', + name: 'Abcd', + kind: HierarchyKind.module, + children: [lab, dmaEngine], + signals: [ + Port( + id: 'clk', + name: 'clk', + type: 'wire', + width: 1, + direction: 'input', + fullPath: 'Abcd/clk', + scopeId: 'Abcd'), + Port( + id: 'resetn', + name: 'resetn', + type: 'wire', + width: 1, + direction: 'input', + fullPath: 'Abcd/resetn', + scopeId: 'Abcd'), + Port( + id: 'araddr_s', + name: 'araddr_s', + type: 'wire', + width: 32, + direction: 'input', + fullPath: 'Abcd/araddr_s', + scopeId: 'Abcd'), + Port( + id: 'rdata_s', + name: 'rdata_s', + type: 'wire', + width: 32, + direction: 'output', + fullPath: 'Abcd/rdata_s', + scopeId: 'Abcd'), + ], + ); +} + +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 Signal.id 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 = + HierarchyAddress.tryFromPathname('Abcd/lab/cam/hit', service.root); + expect(addr, isNotNull); + final hit = service.signalByAddress(addr!); + expect(hit, isNotNull); + expect(hit!.name, 'hit'); + expect(hit.id, 'hit'); // local, not full path + expect(hit.fullPath, 'Abcd/lab/cam/hit'); + }); + + test('searchModules works with local-ID tree', () { + final results = service.searchModules('cam'); + expect(results, isNotEmpty); + expect(results.first.node.name, 'cam'); + }); + }); + + // ── Signal 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.id, 'clk'); + // But fullPath is the full qualified path + expect(clk.fullPath, 'Abcd/clk'); + }); + + test('local signal IDs do not break address resolution', () { + final addr = HierarchyAddress.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.id, '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!.id, 'rdata_s'); // local name + expect(r.signal!.fullPath, '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..aa07598b1 --- /dev/null +++ b/packages/rohd_hierarchy/test/filter_bank_integration_test.dart @@ -0,0 +1,631 @@ +// 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'); + }); + + 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.type != null && c.type!.startsWith(r'$')); + expect(sliceCells, isNotEmpty); + for (final cell in sliceCells) { + expect(cell.isPrimitive, isTrue, + reason: '${cell.name} (${cell.type}) 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 be Port instances + for (final s in prim.signals) { + expect(s is Port, isTrue, + reason: '${prim.name}/${s.name} should be a Port'); + expect((s as Port).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(HierarchyNode 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 Port instances', () { + final ports = service.root.ports; + expect(ports, isNotEmpty); + for (final p in ports) { + expect(p, isA()); + 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(HierarchyNode.isPrimitiveType(r'$mux'), isTrue); + expect(HierarchyNode.isPrimitiveType(r'$and'), isTrue); + }); + + test(r'isPrimitiveType is false for non-$-prefixed types', () { + expect(HierarchyNode.isPrimitiveType('FilterBank'), isFalse); + }); + + test('isPrimitiveType is false for empty string', () { + expect(HierarchyNode.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(HierarchyNode n) => + n.signals.length + + n.children.fold(0, (sum, c) => sum + countSignals(c)); + expect(all.length, countSignals(service.root)); + }); + }); + + // ─────────────── Signal model getters ─────────────── + + group('Signal 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 Signal, 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('Signal.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.searchNodes('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-insensitive', () { + 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('kind reflects node.kind', () { + final results = service.searchModules('ch0'); + expect(results, isNotEmpty); + final r = results.first; + expect(r.kind, isNotNull); + }); + + test('childCount reflects node.children.length', () { + final results = service.searchModules('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.searchModules('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 = + HierarchyNode(id: 'r', name: 'r', kind: HierarchyKind.module); + 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 = HierarchyAddress.tryFromPathname( + 'FilterBank/ch0_1/clk', service.root); + final ch1Addr = HierarchyAddress.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 = HierarchyAddress.tryFromPathname( + 'FilterBank/ch0_1/validPipe', service.root); + final ch1Addr = HierarchyAddress.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 + .whereType() + .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 = + HierarchyAddress.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 + .whereType() + .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 is Port && s.isInout); + expect(inouts, isNotEmpty, + reason: 'depthFirstSignals should include inout ports'); + }); + + test('addressToPathname round-trips for inout signal', () { + final addr = + HierarchyAddress.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_address_test.dart b/packages/rohd_hierarchy/test/hierarchy_address_test.dart new file mode 100644 index 000000000..bb6558706 --- /dev/null +++ b/packages/rohd_hierarchy/test/hierarchy_address_test.dart @@ -0,0 +1,184 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_address_test.dart +// Unit tests for HierarchyAddress class. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + group('HierarchyAddress', () { + test('child() appends module index', () { + final addr = HierarchyAddress.root.child(0).child(2).child(4); + expect(addr.path, equals([0, 2, 4])); + }); + + test('signal() appends signal index', () { + final addr = const HierarchyAddress([0, 1]).signal(5); + expect(addr.path, equals([0, 1, 5])); + }); + + test('equality and hashcode work correctly', () { + const addr1 = HierarchyAddress([0, 2, 4]); + const addr2 = HierarchyAddress([0, 2, 4]); + const addr3 = HierarchyAddress([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(HierarchyAddress.root.toString(), equals('[ROOT]')); + expect(const HierarchyAddress([0, 2, 4]).toString(), equals('[0.2.4]')); + }); + + test('toDotString() returns dot-separated path', () { + expect(HierarchyAddress.root.toDotString(), equals('')); + expect(const HierarchyAddress([0]).toDotString(), equals('0')); + expect(const HierarchyAddress([0, 2, 4]).toDotString(), equals('0.2.4')); + expect(const HierarchyAddress([10, 200]).toDotString(), equals('10.200')); + }); + + test('fromDotString() parses dot-separated path', () { + expect(HierarchyAddress.fromDotString(''), equals(HierarchyAddress.root)); + expect(HierarchyAddress.fromDotString('0'), + equals(const HierarchyAddress([0]))); + expect(HierarchyAddress.fromDotString('0.2.4'), + equals(const HierarchyAddress([0, 2, 4]))); + expect(HierarchyAddress.fromDotString('10.200'), + equals(const HierarchyAddress([10, 200]))); + }); + + test('toDotString/fromDotString round-trip', () { + final testCases = [ + HierarchyAddress.root, + const HierarchyAddress([0]), + const HierarchyAddress([5, 10, 15]), + const HierarchyAddress([0, 0, 0]), + const HierarchyAddress([255]), + const HierarchyAddress([0, 1, 2, 3, 4, 5]), + ]; + for (final original in testCases) { + final dot = original.toDotString(); + final restored = HierarchyAddress.fromDotString(dot); + expect(restored, equals(original), reason: 'Failed for $original'); + } + }); + }); + + group('HierarchyAddress with HierarchyNode integration', () { + late HierarchyNode root; + + setUp(() { + // Build a simple tree structure + final child0 = HierarchyNode( + id: 'child_0', + name: 'child_0', + kind: HierarchyKind.module, + signals: [ + Signal( + id: 'sig0', + name: 'sig0', + type: 'wire', + width: 1, + fullPath: 'root/child_0/sig0', + scopeId: 'root/child_0', + ), + Signal( + id: 'sig1', + name: 'sig1', + type: 'wire', + width: 8, + fullPath: 'root/child_0/sig1', + scopeId: 'root/child_0', + ), + ], + ); + + final grandchild = HierarchyNode( + id: 'root/child_0/grandchild_0', + name: 'grandchild_0', + kind: HierarchyKind.module, + signals: [ + Signal( + id: 'sig0', + name: 'sig0', + type: 'wire', + width: 1, + fullPath: 'root/child_0/grandchild_0/sig0', + scopeId: 'root/child_0/grandchild_0', + ), + ], + ); + + final child1 = HierarchyNode( + id: 'child_1', + name: 'child_1', + kind: HierarchyKind.module, + signals: [ + Signal( + id: 'sig0', + name: 'sig0', + type: 'wire', + width: 4, + fullPath: 'root/child_1/sig0', + scopeId: 'root/child_1', + ), + ], + ); + + child0.children.add(grandchild); + + root = HierarchyNode( + id: 'root', + name: 'root', + kind: HierarchyKind.module, + signals: [ + Signal( + id: 'clk', + name: 'clk', + type: 'wire', + width: 1, + fullPath: 'root/clk', + scopeId: 'root', + ), + ], + children: [child0, child1], + ) + // Build addresses for all nodes + ..buildAddresses(); + }); + + test('buildAddresses assigns address to root', () { + expect(root.address, equals(HierarchyAddress.root)); + }); + + test('buildAddresses assigns addresses to all nodes', () { + expect(root.children[0].address, equals(const HierarchyAddress([0]))); + expect(root.children[1].address, equals(const HierarchyAddress([1]))); + expect(root.children[0].children[0].address, + equals(const HierarchyAddress([0, 0]))); + }); + + test('buildAddresses assigns addresses to all signals', () { + // Root signals + expect(root.signals[0].address, equals(const HierarchyAddress([0]))); + + // Child signals + expect(root.children[0].signals[0].address, + equals(const HierarchyAddress([0, 0]))); + expect(root.children[0].signals[1].address, + equals(const HierarchyAddress([0, 1]))); + + // Grandchild signals + expect(root.children[0].children[0].signals[0].address, + equals(const HierarchyAddress([0, 0, 0]))); + }); + }); +} 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..5a113685f --- /dev/null +++ b/packages/rohd_hierarchy/test/hierarchy_path_vs_signal_id_test.dart @@ -0,0 +1,205 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_path_vs_signal_id_test.dart +// Verifies that the canonical signal identity (hierarchyPath) and the +// search result path (signalId) are handled correctly across VCD +// (dot-separated) and ROHD (slash-separated) hierarchies. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + group('hierarchyPath vs signalId — VCD (dot-separated)', () { + late BaseHierarchyAdapter adapter; + + setUp(() { + final root = HierarchyNode( + id: 'abcd', + name: 'abcd', + kind: HierarchyKind.module, + signals: [ + Port( + id: 'abcd.clk', + name: 'clk', + type: 'wire', + width: 1, + direction: 'input', + fullPath: 'abcd.clk', + ), + Port( + id: 'abcd.arvalid_s', + name: 'arvalid_s', + type: 'wire', + width: 1, + direction: 'input', + fullPath: 'abcd.arvalid_s', + ), + ], + children: [ + HierarchyNode( + id: 'abcd.lab', + name: 'lab', + kind: HierarchyKind.module, + parentId: 'abcd', + signals: [ + Port( + id: 'abcd.lab.clk', + name: 'clk', + type: 'wire', + width: 1, + direction: 'input', + fullPath: 'abcd.lab.clk', + ), + Port( + id: 'abcd.lab.data', + name: 'data', + type: 'wire', + width: 8, + direction: 'output', + fullPath: 'abcd.lab.data', + ), + ], + ), + ], + )..buildAddresses(); + adapter = BaseHierarchyAdapter.fromTree(root); + }); + + Signal? resolve(String path) { + final addr = HierarchyAddress.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!.hierarchyPath, 'abcd.clk'); + }); + + test('resolves slash-separated IDs', () { + final s = resolve('abcd/clk'); + expect(s, isNotNull); + expect(s!.hierarchyPath, 'abcd.clk'); + }); + + test('resolves case-insensitively', () { + final s = resolve('Abcd.CLK'); + expect(s, isNotNull); + expect(s!.hierarchyPath, 'abcd.clk'); + }); + + test('resolves nested dot-separated IDs', () { + final s = resolve('abcd.lab.data'); + expect(s, isNotNull); + expect(s!.hierarchyPath, 'abcd.lab.data'); + }); + + test('resolves nested slash-separated IDs', () { + final s = resolve('abcd/lab/data'); + expect(s, isNotNull); + expect(s!.hierarchyPath, 'abcd.lab.data'); + }); + + test('searchSignals returns result with signal.hierarchyPath preserved', + () { + final results = adapter.searchSignals('clk'); + expect(results, isNotEmpty); + // Every result should have a non-null signal + for (final r in results) { + expect(r.signal, isNotNull, + reason: 'Signal should be resolved for "${r.signalId}"'); + } + // The signal's hierarchyPath should be dot-separated (VCD format) + final clkResult = results.firstWhere( + (r) => r.path.last == 'clk' && r.path.length == 2); // root-level clk + expect(clkResult.signal!.hierarchyPath, 'abcd.clk'); + }); + + test('searchSignals signalId is walker-built (slash) path', () { + final results = adapter.searchSignals('clk'); + expect(results, isNotEmpty); + // signalId from the walker uses slash separator + final clkResult = + results.firstWhere((r) => r.path.last == 'clk' && r.path.length == 2); + expect(clkResult.signalId, 'abcd/clk'); + }); + + test( + 'signal selected from search has correct ' + 'hierarchyPath for waveform lookup', () { + final results = adapter.searchSignals('clk'); + final result = results.first; + + // When result.signal is non-null, use it directly + // Its hierarchyPath is the canonical key for waveform lookup + expect(result.signal, isNotNull); + expect(result.signal!.hierarchyPath, startsWith('abcd.')); + + // The search signalId is slash-separated (different!) + expect(result.signalId, contains('/')); + + // These are different representations of the same signal + // hierarchyPath: for waveform data lookup (preserves original format) + // signalId: for display (walker-built, always slash-separated) + }); + + 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: 'Signal should be resolved for "${r.signalId}"'); + // hierarchyPath preserves the original dot format + expect(r.signal!.hierarchyPath, contains('.')); + } + }); + }); + + group('hierarchyPath vs signalId — ROHD (slash-separated)', () { + late BaseHierarchyAdapter adapter; + + setUp(() { + adapter = BaseHierarchyAdapter.fromTree( + HierarchyNode( + id: 'Top', + name: 'Top', + kind: HierarchyKind.module, + signals: [ + Signal(id: 'Top/clk', name: 'clk', type: 'wire', width: 1), + ], + children: [ + HierarchyNode( + id: 'Top/cpu', + name: 'cpu', + kind: HierarchyKind.instance, + parentId: 'Top', + signals: [ + Signal( + id: 'Top/cpu/data_out', + name: 'data_out', + type: 'wire', + width: 8, + ), + ], + ), + ], + ), + ); + }); + + test('ROHD signals: hierarchyPath matches signalId (both slash)', () { + final results = adapter.searchSignals('clk'); + expect(results, isNotEmpty); + final r = results.first; + // For ROHD, signal.id uses slash separator, no fullPath set + // So hierarchyPath = id = 'Top/clk' + // And signalId (walker) = 'Top/clk' + // They match! + expect(r.signal!.hierarchyPath, r.signalId); + }); + }); +} 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..d94734257 --- /dev/null +++ b/packages/rohd_hierarchy/test/hierarchy_search_controller_test.dart @@ -0,0 +1,587 @@ +// 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. +HierarchyNode _buildTestTree() => HierarchyNode( + id: 'Top', + name: 'Top', + kind: HierarchyKind.module, + signals: [ + Signal(id: 'Top/clk', name: 'clk', type: 'wire', width: 1), + Signal(id: 'Top/rst', name: 'rst', type: 'wire', width: 1), + ], + children: [ + HierarchyNode( + id: 'Top/cpu', + name: 'cpu', + kind: HierarchyKind.instance, + parentId: 'Top', + signals: [ + Signal( + id: 'Top/cpu/data_in', name: 'data_in', type: 'wire', width: 8), + Signal( + id: 'Top/cpu/data_out', + name: 'data_out', + type: 'wire', + width: 8), + ], + children: [ + HierarchyNode( + id: 'Top/cpu/alu', + name: 'alu', + kind: HierarchyKind.instance, + parentId: 'Top/cpu', + signals: [ + Signal(id: 'Top/cpu/alu/a', name: 'a', type: 'wire', width: 16), + Signal(id: 'Top/cpu/alu/b', name: 'b', type: 'wire', width: 16), + Signal( + id: 'Top/cpu/alu/result', + name: 'result', + type: 'wire', + width: 16), + ], + ), + ], + ), + HierarchyNode( + id: 'Top/mem', + name: 'mem', + kind: HierarchyKind.instance, + parentId: 'Top', + signals: [ + Signal(id: 'Top/mem/addr', name: 'addr', type: 'wire', width: 32), + ], + ), + ], + ); + +void main() { + late BaseHierarchyAdapter hierarchy; + late HierarchySearchController signalCtrl; + late HierarchySearchController moduleCtrl; + + setUp(() { + hierarchy = BaseHierarchyAdapter.fromTree(_buildTestTree()); + signalCtrl = HierarchySearchController.forSignals(hierarchy); + moduleCtrl = HierarchySearchController.forModules(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.node.name, 'cpu'); + }); + + test('finds nested modules', () { + moduleCtrl.updateQuery('alu'); + expect(moduleCtrl.hasResults, isTrue); + expect(moduleCtrl.results.first.node.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( + HierarchyNode( + id: 'testbench', + name: 'testbench', + kind: HierarchyKind.module, + signals: [ + Signal(id: 'testbench.clk', name: 'clk', type: 'wire', width: 1), + Signal(id: 'testbench.rst', name: 'rst', type: 'wire', width: 1), + ], + children: [ + HierarchyNode( + id: 'testbench.childA', + name: 'childA', + kind: HierarchyKind.instance, + parentId: 'testbench', + signals: [ + Signal( + id: 'testbench.childA.clk', + name: 'clk', + type: 'wire', + width: 1), + Signal( + id: 'testbench.childA.data', + name: 'data', + type: 'wire', + width: 8), + ], + children: [ + HierarchyNode( + id: 'testbench.childA.sub', + name: 'sub', + kind: HierarchyKind.instance, + parentId: 'testbench.childA', + signals: [ + Signal( + id: 'testbench.childA.sub.out', + name: 'out', + type: 'wire', + width: 4), + ], + ), + ], + ), + HierarchyNode( + id: 'testbench.childB', + name: 'childB', + kind: HierarchyKind.instance, + parentId: 'testbench', + signals: [ + Signal( + id: 'testbench.childB.enable', + name: 'enable', + type: 'wire', + 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!.id, 'testbench.childA.clk'); + }); + + test('searchModules with slash query finds dot-separated module', () { + final results = vcdHierarchy.searchModules('childA'); + expect(results, isNotEmpty); + expect(results.first.node.id, '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!.id).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!.id, '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!.id, '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 = HierarchyNode( + id: 'Top/cpu/alu', + name: 'alu', + kind: HierarchyKind.module, + parentId: 'Top/cpu', + signals: [ + Port( + id: 'a', + name: 'a', + type: 'wire', + width: 16, + direction: 'input', + fullPath: 'Top/cpu/alu/a', + scopeId: 'Top/cpu/alu'), + Port( + id: 'b', + name: 'b', + type: 'wire', + width: 16, + direction: 'input', + fullPath: 'Top/cpu/alu/b', + scopeId: 'Top/cpu/alu'), + Port( + id: 'result', + name: 'result', + type: 'wire', + width: 16, + direction: 'output', + fullPath: 'Top/cpu/alu/result', + scopeId: 'Top/cpu/alu'), + ], + ); + final cpu = HierarchyNode( + id: 'Top/cpu', + name: 'cpu', + kind: HierarchyKind.module, + parentId: 'Top', + children: [alu], + signals: [ + Port( + id: 'data_in', + name: 'data_in', + type: 'wire', + width: 8, + direction: 'input', + fullPath: 'Top/cpu/data_in', + scopeId: 'Top/cpu'), + Port( + id: 'data_out', + name: 'data_out', + type: 'wire', + width: 8, + direction: 'output', + fullPath: 'Top/cpu/data_out', + scopeId: 'Top/cpu'), + ], + ); + final mem = HierarchyNode( + id: 'Top/mem', + name: 'mem', + kind: HierarchyKind.module, + parentId: 'Top', + signals: [ + Port( + id: 'addr', + name: 'addr', + type: 'wire', + width: 32, + direction: 'input', + fullPath: 'Top/mem/addr', + scopeId: 'Top/mem'), + ], + ); + final root = HierarchyNode( + id: 'Top', + name: 'Top', + kind: HierarchyKind.module, + children: [cpu, mem], + signals: [ + Port( + id: 'clk', + name: 'clk', + type: 'wire', + width: 1, + direction: 'input', + fullPath: 'Top/clk', + scopeId: 'Top'), + Port( + id: 'rst', + name: 'rst', + type: 'wire', + width: 1, + direction: 'input', + fullPath: 'Top/rst', + scopeId: 'Top'), + ], + )..buildAddresses(); + rohdHierarchy = BaseHierarchyAdapter.fromTree(root); + rohdSignalCtrl = HierarchySearchController.forSignals(rohdHierarchy); + rohdModuleCtrl = HierarchySearchController.forModules(rohdHierarchy); + }); + + test('signal IDs are local (not full paths)', () { + final rootSigs = rohdHierarchy.root.signals; + final clk = rootSigs.firstWhere((s) => s.name == 'clk'); + expect(clk.id, 'clk'); + expect(clk.fullPath, '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 = + HierarchyAddress.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 = + HierarchyAddress.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.node.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..ffd1c288d --- /dev/null +++ b/packages/rohd_hierarchy/test/module_search_test.dart @@ -0,0 +1,372 @@ +// 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 HierarchyNode root; + late HierarchyService hierarchy; + + setUpAll(() { + // Create a test hierarchy + // Top + // CPU (2 children) + // ALU + // Decoder + // Memory + // ControlUnit + + final alu = HierarchyNode( + id: 'Top/CPU/ALU', + name: 'ALU', + kind: HierarchyKind.module, + ); + + final decoder = HierarchyNode( + id: 'Top/CPU/Decoder', + name: 'Decoder', + kind: HierarchyKind.module, + ); + + final cpu = HierarchyNode( + id: 'Top/CPU', + name: 'CPU', + kind: HierarchyKind.module, + children: [alu, decoder], + ); + + final memory = HierarchyNode( + id: 'Top/Memory', + name: 'Memory', + kind: HierarchyKind.module, + ); + + final controlUnit = HierarchyNode( + id: 'Top/ControlUnit', + name: 'ControlUnit', + kind: HierarchyKind.module, + ); + + root = HierarchyNode( + id: 'Top', + name: 'Top', + kind: HierarchyKind.module, + 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.kind, equals(HierarchyKind.module)); + }); + + 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.searchNodePaths('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.searchNodePaths('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.searchNodePaths('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.searchNodePaths('', limit: 2); + expect(results.length, lessThanOrEqualTo(2), + reason: 'Should respect limit parameter'); + }); + + test('searchModules returns ModuleSearchResult objects', () { + final results = hierarchy.searchModules('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.searchModules('decoder'); + expect(results, isNotEmpty); + final result = results.first; + expect(result.moduleId, contains('Decoder')); + expect(result.path, isNotEmpty); + expect(result.path.last, equals('Decoder')); + expect(result.node, isNotNull); + }); + + test('searchNodePaths returns empty for non-matching query', () { + final results = hierarchy.searchNodePaths('nonexistent'); + expect(results, isEmpty, + reason: 'Should return empty list for non-matching query'); + }); + + test('searchNodePaths returns empty for empty query', () { + final results = hierarchy.searchNodePaths(''); + 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.searchModules('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 = HierarchyNode( + id: 'Design/ProcessingUnit/DataPath/Multiplier', + name: 'Multiplier', + kind: HierarchyKind.module, + ); + + final adder = HierarchyNode( + id: 'Design/ProcessingUnit/DataPath/Adder', + name: 'Adder', + kind: HierarchyKind.module, + ); + + final dataPath = HierarchyNode( + id: 'Design/ProcessingUnit/DataPath', + name: 'DataPath', + kind: HierarchyKind.module, + children: [multiplier, adder], + ); + + final controller = HierarchyNode( + id: 'Design/ProcessingUnit/Controller', + name: 'Controller', + kind: HierarchyKind.module, + ); + + final processingUnit = HierarchyNode( + id: 'Design/ProcessingUnit', + name: 'ProcessingUnit', + kind: HierarchyKind.module, + children: [dataPath, controller], + ); + + final ram = HierarchyNode( + id: 'Design/Memory/RAM', + name: 'RAM', + kind: HierarchyKind.module, + ); + + final cache = HierarchyNode( + id: 'Design/Memory/Cache', + name: 'Cache', + kind: HierarchyKind.module, + ); + + final memory = HierarchyNode( + id: 'Design/Memory', + name: 'Memory', + kind: HierarchyKind.module, + children: [ram, cache], + ); + + final root = HierarchyNode( + id: 'Design', + name: 'Design', + kind: HierarchyKind.module, + children: [processingUnit, memory], + ); + + hierarchy = BaseHierarchyAdapter.fromTree(root); + }); + + test('single segment matches at any level', () { + final results = hierarchy.searchNodePaths('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.searchNodePaths('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.searchNodePaths('processingunit/datapath/adder'); + expect(results.any((r) => r.contains('ProcessingUnit/DataPath/Adder')), + isTrue, + reason: 'Should find Adder with full hierarchical path'); + }); + + test('case insensitive matching works', () { + final resultsLower = hierarchy.searchNodePaths('MULTIPLIER'); + final resultsUpper = hierarchy.searchNodePaths('multiplier'); + expect(resultsLower, isNotEmpty); + expect(resultsUpper, isNotEmpty); + expect(resultsLower.length, equals(resultsUpper.length), + reason: 'Case should not affect matching'); + }); + + test('partial name matching works', () { + final results1 = hierarchy.searchNodePaths('path'); + expect(results1.any((r) => r.contains('DataPath')), isTrue, + reason: 'Should match partial "path" in DataPath'); + + final results2 = hierarchy.searchNodePaths('unit'); + expect(results2.any((r) => r.contains('ProcessingUnit')), isTrue, + reason: 'Should match partial "unit" in ProcessingUnit'); + }); + }); + + group('Module Search - Integration with Tree Filtering', () { + late HierarchyNode root; + + setUpAll(() { + final alu = HierarchyNode( + id: 'Top/CPU/ALU', + name: 'ALU', + kind: HierarchyKind.module, + ); + + final cpu = HierarchyNode( + id: 'Top/CPU', + name: 'CPU', + kind: HierarchyKind.module, + children: [alu], + ); + + final memory = HierarchyNode( + id: 'Top/Memory', + name: 'Memory', + kind: HierarchyKind.module, + ); + + root = HierarchyNode( + id: 'Top', + name: 'Top', + kind: HierarchyKind.module, + 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(HierarchyNode 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( + HierarchyNode 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/regex_search_test.dart b/packages/rohd_hierarchy/test/regex_search_test.dart new file mode 100644 index 000000000..8616e170d --- /dev/null +++ b/packages/rohd_hierarchy/test/regex_search_test.dart @@ -0,0 +1,623 @@ +// 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 = HierarchyNode( + id: 'Top.CPU.ALU', + name: 'ALU', + kind: HierarchyKind.module, + signals: [ + Signal(type: 'wire', id: 'Top.CPU.ALU.a', name: 'a', width: 8), + Signal(type: 'wire', id: 'Top.CPU.ALU.b', name: 'b', width: 8), + Signal( + type: 'wire', id: 'Top.CPU.ALU.result', name: 'result', width: 8), + Signal( + type: 'wire', + id: 'Top.CPU.ALU.carry_out', + name: 'carry_out', + width: 1), + ], + ); + + final decoder = HierarchyNode( + id: 'Top.CPU.Decoder', + name: 'Decoder', + kind: HierarchyKind.module, + signals: [ + Signal( + type: 'wire', + id: 'Top.CPU.Decoder.opcode', + name: 'opcode', + width: 4), + Signal( + type: 'wire', + id: 'Top.CPU.Decoder.enable', + name: 'enable', + width: 1), + ], + ); + + final regFile = HierarchyNode( + id: 'Top.CPU.RegFile', + name: 'RegFile', + kind: HierarchyKind.module, + signals: [ + Signal( + type: 'wire', id: 'Top.CPU.RegFile.clk', name: 'clk', width: 1), + Signal( + type: 'wire', + id: 'Top.CPU.RegFile.reset', + name: 'reset', + width: 1), + Signal(type: 'wire', id: 'Top.CPU.RegFile.d0', name: 'd0', width: 8), + Signal(type: 'wire', id: 'Top.CPU.RegFile.d1', name: 'd1', width: 8), + Signal(type: 'wire', id: 'Top.CPU.RegFile.d2', name: 'd2', width: 8), + Signal( + type: 'wire', id: 'Top.CPU.RegFile.d15', name: 'd15', width: 8), + ], + ); + + final cpu = HierarchyNode( + id: 'Top.CPU', + name: 'CPU', + kind: HierarchyKind.module, + children: [alu, decoder, regFile], + ); + + final cache = HierarchyNode( + id: 'Top.Memory.Cache', + name: 'Cache', + kind: HierarchyKind.module, + signals: [ + Signal( + type: 'wire', id: 'Top.Memory.Cache.clk', name: 'clk', width: 1), + Signal( + type: 'wire', + id: 'Top.Memory.Cache.addr', + name: 'addr', + width: 16), + Signal( + type: 'wire', + id: 'Top.Memory.Cache.data', + name: 'data', + width: 32), + Signal( + type: 'wire', id: 'Top.Memory.Cache.hit', name: 'hit', width: 1), + ], + ); + + final dram = HierarchyNode( + id: 'Top.Memory.DRAM', + name: 'DRAM', + kind: HierarchyKind.module, + signals: [ + Signal( + type: 'wire', id: 'Top.Memory.DRAM.clk', name: 'clk', width: 1), + Signal( + type: 'wire', id: 'Top.Memory.DRAM.cas', name: 'cas', width: 1), + Signal( + type: 'wire', id: 'Top.Memory.DRAM.ras', name: 'ras', width: 1), + ], + ); + + final memory = HierarchyNode( + id: 'Top.Memory', + name: 'Memory', + kind: HierarchyKind.module, + children: [cache, dram], + ); + + final uart = HierarchyNode( + id: 'Top.IO.UART', + name: 'UART', + kind: HierarchyKind.module, + signals: [ + Signal(type: 'wire', id: 'Top.IO.UART.clk', name: 'clk', width: 1), + Signal(type: 'wire', id: 'Top.IO.UART.tx', name: 'tx', width: 1), + Signal(type: 'wire', id: 'Top.IO.UART.rx', name: 'rx', width: 1), + ], + ); + + final io = HierarchyNode( + id: 'Top.IO', + name: 'IO', + kind: HierarchyKind.module, + children: [uart], + ); + + final root = HierarchyNode( + id: 'Top', + name: 'Top', + kind: HierarchyKind.module, + children: [cpu, memory, io], + signals: [ + Signal(type: 'wire', id: 'Top.clk', name: 'clk', width: 1), + Signal(type: 'wire', id: 'Top.reset', name: 'reset', width: 1), + Signal(type: 'wire', id: 'Top.data_m', name: 'data_m', width: 8), + Signal(type: 'wire', id: 'Top.addr_m', name: 'addr_m', width: 16), + Signal(type: 'wire', id: 'Top.flag_m', 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', + ])); + }); + + // ── Case insensitivity ── + + test('search is case-insensitive', () { + final results = hierarchy.searchSignalPathsRegex('top/cpu/alu/RESULT'); + expect(results, contains('Top/CPU/ALU/result')); + }); + + // ── Module search ── + + test('searchNodePathsRegex finds modules', () { + final results = hierarchy.searchNodePathsRegex('Top/CPU/.*'); + expect( + results, + containsAll([ + 'Top/CPU/ALU', + 'Top/CPU/Decoder', + 'Top/CPU/RegFile', + ])); + }); + + test('searchNodePathsRegex with **', () { + final results = hierarchy.searchNodePathsRegex('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 Signal 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('searchModulesRegex returns ModuleSearchResult objects', () { + final results = hierarchy.searchModulesRegex('Top/**/Cache'); + expect(results.length, 1); + expect(results.first.moduleId, '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.searchNodePathsRegex(''), 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('searchModules dispatches to regex', () { + late HierarchyService hierarchy; + + setUpAll(() { + // Build hierarchy: + // Top + // CPU + // ALU + // Decoder + // MuxUnit + // Memory + // Cache + // DRAM + // IO + // UART + + final alu = HierarchyNode( + id: 'Top/CPU/ALU', + name: 'ALU', + kind: HierarchyKind.module, + ); + + final decoder = HierarchyNode( + id: 'Top/CPU/Decoder', + name: 'Decoder', + kind: HierarchyKind.module, + ); + + final muxUnit = HierarchyNode( + id: 'Top/CPU/MuxUnit', + name: 'MuxUnit', + kind: HierarchyKind.module, + ); + + final cpu = HierarchyNode( + id: 'Top/CPU', + name: 'CPU', + kind: HierarchyKind.module, + children: [alu, decoder, muxUnit], + ); + + final cache = HierarchyNode( + id: 'Top/Memory/Cache', + name: 'Cache', + kind: HierarchyKind.module, + ); + + final dram = HierarchyNode( + id: 'Top/Memory/DRAM', + name: 'DRAM', + kind: HierarchyKind.module, + ); + + final memory = HierarchyNode( + id: 'Top/Memory', + name: 'Memory', + kind: HierarchyKind.module, + children: [cache, dram], + ); + + final uart = HierarchyNode( + id: 'Top/IO/UART', + name: 'UART', + kind: HierarchyKind.module, + ); + + final io = HierarchyNode( + id: 'Top/IO', + name: 'IO', + kind: HierarchyKind.module, + children: [uart], + ); + + final root = HierarchyNode( + id: 'Top', + name: 'Top', + kind: HierarchyKind.module, + children: [cpu, memory, io], + ); + + hierarchy = BaseHierarchyAdapter.fromTree(root); + }); + + test('searchModules with glob pattern finds modules', () { + // Pattern: *mux* should find MuxUnit (auto-prepended with */) + final results = hierarchy.searchModules('*mux*'); + expect(results, isNotEmpty, + reason: 'searchModules should dispatch to regex for glob patterns'); + expect(results.any((r) => r.name == 'MuxUnit'), isTrue); + }); + + test('searchModules with ** finds deep modules', () { + final results = hierarchy.searchModules('**/*mux*'); + expect(results, isNotEmpty); + expect(results.any((r) => r.name == 'MuxUnit'), isTrue); + }); + + test('searchModules with .* matches at one level', () { + // */.* matches any child one level below root + final results = hierarchy.searchModules('*/.*/.*'); + expect(results.length, greaterThanOrEqualTo(3), + reason: 'Should match ALU, Decoder, MuxUnit, Cache, DRAM, UART'); + }); + + test('searchModules with explicit path pattern', () { + // */CPU/.* matches children of CPU + final results = hierarchy.searchModules('*/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('searchModules with alternation', () { + final results = hierarchy.searchModules('**/(ALU|DRAM)'); + expect(results.length, 2); + expect(results.any((r) => r.name == 'ALU'), isTrue); + expect(results.any((r) => r.name == 'DRAM'), isTrue); + }); + + test('searchModules without regex uses plain matching', () { + // Plain query without glob chars uses substring matching + final results = hierarchy.searchModules('mux'); + expect(results, isNotEmpty); + expect(results.any((r) => r.name == 'MuxUnit'), isTrue); + }); + + test('searchModules with leading **/ is not double-prepended', () { + final results = hierarchy.searchModules('**/UART'); + expect(results.length, 1); + expect(results.first.name, 'UART'); + }); + + test('searchModules with leading */ is not double-prepended', () { + final results = hierarchy.searchModules('*/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..7ec415e7f --- /dev/null +++ b/packages/rohd_hierarchy/test/rohd_signal_resolve_test.dart @@ -0,0 +1,97 @@ +// 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 HierarchyNode root; + late BaseHierarchyAdapter adapter; + + setUpAll(() { + root = HierarchyNode( + id: 'abcd', + name: 'abcd', + kind: HierarchyKind.module, + signals: [ + Port( + id: 'abcd.clk', + name: 'clk', + type: 'wire', + width: 1, + direction: 'input'), + Port( + id: 'abcd.resetn', + name: 'resetn', + type: 'wire', + width: 1, + direction: 'input'), + Port( + id: 'abcd.arvalid_s', + name: 'arvalid_s', + type: 'wire', + width: 1, + direction: 'input'), + ], + children: [ + HierarchyNode( + id: 'abcd.sub', + name: 'sub', + kind: HierarchyKind.module, + parentId: 'abcd', + signals: [ + Port( + id: 'abcd.sub.data', + name: 'data', + type: 'wire', + width: 8, + direction: 'output'), + ], + ), + ], + ); + + adapter = BaseHierarchyAdapter.fromTree(root); + root.buildAddresses(); + }); + + Signal? resolve(String dotPath) { + final addr = HierarchyAddress.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!.id, 'abcd.clk'); + }); + + test('resolves top-level resetn', () { + final sig = resolve('abcd.resetn'); + expect(sig, isNotNull); + expect(sig!.id, 'abcd.resetn'); + }); + + test('resolves top-level arvalid_s', () { + final sig = resolve('abcd.arvalid_s'); + expect(sig, isNotNull); + expect(sig!.id, 'abcd.arvalid_s'); + }); + + test('resolves nested sub.data', () { + final sig = resolve('abcd.sub.data'); + expect(sig, isNotNull); + expect(sig!.id, '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..36810a17e --- /dev/null +++ b/packages/rohd_hierarchy/test/signal_search_result_test.dart @@ -0,0 +1,256 @@ +// 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('intermediateInstanceNames extracts middle segments', () { + const result = SignalSearchResult( + signalId: 'Top/sub1/sub2/clk', + path: ['Top', 'sub1', 'sub2', 'clk'], + ); + expect(result.intermediateInstanceNames, equals(['sub1', 'sub2'])); + }); + + test('intermediateInstanceNames empty for top-level signal', () { + const result = SignalSearchResult( + signalId: 'Top/clk', + path: ['Top', 'clk'], + ); + expect(result.intermediateInstanceNames, isEmpty); + }); + + test('intermediateInstanceNames 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.intermediateInstanceNames, 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('SignalSearchResult.normalizeQuery', () { + test('converts dots to slashes', () { + expect( + SignalSearchResult.normalizeQuery('top.cpu.clk'), + equals('top/cpu/clk'), + ); + }); + + test('preserves slashes', () { + expect( + SignalSearchResult.normalizeQuery('top/cpu/clk'), + equals('top/cpu/clk'), + ); + }); + + test('handles mixed separators', () { + expect( + SignalSearchResult.normalizeQuery('top.cpu/clk'), + equals('top/cpu/clk'), + ); + }); + + test('handles empty query', () { + expect(SignalSearchResult.normalizeQuery(''), equals('')); + }); + }); + + group('ModuleSearchResult display helpers', () { + late HierarchyNode aluNode; + + setUp(() { + aluNode = HierarchyNode( + id: 'Top/CPU/ALU', + name: 'ALU', + kind: HierarchyKind.module, + ); + }); + + test('displayPath strips top module', () { + final result = ModuleSearchResult( + moduleId: 'Top/CPU/ALU', + path: const ['Top', 'CPU', 'ALU'], + node: aluNode, + ); + expect(result.displayPath, equals('CPU/ALU')); + }); + + test('displaySegments strips top module', () { + final result = ModuleSearchResult( + moduleId: 'Top/CPU/ALU', + path: const ['Top', 'CPU', 'ALU'], + node: aluNode, + ); + expect(result.displaySegments, equals(['CPU', 'ALU'])); + }); + + test('displayPath for single-segment path', () { + final topNode = HierarchyNode( + id: 'Top', + name: 'Top', + kind: HierarchyKind.module, + ); + final result = ModuleSearchResult( + moduleId: 'Top', + path: const ['Top'], + node: topNode, + ); + expect(result.displayPath, equals('Top')); + }); + + test('equality based on moduleId', () { + final a = ModuleSearchResult( + moduleId: 'Top/CPU/ALU', + path: const ['Top', 'CPU', 'ALU'], + node: aluNode, + ); + final b = ModuleSearchResult( + moduleId: 'Top/CPU/ALU', + path: const ['Top', 'CPU', 'ALU'], + node: aluNode, + ); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + }); + + group('ModuleSearchResult.normalizeQuery', () { + test('converts dots to slashes', () { + expect( + ModuleSearchResult.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 = HierarchyNode( + id: 'Top/counter', + name: 'counter', + kind: HierarchyKind.module, + signals: [ + Signal( + id: 'Top/counter/clk', + name: 'clk', + type: 'wire', + width: 1, + fullPath: 'Top/counter/clk', + scopeId: 'Top/counter', + ), + Signal( + id: 'Top/counter/data', + name: 'data', + type: 'wire', + width: 8, + fullPath: 'Top/counter/data', + scopeId: 'Top/counter', + ), + ], + ); + + final root = HierarchyNode( + id: 'Top', + name: 'Top', + kind: HierarchyKind.module, + children: [counter], + signals: [ + Port( + id: 'Top/reset', + name: 'reset', + type: 'wire', + width: 1, + direction: 'input', + fullPath: 'Top/reset', + scopeId: 'Top', + ), + ], + ); + + 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.intermediateInstanceNames, 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.intermediateInstanceNames, isEmpty); + }); + }); +} 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