From 2903adfed9553143e2724b88bdf4ef544d117749 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Fri, 7 Aug 2026 04:09:54 +0300 Subject: [PATCH 01/25] compiler_stages: remove SanityCheckSpec Its deliberately-corrupted DBs made SanityCheck print its violation diagnostic into every test log (CI and local) while the intercepting tests passed, reading as a failure that never happened. The check itself still runs after every stage in every stage test via StageRunner. Co-Authored-By: Claude Fable 5 --- .../scala/StagesSpec/SanityCheckSpec.scala | 44 ------------------- 1 file changed, 44 deletions(-) delete mode 100644 compiler/stages/src/test/scala/StagesSpec/SanityCheckSpec.scala diff --git a/compiler/stages/src/test/scala/StagesSpec/SanityCheckSpec.scala b/compiler/stages/src/test/scala/StagesSpec/SanityCheckSpec.scala deleted file mode 100644 index 7612c2ae8..000000000 --- a/compiler/stages/src/test/scala/StagesSpec/SanityCheckSpec.scala +++ /dev/null @@ -1,44 +0,0 @@ -package StagesSpec - -import dfhdl.* -import dfhdl.compiler.ir.* -import dfhdl.compiler.patching.* -import dfhdl.compiler.stages.sanityCheck -// scalafmt: { align.tokens = [{code = "<>"}, {code = "="}, {code = "=>"}, {code = ":="}]} - -// `SanityCheck` guards what a *stage* may produce, so its inputs are DBs no design can express: -// each test elaborates a valid design and then breaks its sub-DB the way a faulty stage would. -class SanityCheckSpec extends StageSpec: - class Top extends DFDesign: - val x = SInt(16) <> IN - val y = SInt(16) <> OUT - y := x + 1 - end Top - - private def breakSubDB(db: DB)(f: DB => DB): DB = - val (key, sub) = db.subDBs.head - db.update(subDBs = db.subDBs.updated(key, f(sub))) - - test("a member referencing a later member fails the order check") { - val broken = breakSubDB((new Top).getDB) { sub => - val members = sub.members - val net = members.collectFirst { case n: DFNet => n }.get - // the assignment moves ahead of everything it reads, which is what a relocating stage does - // when it leaves a dependency behind - val (before, after) = members.filterNot(_ eq net).splitAt(members.indexOf(sub.top) + 1) - sub.update(members = before ::: net :: after) - } - val err = intercept[IllegalArgumentException](broken.sanityCheck) - assert(clue(err.getMessage).contains("Failed member order check!")) - } - - test("a broken per-design elaboration check is caught between stages") { - val broken = breakSubDB((new Top).getDB) { sub => - given MemberGetSet = sub.getSet - val y = sub.members.collectFirst { case dcl: DFVal.Dcl if dcl.getName == "y" => dcl }.get - sub.patch(List(y -> Patch.Replace(y.anonymize, Patch.Replace.Config.FullReplacement))) - } - val err = intercept[IllegalArgumentException](broken.sanityCheck) - assert(clue(err.getMessage).contains("DFiant HDL name errors!")) - } -end SanityCheckSpec From 03afffb156553f88e1ae2f02d7aa2b359380b4b2 Mon Sep 17 00:00:00 2001 From: Oron Date: Fri, 7 Aug 2026 05:50:59 +0300 Subject: [PATCH 02/25] docs: elaboration-vs-hardware scope rules, enum/localparam mapping, operations reference Resolves knowledge-gap tickets from the hog-fpga, castle-drawing and starfield learner runs. Operations reference (closes #113, #114 in part): - add `Bit`/`Boolean` to the Comparison `Applies to:` line, plus a subsection covering them as operands and stating that ordering comparisons are rejected - normalize the Selection section to the `Applies to:` convention and introduce that convention once at the top of the Operations part - cross-link the `Bit`/`Boolean` and Enumeration type sections to the operations that apply to them, and link comparison-ops from from-verilog Elaboration-time vs hardware (closes #109, #107, #103): - qualify the "both branches" rule: Scala checks both, but only literal widths are tracked at the Scala type level, so a parameterized `generate if` needs no guard. Note the ED implicit `.toScalaBoolean` and the RT/DF hardware-`if` default - drop "these loops are unrolled by the compiler", contradicted by the adjacent example: a process loop stays a loop - document the process-scope iterator as a hardware value that cannot be read into Scala, with the design-scope-loop alternative - qualify the part-select base as allowing a process loop iterator, and add the variable-base row to the from-verilog table Verilog mappings (closes #106, #91, #112): - document `localparam`: a `val` is emitted only when it is a DFHDL constant, which the `: Int <> CONST` ascription forces - list an inline expression as a valid child-port connection - document enum declaration scope, and that the generated typedef's placement follows usage rather than the Scala declaration site - document the generated global definitions/package files, which were previously only visible inside generated snippets Naming (closes #110): - a port sharing a design class's name is not a collision; types and terms are separate namespaces. Remove the `@targetName` workaround that contradicted the page's own `new` recommendation Bit concatenation (closes #95): - document the per-bit connection loop and the ascribed-`var` lane accumulation as the two ways to build a value from a collection All added snippets and generated HDL were compiled against 0.22.0+38-ba527c30-SNAPSHOT. --- docs/transitioning/from-verilog/index.md | 106 ++++++++++++-- docs/user-guide/compilation/index.md | 81 +++++++++++ docs/user-guide/design-hierarchy/index.md | 15 ++ docs/user-guide/loops/index.md | 164 ++++++++++++++++++++-- docs/user-guide/naming/index.md | 80 ++++++++--- docs/user-guide/type-system/index.md | 108 +++++++++++++- 6 files changed, 504 insertions(+), 50 deletions(-) diff --git a/docs/transitioning/from-verilog/index.md b/docs/transitioning/from-verilog/index.md index 8aa89e11e..f6cbd6f78 100644 --- a/docs/transitioning/from-verilog/index.md +++ b/docs/transitioning/from-verilog/index.md @@ -99,6 +99,37 @@ end Concat ``` + +### `localparam` {#localparam} + +A module-level `parameter` becomes a design constructor argument, as above. A body-level **`localparam`** becomes a `val` in the design body, but whether it survives into the generated HDL depends on whether it is a DFHDL constant: + +- `val X = ` is a DFHDL constant only if a DFHDL constant appears in its right-hand side. Otherwise it is plain Scala arithmetic, and its value is **inlined away**. +- `val X: Int <> CONST = ` is always a DFHDL constant, and is **emitted as `localparam int X`**. + +This is worth knowing because two adjacent, identically written `val`s can compile to different things depending only on the types of the operands in their initializers: + +```scala +class Foo( + val IMAGE_WIDTH: Int <> CONST = 640 +) extends EDDesign: + val WINDOW_BLOCKS = 64 / 16 // plain Scala arithmetic + val ROW_BLOCKS = IMAGE_WIDTH / 16 // derives from an Int <> CONST + val COLUMN_BLOCKS: Int <> CONST = 128 / 16 // ascription forces a DFHDL constant + // ... +``` + +```verilog title="Generated Verilog" +module Foo#(parameter int IMAGE_WIDTH = 640)( + output logic signed [31:0] o +); + localparam int ROW_BLOCKS = IMAGE_WIDTH / 16; + localparam int COLUMN_BLOCKS = 8; + assign o = 32'(ROW_BLOCKS + 4 + COLUMN_BLOCKS); +endmodule +``` + +`WINDOW_BLOCKS` has vanished into the literal `4`, while the other two survive. Ascribe `: Int <> CONST` wherever you want the `localparam` preserved in the emitted interface; leave it off when the value is only needed during elaboration. Note that the `Concat` example above deliberately uses the first form, so its `midLen` and `outlen` are elaboration-time only and emit nothing. /// /// admonition | Unconnected Output Ports @@ -391,21 +422,26 @@ always @(posedge clk) ``` ```scala linenums="0" title="DFHDL" -enum State extends Encoded: - case Ready, Aim, Fire -import State.* -val state = State <> VAR init Ready +class gun extends EDDesign: + //declared inside the design, like the + //Verilog module-local parameters it replaces + enum State extends Encoded: + case Ready, Aim, Fire + import State.* + val state = State <> VAR init Ready -process(clk.rising): - state match - case Ready => if (go) state :== Aim - case Aim => state :== Fire - case Fire => state :== Ready - case _ => state :== Ready + process(clk.rising): + state match + case Ready => if (go) state :== Aim + case Aim => state :== Fire + case Fire => state :== Ready + case _ => state :== Ready ``` +A Verilog `enum {IDLE, DRAW} state;` (or a set of state `parameter`s) is a **module-local** declaration, so the faithful translation declares the enum inside the design class. That also keeps per-module FSMs independent: several designs may each declare their own `State` without colliding, whereas two top-level enums of the same name in one compilation unit are a duplicate definition. See [Declaration Scope][DFEnum] for the details and for where the generated typedef ends up. + If the encoded Verilog state values follow a standard pattern (incremental, gray, one-hot), use the corresponding `Encoded` variant. For non-standard encodings, use `Encoded.Manual` with a constructor parameter: ```scala @@ -795,6 +831,7 @@ Verilog's descending and ascending part-select notation maps directly to DFHDL's | `sig[N-1 -: W]` | `sig.msbits(W)` or `sig(N-1, N-W)` | Top `W` bits | | `sig[0 +: W]` | `sig.lsbits(W)` or `sig(W-1, 0)` | Bottom `W` bits | | `sig[idx]` | `sig(idx)` | Single bit access | +| `sig[k*W +: W]` (runtime `k`) | `sig.lsbitsAt(k * W, W)` inside an ED `process` loop | Variable-base part-select, with `k` a process-scope `for` iterator |
@@ -824,7 +861,34 @@ val bit5 = data(5) // single bit
-The part-select base index and width must be elaboration-time constants (Scala `Int` values or `Int <> CONST` parameters), as in a Verilog constant part-select. +The part-select **width** must always be an elaboration-time constant (a Scala `Int` value or an `Int <> CONST` parameter). The **base** must be too when the part-select sits at design scope, matching a Verilog constant part-select. + +A Verilog **variable-base** part-select does have a direct equivalent: inside an ED `process`, a `for` loop stays a hardware loop, and its iterator is a legal part-select base: + +```scala +class Foo( + val LANE: Int <> CONST = 8, + val LANES: Int <> CONST = 4 +) extends EDDesign: + val data = Bits(LANE * LANES) <> IN + val sum = UInt(16) <> OUT + process(all): + sum := 0 + for (k <- 0 until LANES) + sum := sum + data.lsbitsAt(k * LANE, LANE).uint.resize(16) +``` + +```verilog title="Generated Verilog" +always_comb +begin + sum = 16'd0; + for (int k = 0; k < LANES; k = k + 1) begin + sum = sum + 16'(data[((k * LANE) + LANE) - 1:k * LANE]); + end +end +``` + +See [ED Domain Loops][loops] for the limits on that iterator: it is a hardware value, so it cannot be read into Scala or used to index a Scala collection. Bit-slicing and single-bit access work on `Bits`, `UInt`, and `SInt` values with the same syntax, including the `.msbits(W)`/`.lsbits(W)` and `.msbitsAt(base, W)`/`.lsbitsAt(base, W)` convenience methods. As in Verilog, a slice is a bit-level operation and yields an unsigned result (`SInt` slices return `UInt`, not `SInt`): @@ -891,6 +955,8 @@ sd"8'5" == (-3) // OK (-3 adapts to SInt[8]) To compare values of different widths, use `.resize(W)` to match widths first. To compare values of different signedness, convert explicitly (e.g., `.bits.sint` or `.signed`). +Comparisons are not a numeric-only facility. `Bits`, `Bit`, `Boolean`, `Enum`, `Struct`, and `Tuple` all support `==`/`!=` (only the ordering comparisons are restricted to the numeric types), so a Verilog `b_edge == 1'b0` or a state test translates directly. See [Comparison Operations][comparison-ops] for the operand rules per type. + **UInt-to-SInt conversion methods:** - `.signed` converts `UInt[W]` to `SInt[W+1]` by adding a sign bit. The value is preserved (always non-negative). @@ -1110,7 +1176,23 @@ end gate -**Important difference from Verilog:** DFHDL type-checks **both** branches, regardless of the condition's value, so both must be valid for all parameter values. Because the taken branch depends on the constant, instantiating the design with different parameter values produces distinct elaborated designs. See [Loops][loops] for the analogous elaboration-time loop behavior and workarounds. +**Difference from Verilog, and its limit:** Scala type-checks **both** branches, since both are ordinary Scala code. That only constrains you where the Scala type level actually tracks widths, which is when widths are **literal** (bounded): then both branches must be valid for every parameter value. When widths derive from `Int <> CONST` parameters the types are unbounded (`Bits[Int]`), Scala checks nothing about them, and the **elaboration-time** width check runs on the taken branch only. So a parameterized `generate if` whose branches are each valid only for their own parameter value translates directly, with no `.toScalaInt` and no `.resize` guard: + +```scala +class narrow( + val N: Int <> CONST = 4 +) extends EDDesign: + val din = Bits(N) <> IN + val dout = Bits(N) <> OUT + if (N == 4) + dout <> din + else + dout <> din.msbits(2) // Bits[2] into Bits[N]: never elaborated, never checked +``` + +Change `Bits(N)` to a literal `Bits(4)` and the same untaken branch is rejected before elaboration ever runs, with `The argument width (2) is different than the receiver width (4)`. + +Reaching for `BLOCK_WIDTH.toScalaInt == 1` to force a Scala-level branch is unnecessary here: the plain constant `if` already resolves during elaboration. Because the taken branch depends on the constant, instantiating the design with different parameter values produces distinct elaborated designs either way. See [Loops][loops] for the analogous elaboration-time loop behavior. /// ## Common Pitfalls diff --git a/docs/user-guide/compilation/index.md b/docs/user-guide/compilation/index.md index c24baa122..ae28a06a8 100755 --- a/docs/user-guide/compilation/index.md +++ b/docs/user-guide/compilation/index.md @@ -14,3 +14,84 @@ When [arithmetic operations][arithmetic-ops] involve wildcard `Int` values (Scal 2. **DFHDL elaboration-time**: non-literal Scala integers (e.g., `val x: Int = computeValue(); u8 + x`) and DFHDL `Int` constants whose values are resolved during elaboration. A DFHDL elaboration error (Scala runtime error) is generated if the value does not fit. 3. **Synthesis/simulation-time**: DFHDL `Int` parameters that are set externally or computed in complex generation loops may not be known until synthesis or simulation. Assertions must be added to verify these values at the target platform level. This is a planned future feature (TODO). + +--- + +## Generated Files {#generated-files} + +Compiling a design emits one file per design, plus up to two shared files. + +/// tab | Verilog +| File | Contents | +|------|----------| +| `.sv` | One file per design in the hierarchy | +| `dfhdl_defs.svh` | DFHDL's own macros and helper definitions. Always emitted, and `` `include ``d by every design. Public domain, so it can be redistributed with generated output | +| `_defs.svh` | **Your design's** global declarations, named after the top design. Emitted only when something needs to be shared across designs | +/// + +/// tab | VHDL +| File | Contents | +|------|----------| +| `.vhd` | One file per design in the hierarchy | +| `dfhdl_pkg.vhd` | DFHDL's own helper package. Always emitted | +| `_pkg.vhd` | **Your design's** global package, named after the top design. Emitted only when something needs to be shared across designs | +/// + +### The global definitions file {#global-defs} + +A declaration goes into `_defs.svh` / `_pkg.vhd` when more than one design must name it. The common case is an [enum][DFEnum] appearing in a port type, since the two modules on either side of the connection have to agree on the type: + +```scala +enum State extends Encoded: + case IDLE, RUN + +class lane extends EDDesign: + val s = State <> IN + // ... + +class Foo extends EDDesign: + val s = State <> IN + val u = new lane + u.s <> s +``` + +/// tab | Generated Verilog +```systemverilog title="Foo_defs.svh" +`ifndef FOO_DEFS +`define FOO_DEFS +typedef enum logic [0:0] { + State_IDLE = 0, + State_RUN = 1 +} t_enum_State; +`endif +``` + +```systemverilog title="Foo.sv" +`include "Foo_defs.svh" + +module Foo( + input wire t_enum_State s, + output logic o +); + `include "dfhdl_defs.svh" + // ... +``` +The file is include-guarded, so every design that needs it can include it unconditionally. +/// + +/// tab | Generated VHDL +```vhdl title="Foo_pkg.vhd" +package Foo_pkg is +type t_enum_State is ( + State_IDLE, State_RUN +); +function bitWidth(A: t_enum_State) return integer; +function to_slv(A: t_enum_State) return std_logic_vector; +function to_t_enum_State(A: std_logic_vector) return t_enum_State; +-- ... +end package Foo_pkg; +``` +The VHDL package carries the conversion and helper functions for the type alongside its declaration. +/// + +Had `State` been used only inside a single design, no `_defs` file would be emitted at all and the typedef would sit inside that one module. The placement follows the usage, not the Scala declaration site. diff --git a/docs/user-guide/design-hierarchy/index.md b/docs/user-guide/design-hierarchy/index.md index fa4d2c487..487a7bacb 100644 --- a/docs/user-guide/design-hierarchy/index.md +++ b/docs/user-guide/design-hierarchy/index.md @@ -694,8 +694,23 @@ Where: - A variable - A port of the parent design - A port of another child design instance + - An inline expression (a comparison, a logical combination, a slice, and so on) - `OPEN` - to explicitly leave an output port unconnected (see [Open Ports](#open-ports) below) +An inline expression needs no intermediate `val`, and is the direct equivalent of Verilog's `.port(expr)` connection at the instantiation site: + +```scala +val u = new lane +u.start <> (state == State.RUN) +``` + +```verilog title="Generated Verilog" +lane u(...); +assign u_start = state == State_RUN; +``` + +The expression elaborates as if an anonymous value held its result, and that value drives the child port. + The `<>` connection operator has no explicit directionality - it automatically infers producer/consumer relationships based on the connected value types and scope. See the [connectivity][connectivity] section for details. #### `LRShiftDirect` example {#LRShiftDirect} diff --git a/docs/user-guide/loops/index.md b/docs/user-guide/loops/index.md index f05e15507..939db0840 100644 --- a/docs/user-guide/loops/index.md +++ b/docs/user-guide/loops/index.md @@ -31,25 +31,165 @@ When a design containing an elaboration-time loop is instantiated with different ### Elaboration-Time Conditionals -Unlike Verilog `generate if`, DFHDL type-checks **both** branches of an `if` expression at elaboration time, regardless of the parameter value. This means both branches must be type-correct for all possible parameter values: +An `if` whose condition is a **constant** resolves during elaboration, so only the taken branch produces hardware. Both branches are still ordinary Scala code, though, so Scala type-checks both. Whether that rejects an untaken branch depends on whether the widths involved are **literal** or **parameterized**. + +/// admonition | Which `if` you get depends on the domain + type: note +In an **ED** design body, an implicit `.toScalaBoolean` is applied to a constant condition, so the `if` is a Scala `if` and resolves at elaboration. + +In an **RT** or **DF** design body, an `if` is a DFHDL (hardware) `if` unless its condition is a Scala `Boolean`. Force that with `.toScalaBoolean` when you want the elaboration-time behavior: + +```scala +if ((WIDTH == 4).toScalaBoolean) // a Scala `if` in an RT/DF body +``` + +A DFHDL `if` elaborates **both** branches, so a width that is invalid in either one is an error regardless of which is taken: + +``` +The argument width (((WIDTH - 1) - (WIDTH - 2)) + 1) is different than the receiver width (WIDTH). +``` +/// + +What decides the Scala-level check is the **type ascription** on the width, not the value. These two declarations look almost identical and behave differently: ```scala -// PROBLEM: when DEPTH == 1, the else branch has an invalid slice -if (DEPTH == 1) - out := in -else - out := (in, data(WIDTH - 1, ELEM_WIDTH)) // invalid range when DEPTH=1 +val WIDTH = 4 // a plain Scala Int: a literal the Scala type level tracks +val WIDTH: Int <> CONST = 4 // a DFHDL constant: unbounded at the Scala type level +``` + +**A plain Scala `Int`** gives `Bits(4)` the bounded type `Bits[4]`, so Scala tracks the width and rejects an invalid untaken branch at compile time: + +```scala +class narrow_lit extends EDDesign: + val WIDTH = 4 // plain Scala Int + val din = Bits(WIDTH) <> IN + val dout = Bits(WIDTH) <> OUT + if (WIDTH == 4) + dout <> din + else + dout <> din.msbits(2) // rejected even though this branch is never taken +``` + +``` +The argument width (2) is different than the receiver width (4). +Consider applying `.resize` to resolve this issue. +``` + +**An `Int <> CONST`** gives `Bits(WIDTH)` the unbounded type `Bits[Int]`, which the Scala type level does not track, so there is nothing for it to reject. The width check moves to elaboration, and elaboration only ever visits the taken branch: -// SOLUTION: use .resize or guard index computations -if (DEPTH == 1) - out := in.resize(WIDTH) -else - out := (in, data.msbits(WIDTH - ELEM_WIDTH)) +```scala +class narrow_const extends EDDesign: + val WIDTH: Int <> CONST = 4 // same value, ascribed as a DFHDL constant + val din = Bits(WIDTH) <> IN + val dout = Bits(WIDTH) <> OUT + if (WIDTH == 4) + dout <> din + else + dout <> din.msbits(2) // never elaborated, so never checked ``` +The same holds for a width that arrives as a design parameter (`class narrow(val WIDTH: Int <> CONST = 4)`), which is the usual case when translating a Verilog `parameter`. This is why a `generate if` whose branches are each valid only for their own parameter value translates directly, with no `.resize` guard and no `.toScalaInt`. If you do need both branches valid at the Scala level, use `.resize` or guard the index computations, as in the plain-`Int` example above. + +The ascription has a second, visible consequence: an `Int <> CONST` survives into the generated HDL as a `localparam`, while a plain Scala `Int` is inlined away. See [`localparam`][localparam] for that side of the same distinction. + ## ED Domain Loops -In ED designs, `for` and `while` loops inside processes produce combinational or sequential logic depending on the process type. These loops are unrolled by the compiler. +In ED designs, `for` and `while` loops inside processes produce combinational or sequential logic depending on the process type. Unlike a design-scope loop, a loop inside a process **stays a loop**: it is elaborated once and emitted as a real `for` in the generated HDL, as the `OnesCount` example below shows. + +That difference decides what its iterator is. A design-scope iterator is an ordinary Scala `Int`, so it can index Scala collections and be used anywhere Scala needs a number. A **process-scope iterator is a hardware value**, and cannot be read out into Scala at all, `.toScalaInt` included: + +```scala +val lanes = List.fill(3)(new lane) // a Scala collection, built at design scope +process(all): + for (i <- 0 until LANES) + out_bus.lsbitsAt(i * 8, 8) := lanes(i.toScalaInt).q // error +``` + +``` +Scala value access error! +Message: Cannot fetch a Scala value from a non-constant DFHDL value. +``` + +The reported position is the `i` in the `for` binding rather than the use that actually needs a Scala value, so read the message as "something in this loop body wanted a Scala `Int`" and look at the uses, not the range. + +To write per-index slices of a packed bus, do the work in a **design-scope** loop and give each iteration its own small process. The `i` is then a Scala value captured by closure, and no loop exists inside a process: + +```scala +class Foo(val LANES: Int <> CONST = 3) extends EDDesign: + val out_bus = Bits(8 * 3) <> OUT + for (i <- 0 until LANES) + val u = new lane + process(all): + out_bus.lsbitsAt(i * 8, 8) := u.q +``` + +/// tab | Generated Verilog +```verilog +module Foo#(parameter int LANES = 3)( + output logic [23:0] out_bus +); + logic [7:0] u_0_q; + logic [7:0] u_1_q; + logic [7:0] u_2_q; + lane u_0(.q /*-->*/ (u_0_q)); + lane u_1(.q /*-->*/ (u_1_q)); + lane u_2(.q /*-->*/ (u_2_q)); + always_comb + begin + out_bus[7:0] = u_0_q; + end + always_comb + begin + out_bus[15:8] = u_1_q; + end + always_comb + begin + out_bus[23:16] = u_2_q; + end +endmodule +``` +The design-scope loop unrolls: three `lane` instances and three separate `always_comb` blocks, each writing one static slice. No loop remains. +/// + +/// tab | Generated VHDL +```vhdl +entity Foo is +generic ( + LANES : integer := 3 +); +port ( + out_bus : out std_logic_vector(23 downto 0) +); +end Foo; + +architecture Foo_arch of Foo is + signal u_0_q : std_logic_vector(7 downto 0); + signal u_1_q : std_logic_vector(7 downto 0); + signal u_2_q : std_logic_vector(7 downto 0); +begin + u_0 : entity work.lane(lane_arch) port map (q => u_0_q); + u_1 : entity work.lane(lane_arch) port map (q => u_1_q); + u_2 : entity work.lane(lane_arch) port map (q => u_2_q); + process (all) + begin + out_bus(7 downto 0) <= u_0_q; + end process; + process (all) + begin + out_bus(15 downto 8) <= u_1_q; + end process; + process (all) + begin + out_bus(23 downto 16) <= u_2_q; + end process; +end Foo_arch; +``` +Same unrolling: three component instantiations and three processes, each driving one slice of `out_bus`. +/// + +Contrast this with the `OnesCount` example above, where the loop is **inside** the process and survives into the generated HDL as a real `for`. + +Arithmetic on the iterator that stays inside DFHDL is fine either way: a process-scope `i` is a valid part-select base (see [Bit Selection and Slicing][common-bit-vector-ops]), and emits a variable-base part-select. ### Loops Accumulation Example {#loop-accumulators} diff --git a/docs/user-guide/naming/index.md b/docs/user-guide/naming/index.md index c075eeb00..54b5b3478 100644 --- a/docs/user-guide/naming/index.md +++ b/docs/user-guide/naming/index.md @@ -69,29 +69,46 @@ val u_abs = new abs(DATA_WIDTH = 16) This is the [general `new` recommendation](#general-recommendation-instantiate-designs-with-new) applied to a built-in collision. Following it from the start makes this collision a non-issue. -### Design-class name colliding with a value name +### Design-class name shared with a value name -A common translation collision is a **design class** whose name is the same as a port or variable in scope (or a built-in). When the bare name `design_class_name(...)` resolves to the value instead of the class, use one of these two fixes: - -**1. Explicitly instantiate with `new`** (the recommended default form). The `new` keyword forces resolution to the class constructor, sidestepping the shadowing value: +A **design class** may share its name with a port or variable in scope. This is not a collision that needs resolving: Scala keeps types and terms in separate namespaces, so the class stays reachable and the value keeps its name. ```scala -// `design_class_name` the value is in scope and shadows the class -val u = new design_class_name(DATA_WIDTH = 16) +class stage(val WIDTH: Int <> CONST = 8) extends EDDesign: + val d = Bits(WIDTH) <> IN + val q = Bits(WIDTH) <> OUT + q <> d + +class wrapper(val WIDTH: Int <> CONST = 8) extends EDDesign: + val d = Bits(WIDTH) <> IN + val stage = Bits(WIDTH) <> OUT // port sharing the child class's name + + val stage_inst = new stage(WIDTH) // resolves to the class constructor + stage_inst.d <> d + stage <> stage_inst.q +``` + +```verilog title="Generated Verilog" +module wrapper#(parameter int WIDTH = 8)( + input wire logic [WIDTH - 1:0] d, + output logic [WIDTH - 1:0] stage +); + stage #(.WIDTH (WIDTH)) stage_inst(...); ``` -**2. Use a Capitalized class name plus `@targetName` to preserve the emitted name.** Rename the Scala class to a Capitalized identifier (which can no longer collide with a camelCase value) and pin the original lowercase name onto the generated HDL with `@targetName`: +The port keeps the name `stage` and the child module is still instantiated from the class of the same name. The one form that goes wrong is the bare apply, and only because the value wins the term position: ```scala -import scala.annotation.targetName +val stage_inst = stage(WIDTH) // NOT an instantiation: this bit-selects the port +``` -@targetName("design_class_name") -class DesignClassName(val DATA_WIDTH: Int <> CONST = 8) extends EDDesign: - // ... +It fails in a way worth recognizing, because it usually stays silent until something downstream reads a member off it: -// No collision with values; generated HDL module is still named "design_class_name" -val u = new DesignClassName(DATA_WIDTH = 16) ``` +value q is not a member of Bit <> VAR, but could be made available as an extension method. +``` + +That is `stage(WIDTH)` having quietly become a bit-select on the port. Always instantiating with `new`, per the [general recommendation](#general-recommendation-instantiate-designs-with-new), removes the whole class of problems and needs no renaming and no annotation. ## Resolution Patterns @@ -106,19 +123,36 @@ val `match` = Bit <> OUT ### `@targetName` annotation -When a Scala-side name must differ from the generated HDL name, use `@targetName` to set the hardware name explicitly. This is useful when: - -- A port name conflicts with a sub-module class name in the same design. -- You want to rename a Scala identifier but preserve the original Verilog port/module name (see [Design-class name colliding with a value name](#design-class-name-colliding-with-a-value-name)). +`@targetName` applies to a **port or variable**, and sets the name that value carries in the generated HDL. Use it when the Scala-side identifier must differ from the HDL name you need to emit: ```scala import scala.annotation.targetName -// Port "kernel" conflicts with class "kernel" in scope -@targetName("kernel") -val kernel_out = Bits(WIDTH) <> OUT -// Generated HDL port is still named "kernel" +class filter(val WIDTH: Int <> CONST = 8) extends EDDesign: + @targetName("data_out") + val dataOut = Bits(WIDTH) <> OUT + dataOut <> all(0) +``` -// The class "kernel" remains available for instantiation -val u_kernel = new kernel() +```verilog title="Generated Verilog" +module filter#(parameter int WIDTH = 8)( + output logic [WIDTH - 1:0] data_out +); ``` + +The same annotation applies to a **design class**, where it sets the emitted module name. This is what lets a translation follow Scala naming style on the Scala side while still emitting the original Verilog module name: + +```scala +import scala.annotation.targetName + +@targetName("data_path") +class DataPath(val WIDTH: Int <> CONST = 8) extends EDDesign: + // ... +// Generated HDL module is named "data_path" +val u_dp = new DataPath(16) +``` + +/// admonition | Not for names that merely look alike + type: note +A port sharing a name with a design class needs no annotation and no rename, since types and terms live in separate namespaces. See [Design-class name shared with a value name](#design-class-name-shared-with-a-value-name). Reach for `@targetName` when you need a **different** HDL name, not when two Scala names collide. +/// diff --git a/docs/user-guide/type-system/index.md b/docs/user-guide/type-system/index.md index 739feacf4..02b28d103 100755 --- a/docs/user-guide/type-system/index.md +++ b/docs/user-guide/type-system/index.md @@ -219,6 +219,8 @@ class Foo extends DFDesign: o := 0 ``` +* Named DFTypes, such as an [enum][DFEnum], a [struct][DFStruct], or an [opaque][DFOpaque] type, may be declared at global scope (shared across designs) **or** inside a design class body (private to that design). Unlike ports and variables, they are not restricted to non-global scopes. See [Declaration Scope][DFEnum] for which to choose and what it means for the generated HDL. + #### Naming {#dcl-naming} Ports and variables must always be named, and cannot be anonymous. @@ -976,6 +978,8 @@ class Foo extends RTDesign: ``` /// +**Operations on this type:** [Logical Operations][logical-ops] (`&`, `|`, `^`, `~`, `&&`, `||`, `!`), [Comparison Operations][comparison-ops] (`==`/`!=` only, with a `Bit`/`Boolean` as an operand), [Selection][sel-ops] (as the condition, and as a selected argument), [Edge Detection][history-ops] (`.rising`, `.falling`, `Bit` only), and [conversions to and from `Bits`/`Boolean`][bit-bool-cast]. + ### `Bits` {#DFBits} `Bits` DFHDL values represent vectors of DFHDL `Bit` values as elements. @@ -1338,6 +1342,56 @@ enum MyEnum extends Encoded: #### Type Signatures `MyEnum <> VAL`, `MyEnum <> CONST`. The enum name itself is the type, with no size parameter. +#### Declaration Scope + +An enum may be declared at **top level**, where it is shared by every design in the compilation unit, or **inside a design class body**, where it is private to that design. A per-design FSM state type belongs inside the class: + +```scala +class Child extends EDDesign: + enum State extends Encoded: + case IDLE, DRAW + val state = State <> VAR + // ... + +class Parent extends EDDesign: + enum State extends Encoded: + case IDLE, BUSY, HOLD + val state = State <> VAR + val c = new Child +``` + +Where the **generated** typedef lands is decided by **usage**, not by where the enum is declared in Scala. An enum used inside a single design is emitted module-scoped; an enum that has to be visible to more than one design (because it appears in a port type, for instance) is emitted into the design's [global definitions file][global-defs] instead. + +Because a single-design enum is module-scoped, two designs may nest identically named enums of different widths with no conflict: + +```systemverilog +module Child(...); + typedef enum logic [0:0] { State_IDLE = 0, State_DRAW = 1 } t_enum_State; + t_enum_State state; +endmodule + +module Parent(...); + typedef enum logic [1:0] { State_IDLE = 0, State_BUSY = 1, State_HOLD = 2 } t_enum_State; + t_enum_State state; + Child c(...); +endmodule +``` + +Put the same enum in a port type and it moves out to the shared file, since both modules must name the same type. That is a property of how the type is used, so a top-level Scala declaration is not required to get it, and does not by itself cause it. + +/// admonition | Two top-level enums of the same name + type: warning +Declaring the same enum name at top level in two files of one compilation unit is a duplicate definition. The first error says so plainly, but the errors after it do not: + +``` +State is already defined as class State in ./src/ModA.scala +value IDLE is not a member of State$2 +value BUSY is not a member of State, but could be made available as an extension method. +``` + +The mangled `State$2` and the import suggestions that follow are dead ends. Read the first line, and prefer nesting the enum inside the design that uses it. +/// + #### Encoding Types DFHDL supports several encoding schemes for enums: @@ -1410,6 +1464,8 @@ class CPU extends RTDesign: // Store state logic ``` +**Operations on this type:** [Comparison Operations][comparison-ops] (`==`/`!=` between values of the same enum type), [Selection][sel-ops] (enum values are valid `.sel` arguments), [pattern matching](#pattern-matching), and [`Enum` to `UInt` conversion][enum-uint-cast]. + ### Vector {#DFVector} DFHDL vectors allow creating arrays of any DFHDL type. Unlike `Bits` which is specialized for bit vectors, generic vectors can hold any DFHDL type and support multi-dimensional arrays. @@ -1779,6 +1835,13 @@ class Example extends EDDesign: ``` ## Operations +/// admonition | Which operations apply to which types + type: note +Every section in this part opens with an **`Applies to:`** line naming the types the operation accepts. Those lines are the reference for "does this type support this operation": they are meant to be exhaustive, so a type absent from one is a type the operation does not accept. + +Read that line before concluding an operation is unavailable. The operations reference sits below the whole type reference, so it is easy to work through the type sections and never reach the section that actually decides whether an expression is legal. +/// + ### Constant Propagation When all operands of an expression are constants (`CONST`), the result is also a constant. This includes Scala `Int` literals, DFHDL `Int` parameters, and bit-accurate constants created with `d""` or `sd""`. @@ -1889,7 +1952,7 @@ Applies to: `Bits`, `UInt`, `SInt` - **Range slice**: `value(hi, lo)` extracts bits `hi` down to `lo`. A slice is a bit-level operation and produces an unsigned result: `Bits` → `Bits`, `UInt` → `UInt`, `SInt` → `UInt`. This matches Verilog's "slices are unsigned" convention. To recover signed bit-semantics on an `SInt` slice, chain `.bits.sint` to re-interpret the slice as signed (same width). Do **not** use `.signed` for this: `.signed` is a numeric conversion that adds a zero-extension sign bit, widening by 1. - **Top/bottom slice**: `value.msbits(W)` returns the top `W` bits and `value.lsbits(W)` returns the bottom `W` bits, with the same unsigned-result rule as range slicing (`Bits` → `Bits`, `UInt` → `UInt`, `SInt` → `UInt`). Equivalent to `value(N-1, N-W)` and `value(W-1, 0)` respectively, but without needing to spell out the indices. -- **Part-select (anchored slice)**: `value.lsbitsAt(baseIdx, selWidth)` returns `selWidth` bits whose LSB is anchored at `baseIdx`, and `value.msbitsAt(baseIdx, selWidth)` returns `selWidth` bits whose MSB is anchored at `baseIdx`. These are the DFHDL equivalents of Verilog's ascending (`value[baseIdx +: selWidth]`) and descending (`value[baseIdx -: selWidth]`) part-selects, equivalent to `value(baseIdx + selWidth - 1, baseIdx)` and `value(baseIdx, baseIdx - selWidth + 1)` respectively, with the same unsigned-result rule. The generalization of the top/bottom slices: `msbits(W)` is `msbitsAt(N-1, W)` and `lsbits(W)` is `lsbitsAt(0, W)`. Both arguments must be elaboration-time constants (Scala `Int` values or `Int` parameters). +- **Part-select (anchored slice)**: `value.lsbitsAt(baseIdx, selWidth)` returns `selWidth` bits whose LSB is anchored at `baseIdx`, and `value.msbitsAt(baseIdx, selWidth)` returns `selWidth` bits whose MSB is anchored at `baseIdx`. These are the DFHDL equivalents of Verilog's ascending (`value[baseIdx +: selWidth]`) and descending (`value[baseIdx -: selWidth]`) part-selects, equivalent to `value(baseIdx + selWidth - 1, baseIdx)` and `value(baseIdx, baseIdx - selWidth + 1)` respectively, with the same unsigned-result rule. The generalization of the top/bottom slices: `msbits(W)` is `msbitsAt(N-1, W)` and `lsbits(W)` is `lsbitsAt(0, W)`. The **width** must always be an elaboration-time constant (a Scala `Int` value or an `Int` parameter). The **base** must be too, with one exception: inside an ED `process`, the base may be an expression over a process-scope `for` iterator, which emits a variable-base part-select. See [ED Domain Loops][loops] for what that iterator is and is not. - **Single-bit access**: `value(idx)` returns the bit at position `idx` (as `Bit`). The index can be a static integer or a dynamic `UInt` variable. ```scala @@ -1995,6 +2058,22 @@ val wide = (u8, u4).toBits // Bits[12] Values are concatenated from the first (most-significant) to the last (least-significant) position. +/// admonition | Building a value from a Scala collection + type: note +To assemble a value from a collection of **single-bit** sources, connect each bit of the target individually in a loop: + +```scala +class Foo extends EDDesign: + val data = Bits(9 * 8) <> IN + val thr = UInt(8) <> IN + val flags = Bits(9) <> OUT + for (i <- 0 until 9) + flags(i) <> (data.lsbitsAt(i * 8, 8).uint >= thr) +``` + +For **wider lanes**, accumulate with a Scala `#!scala var` ascribed to an unbounded `Bits[Int]`, as in the [`LaneConcat` example][scala-var]. The ascription is what keeps the accumulator's width from being fixed by the first element. +/// + ### Logical Operations {#logical-ops} Applies to: `Bit`, `Boolean`. The bitwise NOT (`~`) additionally applies to `Bits` and `UInt` vectors. @@ -2123,7 +2202,7 @@ val parity = b8.^ // Bit: 1 when odd number of bits are 1 ### Selection (`.sel`) {#sel-ops} -Condition: `Bit`, `Boolean`. Arguments: any DFHDL type. +Applies to: any DFHDL type as the selected arguments; the condition itself is a `Bit` or `Boolean`. The `.sel` operation is a conditional selection, equivalent to Verilog's ternary operator `cond ? onTrue : onFalse`. It selects between two values based on a `Bit` or `Boolean` condition: @@ -2505,7 +2584,7 @@ val e1 = param +^ 1 ### Comparison Operations (`==`, `!=`, `<`, `>`, `<=`, `>=`) {#comparison-ops} -Applies to: `UInt`, `SInt`, `Int`, `Double` (all comparisons); `Bits`, `Enum`, `Struct`, `Tuple` (`==`/`!=` only) +Applies to: `UInt`, `SInt`, `Int`, `Double` (all comparisons); `Bits`, `Bit`, `Boolean`, `Enum`, `Struct`, `Tuple` (`==`/`!=` only) #### Decimal Comparisons @@ -2580,6 +2659,29 @@ val isDec = b8 == d"8'12" // Boolean: match with sized decimal // b8 == all(0) OR b8 == d"8'0" OR b8.uint == 0 ``` +#### `Bit`/`Boolean` Comparisons + +A `Bit` or `Boolean` is a valid **operand** of `==`/`!=`, against another `Bit`/`Boolean` or against the `0`/`1` and `true`/`false` literals. The result is a `Boolean`, like every other comparison: + +```scala +val b1, b2 = Bit <> VAR +val bl = Boolean <> VAR + +val c1 = b1 == 0 // Boolean +val c2 = b1 != 1 // Boolean +val c3 = b1 == b2 // Boolean +val c4 = bl == true // Boolean +``` + +`b == 0` and `!b` describe the same hardware. A Verilog translation produces the former (Verilog spells the test `b == 1'b0`), while idiomatic DFHDL tends toward the latter; use whichever keeps the source recognizable. + +Ordering comparisons (`<`, `>`, `<=`, `>=`) do **not** apply to `Bit`/`Boolean`. They are rejected at compile time: + +```scala +// error: Cannot compare DFHDL value of type `Bit` with value of type `1`. +val e1 = b1 < 1 +``` + #### Enum, Struct, and Tuple Comparisons Enums, structs, and tuples support equality comparisons (`==` and `!=`) between values of the same type: From 506c3f4293603f74cd3a93e0f9fc28d44982530a Mon Sep 17 00:00:00 2001 From: Oron Port Date: Fri, 7 Aug 2026 14:29:21 +0300 Subject: [PATCH 03/25] plugin+core+platforms: `@hw.annotation.setName` replaces `@targetName` for HDL naming (#454) Scala rejects `@targetName` on a top-level class, so a design could not emit a module name different from its Scala class name. DFHDL naming now reads a dedicated `@dfhdl.hw.annotation.setName` annotation in the plugin's `getFinalName`, covering ports, variables, methods, design parameters, and design classes (including top-level ones) through the one mechanism. The annotation is a plain StaticAnnotation, kept out of IR annotation lists; `@targetName` remains a purely Scala/JVM concern (the AES defs keep it for erasure disambiguation alongside the new annotation). Platforms submodule migrated accordingly. Co-Authored-By: Claude Fable 5 --- .../StagesSpec/PrintCodeStringSpec.scala | 21 +++++++++++++++++++ core/src/main/scala/dfhdl/hw/annotation.scala | 7 +++++++ core/src/test/scala/CoreSpec/DFTypeSpec.scala | 2 +- core/src/test/scala/CoreSpec/PluginSpec.scala | 4 ++-- docs/transitioning/from-verilog/index.md | 7 +++---- docs/user-guide/naming/index.md | 16 ++++++-------- lib/src/test/scala/AES/defs.scala | 3 +++ platforms | 2 +- .../src/main/scala/plugin/CommonPhase.scala | 8 ++++--- 9 files changed, 49 insertions(+), 21 deletions(-) diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala index e0a2edf34..e2ae87490 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala @@ -3050,4 +3050,25 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): |""".stripMargin ) } + // The design and port names come from the DFHDL `setName` annotation, decoupled from the + // Scala identifiers (issue #454) + test("Design class and port renaming via `setName` annotation") { + @hw.annotation.setName("data_path") + class DataPath(val WIDTH: Int <> CONST = 8) extends EDDesign: + @hw.annotation.setName("data_in") + val dataIn = Bits(WIDTH) <> IN + @hw.annotation.setName("data_out") + val dataOut = Bits(WIDTH) <> OUT + dataOut <> dataIn + end DataPath + assertCodeString( + (new DataPath()), + """|class data_path(val WIDTH: Int <> CONST = 8) extends EDDesign: + | val data_in = Bits(WIDTH) <> IN + | val data_out = Bits(WIDTH) <> OUT + | data_out <> data_in + |end data_path + |""".stripMargin + ) + } end PrintCodeStringSpec diff --git a/core/src/main/scala/dfhdl/hw/annotation.scala b/core/src/main/scala/dfhdl/hw/annotation.scala index 406f7567d..d7d7fb6ce 100644 --- a/core/src/main/scala/dfhdl/hw/annotation.scala +++ b/core/src/main/scala/dfhdl/hw/annotation.scala @@ -19,6 +19,13 @@ object annotation: case annot: HWAnnotation if annot.isActive => annot.asIR } + /** Sets the emitted HDL name of the annotated construct, decoupling it from its Scala identifier. + * Applies to design classes (setting the emitted module/entity name), ports, variables, design + * parameters, and DFHDL methods. The name is consumed at compile time by the DFHDL compiler + * plugin, so it must be a string literal. + */ + final class setName(val name: String) extends StaticAnnotation + object unused: /** `quiet` suppresses the unused warning for the tagged value. */ diff --git a/core/src/test/scala/CoreSpec/DFTypeSpec.scala b/core/src/test/scala/CoreSpec/DFTypeSpec.scala index daf85ad21..b42d5ed91 100644 --- a/core/src/test/scala/CoreSpec/DFTypeSpec.scala +++ b/core/src/test/scala/CoreSpec/DFTypeSpec.scala @@ -101,7 +101,7 @@ class DFTypeSpec extends DFSpec: assertPluginError( """|Unsupported DFHDL member name x y. |Only alphanumric or underscore characters are supported. - |You can leave the Scala name as-is and add @targetName("newName") annotation.""".stripMargin + |You can leave the Scala name as-is and add @hw.annotation.setName("newName") annotation.""".stripMargin )( """ class Foo extends DFDesign: diff --git a/core/src/test/scala/CoreSpec/PluginSpec.scala b/core/src/test/scala/CoreSpec/PluginSpec.scala index 60b1f04df..32a8d5286 100644 --- a/core/src/test/scala/CoreSpec/PluginSpec.scala +++ b/core/src/test/scala/CoreSpec/PluginSpec.scala @@ -3,7 +3,7 @@ import dfhdl.* import munit.* import internals.* -import scala.annotation.{Annotation, nowarn, targetName} +import scala.annotation.{Annotation, nowarn} import scala.collection.immutable.ListMap class PluginSpec extends DFSpec: @@ -158,7 +158,7 @@ class PluginSpec extends DFSpec: case object FooCaseObj extends Foo(1, 2) assertLastNames("FooCaseObj") - @targetName("foo") + @hw.annotation.setName("foo") val -- = new Foo(1, 2) assertLastNames("foo") val fooCls2 = new Foo(1, 2): diff --git a/docs/transitioning/from-verilog/index.md b/docs/transitioning/from-verilog/index.md index f6cbd6f78..6753f6f88 100644 --- a/docs/transitioning/from-verilog/index.md +++ b/docs/transitioning/from-verilog/index.md @@ -1218,7 +1218,7 @@ class foo extends EDDesign: -Alternatively, use a non-keyword name with the Scala `@targetName` annotation to set the actual HDL name: +Alternatively, use a non-keyword name with the `@hw.annotation.setName` annotation to set the actual HDL name:
@@ -1231,16 +1231,15 @@ endmodule ``` ```scala linenums="0" title="DFHDL" -import scala.annotation.targetName class foo extends EDDesign: - @targetName("class") + @hw.annotation.setName("class") val class_ = SInt(16) <> OUT class_ <> 42 ```
-Beyond Scala keywords, Verilog module names may also conflict with DFHDL built-in functions brought in by `import dfhdl.*` (e.g., `abs`, `max`, `min`) or with other class names in the same design hierarchy. See [Naming][naming] for the full list of reserved names and resolution patterns (`@targetName`, type aliases, backtick escaping). +Beyond Scala keywords, Verilog module names may also conflict with DFHDL built-in functions brought in by `import dfhdl.*` (e.g., `abs`, `max`, `min`) or with other class names in the same design hierarchy. See [Naming][naming] for the full list of reserved names and resolution patterns (`@hw.annotation.setName`, type aliases, backtick escaping). /// /// admonition | `Bits` Initialization or Assignment diff --git a/docs/user-guide/naming/index.md b/docs/user-guide/naming/index.md index 54b5b3478..774f84209 100644 --- a/docs/user-guide/naming/index.md +++ b/docs/user-guide/naming/index.md @@ -121,15 +121,13 @@ val `type` = UInt(8) <> IN val `match` = Bit <> OUT ``` -### `@targetName` annotation +### `@setName` annotation -`@targetName` applies to a **port or variable**, and sets the name that value carries in the generated HDL. Use it when the Scala-side identifier must differ from the HDL name you need to emit: +`@hw.annotation.setName` applies to a **port, variable, or DFHDL method**, and sets the name that construct carries in the generated HDL. Use it when the Scala-side identifier must differ from the HDL name you need to emit: ```scala -import scala.annotation.targetName - class filter(val WIDTH: Int <> CONST = 8) extends EDDesign: - @targetName("data_out") + @hw.annotation.setName("data_out") val dataOut = Bits(WIDTH) <> OUT dataOut <> all(0) ``` @@ -140,12 +138,10 @@ module filter#(parameter int WIDTH = 8)( ); ``` -The same annotation applies to a **design class**, where it sets the emitted module name. This is what lets a translation follow Scala naming style on the Scala side while still emitting the original Verilog module name: +The same annotation applies to a **design class**, where it sets the emitted module name. This is what lets a translation follow Scala naming style on the Scala side while still emitting the original Verilog module name. It works on top-level classes too, where Scala's own `@targetName` is rejected (the DFHDL compiler plugin reads the annotation, not the Scala backend): ```scala -import scala.annotation.targetName - -@targetName("data_path") +@hw.annotation.setName("data_path") class DataPath(val WIDTH: Int <> CONST = 8) extends EDDesign: // ... // Generated HDL module is named "data_path" @@ -154,5 +150,5 @@ val u_dp = new DataPath(16) /// admonition | Not for names that merely look alike type: note -A port sharing a name with a design class needs no annotation and no rename, since types and terms live in separate namespaces. See [Design-class name shared with a value name](#design-class-name-shared-with-a-value-name). Reach for `@targetName` when you need a **different** HDL name, not when two Scala names collide. +A port sharing a name with a design class needs no annotation and no rename, since types and terms live in separate namespaces. See [Design-class name shared with a value name](#design-class-name-shared-with-a-value-name). Reach for `@hw.annotation.setName` when you need a **different** HDL name, not when two Scala names collide. /// diff --git a/lib/src/test/scala/AES/defs.scala b/lib/src/test/scala/AES/defs.scala index a24b39f78..2e9e8f577 100644 --- a/lib/src/test/scala/AES/defs.scala +++ b/lib/src/test/scala/AES/defs.scala @@ -41,6 +41,7 @@ extension (lhs: AESByte <> VAL) // corresponding powers in the polynomials for the two elements. The addition is performed with // the XOR operation. @targetName("addByte") + @hw.annotation.setName("addByte") @inline def +(rhs: AESByte <> VAL): AESByte <> DFRET = (lhs.actual ^ rhs.actual).as(AESByte) private def xtime: AESByte <> DFRET = lhs.mapActual: lhs => @@ -63,6 +64,7 @@ extension (lhs: Byte <> CONST) // its own design). The declaration is explicit because the automatic attribution // cannot trace the forcing through the foldLeft lambda's pattern-bound index. @targetName("mulByte") + @hw.annotation.setName("mulByte") @hw.annotation.pure(true, "lhs") def *(rhs: AESByte <> VAL): AESByte <> DFRET = val a = LazyList.iterate(rhs)(_.xtime) @@ -78,6 +80,7 @@ case class AESWord() extends Column(AESByte, 4) extension (lhs: AESWord <> VAL) @targetName("addWord") + @hw.annotation.setName("addWord") @inline def +(rhs: AESWord <> VAL): AESWord <> DFRET = lhs.zipMapElems(rhs)(_ + _) diff --git a/platforms b/platforms index 575c74316..8ee7cfc1a 160000 --- a/platforms +++ b/platforms @@ -1 +1 @@ -Subproject commit 575c743165b1e580844d555b6bb3fd763de6098f +Subproject commit 8ee7cfc1a62a588dab85a19398a34c6a8086cd29 diff --git a/plugin/src/main/scala/plugin/CommonPhase.scala b/plugin/src/main/scala/plugin/CommonPhase.scala index bc0993679..090922e4b 100755 --- a/plugin/src/main/scala/plugin/CommonPhase.scala +++ b/plugin/src/main/scala/plugin/CommonPhase.scala @@ -19,7 +19,6 @@ import dotty.tools.dotc.ast.tpd.Tree import annotation.tailrec import scala.language.implicitConversions import scala.compiletime.uninitialized -import scala.annotation.targetName given canEqualNothingL: CanEqual[Nothing, Any] = CanEqual.derived given canEqualNothingR: CanEqual[Any, Nothing] = CanEqual.derived @@ -169,6 +168,7 @@ abstract class CommonPhase extends PluginPhase: var contextFunctionSym: Symbol = uninitialized var hasDFCTpe: TypeRef = uninitialized var inlineAnnotSym: Symbol = uninitialized + var setNameAnnotSym: ClassSymbol = uninitialized var dfValSym: Symbol = uninitialized var constModTpe: Type = uninitialized var inArgAnnotSym: Symbol = uninitialized @@ -203,6 +203,7 @@ abstract class CommonPhase extends PluginPhase: if (tree.span.exists) t.cloneIn(ctx.source).withSpan(tree.span) else t } + end unapply end HackedGuard extension (tree: TypeDef) @@ -375,7 +376,7 @@ abstract class CommonPhase extends PluginPhase: extension (sym: Symbol)(using Context) def getFinalName(name: String = sym.name.toString): String = - sym.getAnnotation(defn.TargetNameAnnot) + sym.getAnnotation(setNameAnnotSym) .flatMap(_.argumentConstantString(0)) .getOrElse(name) @@ -390,7 +391,7 @@ abstract class CommonPhase extends PluginPhase: report.error( s"""Unsupported DFHDL member name $finalName. |Only alphanumric or underscore characters are supported. - |You can leave the Scala name as-is and add @targetName("newName") annotation.""".stripMargin, + |You can leave the Scala name as-is and add @hw.annotation.setName("newName") annotation.""".stripMargin, posTree.srcPos ) finalName @@ -548,6 +549,7 @@ abstract class CommonPhase extends PluginPhase: positionGenSym = requiredMethod("dfhdl.internals.Position.fromAbsPath") hasDFCTpe = requiredClassRef("dfhdl.core.HasDFC") inlineAnnotSym = requiredClass("scala.inline") + setNameAnnotSym = requiredClass("dfhdl.hw.annotation.setName") constModTpe = requiredClassRef("dfhdl.core.ISCONST").appliedTo(ConstantType(Constant(true))) inArgAnnotSym = requiredClass("dfhdl.core.IN") outArgAnnotSym = requiredClass("dfhdl.core.OUT") From a3a157c05a37ca3225e789d0efc71bdb1463f6bd Mon Sep 17 00:00:00 2001 From: Oron Port Date: Fri, 7 Aug 2026 15:44:52 +0300 Subject: [PATCH 04/25] plugin+docs: forbid multi-block DFHDL parameters; defaults only in the first block (#456) A default value on a DFHDL parameter in a non-first (curried) parameter block crashed scalac at Erasure: the companion default getter takes the earlier blocks' parameters as arguments, which the plugin's parameter threading cannot consume. MetaContextPlacerPhase now reports clear errors instead: a design/interface class must declare all its DFHDL parameters in a single parameter block, and a defaulted DFHDL parameter must be in the first block. Plain Scala blocks stay free, so a dependent-typed DFHDL block after a plain block (RTGenericRom's `depth.type`-sized ROM) remains legal. Co-Authored-By: Claude Fable 5 --- core/src/test/scala/CoreSpec/DFTypeSpec.scala | 30 +++++++++++++++++++ docs/transitioning/from-verilog/index.md | 2 ++ .../scala/plugin/MetaContextPlacerPhase.scala | 30 +++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/core/src/test/scala/CoreSpec/DFTypeSpec.scala b/core/src/test/scala/CoreSpec/DFTypeSpec.scala index b42d5ed91..c7ebd779b 100644 --- a/core/src/test/scala/CoreSpec/DFTypeSpec.scala +++ b/core/src/test/scala/CoreSpec/DFTypeSpec.scala @@ -177,4 +177,34 @@ class DFTypeSpec extends DFSpec: val x = UInt(8) <> VAR """ ) + + test("multiple DFHDL parameter blocks for a design class"): + val expectedErr = + """|A DFHDL design/interface class must declare all its DFHDL parameters in a single parameter block. + |For a parameter that depends on an earlier parameter, use a derived value in the class body instead.""".stripMargin + // a default value in a second parameter block used to crash the compiler (issue #456) + assertPluginError(expectedErr)( + """ + class Foo(val A: Int <> CONST = 4)(val B: Int <> CONST = 8) extends DFDesign: + val o = UInt(8) <> OUT + """ + ) + // forbidden also without default values + assertPluginError(expectedErr)( + """ + class Foo(val A: Int <> CONST)(val B: Int <> CONST) extends DFDesign: + val o = UInt(8) <> OUT + """ + ) + // a single DFHDL parameter block AFTER a plain Scala block is allowed (dependent + // types require it), but a default value there generates a default getter taking + // the earlier block's parameters, which used to crash the compiler (issue #456) + assertPluginError( + "A DFHDL parameter with a default value must be declared in the first parameter block." + )( + """ + class Foo(val a: Int)(val B: Int <> CONST = 8) extends DFDesign: + val o = UInt(8) <> OUT + """ + ) end DFTypeSpec diff --git a/docs/transitioning/from-verilog/index.md b/docs/transitioning/from-verilog/index.md index 6753f6f88..7309e3ec3 100644 --- a/docs/transitioning/from-verilog/index.md +++ b/docs/transitioning/from-verilog/index.md @@ -100,6 +100,8 @@ end Concat +A Verilog parameter whose default references an earlier parameter cannot stay a parameter in DFHDL: Scala does not allow a default value to reference an earlier parameter of the same parameter block, and DFHDL requires all its parameters in a single block (a curried second block is rejected with a clear error). Declare it as a derived value in the design body instead, like `midLen` and `outlen` above. + ### `localparam` {#localparam} A module-level `parameter` becomes a design constructor argument, as above. A body-level **`localparam`** becomes a `val` in the design body, but whether it survives into the generated HDL depends on whether it is a DFHDL constant: diff --git a/plugin/src/main/scala/plugin/MetaContextPlacerPhase.scala b/plugin/src/main/scala/plugin/MetaContextPlacerPhase.scala index 815f6213f..a8a1a6f9f 100644 --- a/plugin/src/main/scala/plugin/MetaContextPlacerPhase.scala +++ b/plugin/src/main/scala/plugin/MetaContextPlacerPhase.scala @@ -269,6 +269,36 @@ class MetaContextPlacerPhase(setting: Setting) extends CapturePhase, IdentityDen // parameters into design-parameter members — other `HasClsMeta` classes (e.g. // platform resources) may carry DFHDL-value parameters that must stay untouched val hasClsArgs = clsTpe <:< hasClsArgsTpe + // a design/interface class must declare all its DFHDL parameters in a single + // parameter block: the plugin's parameter threading assumes it, and a default + // value in a later DFHDL block crashes the compiler downstream (issue #456). + // Plain Scala parameter blocks are free, so a dependent-typed DFHDL parameter + // block after a plain block (e.g. RTGenericRom's `depth.type`-sized ROM) stays + // legal + if (hasClsArgs) + val termParamss = template.constr.termParamss + termParamss.filter(_.exists(_.dfValTpeOpt.nonEmpty)) match + case _ :: secondBlock :: _ => + report.error( + """|A DFHDL design/interface class must declare all its DFHDL parameters in a single parameter block. + |For a parameter that depends on an earlier parameter, use a derived value in the class body instead.""".stripMargin, + secondBlock.head.srcPos + ) + case _ => + // a DFHDL parameter default in a non-first block generates a default getter + // that takes the earlier blocks' parameters as arguments, which the + // parameter threading cannot consume + for + block <- termParamss.drop(1) + v <- block + if v.dfValTpeOpt.nonEmpty && v.symbol.is(HasDefault) + do + report.error( + "A DFHDL parameter with a default value must be declared in the first parameter block.", + v.srcPos + ) + end match + end if val (updatedBody, containerParamGenValDefs) = dfcArgOpt match case Some(dfcTree) if hasClsArgs => val defaults = defaultParamMap.getOrElse(clsSym, Map.empty) From bdf077ccd6ccaaaa270e5ddf1bba11ab38c4dedb Mon Sep 17 00:00:00 2001 From: Oron Port Date: Fri, 7 Aug 2026 16:00:27 +0300 Subject: [PATCH 05/25] docs: enum match wildcard `case _` rules for translations and fresh FSMs Fixes #459 Co-Authored-By: Claude Fable 5 --- .claude/commands/verilog-to-dfhdl.md | 3 ++- docs/transitioning/from-verilog/index.md | 4 +++ docs/user-guide/conditionals/index.md | 31 +++++++++++++++++++----- docs/user-guide/type-system/index.md | 2 ++ 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/.claude/commands/verilog-to-dfhdl.md b/.claude/commands/verilog-to-dfhdl.md index 5161086ec..9f9d20c34 100644 --- a/.claude/commands/verilog-to-dfhdl.md +++ b/.claude/commands/verilog-to-dfhdl.md @@ -13,7 +13,8 @@ for all the mechanics this skill deliberately omits: - **[Transitioning from Verilog][from-verilog]** (`docs/transitioning/from-verilog/index.md`) - module/param/port mapping, `logic`/`reg`/`wire` → `VAR init`, `UInt`/`Bits`/`SInt` choice, numeric literals, `$clog2` → `.until`/`.to`, `always` → `process`, blocking/non-blocking → `:=`/`:==`, FSM → - `enum extends Encoded`, integer `case` → `match`, functions/tasks → methods, all operators (shift, + `enum extends Encoded`, integer `case` → `match`, `default:`/`others` → `case _` (kept for formal + equivalence unless binary encoding with exactly 2^n cases), functions/tasks → methods, all operators (shift, `|&~`, reductions, `.repeat`, `++`/`.toBits`, part-select `-:`/`+:`, signed arithmetic), `generate for` → Scala `for`, reserved-keyword escaping (backtick / `@targetName`), `Bits` init `all(0)`, ternary → `.sel`. diff --git a/docs/transitioning/from-verilog/index.md b/docs/transitioning/from-verilog/index.md index 7309e3ec3..7d045d450 100644 --- a/docs/transitioning/from-verilog/index.md +++ b/docs/transitioning/from-verilog/index.md @@ -402,6 +402,8 @@ class Foo extends EDDesign: /// +[](){#fsm-state-encoding} + /// admonition | FSM State Encoding type: verilog Verilog FSMs typically use `parameter` constants and `case`/`if` chains. In DFHDL, the idiomatic translation uses an `enum extends Encoded` and `match`: @@ -442,6 +444,8 @@ class gun extends EDDesign: +Note that the `default:` branch is kept as `case _ =>`: the three states occupy a 2-bit binary encoding, leaving `2'b11` representable but unlisted, and only the wildcard branch covers it. Translate a `default:` branch as `case _ =>` whenever such unlisted encodings exist, i.e. always except for the default binary encoding with exactly 2^n unique cases; dropping it turns them into synthesis don't-cares and the translation is no longer formally equivalent to the original. For fresh (non-translated) FSMs the wildcard is a reliability choice instead; see [Enum Matches and the Wildcard `case _`][enum-match-wildcard]. + A Verilog `enum {IDLE, DRAW} state;` (or a set of state `parameter`s) is a **module-local** declaration, so the faithful translation declares the enum inside the design class. That also keeps per-module FSMs independent: several designs may each declare their own `State` without colliding, whereas two top-level enums of the same name in one compilation unit are a duplicate definition. See [Declaration Scope][DFEnum] for the details and for where the generated typedef ends up. If the encoded Verilog state values follow a standard pattern (incremental, gray, one-hot), use the corresponding `Encoded` variant. For non-standard encodings, use `Encoded.Manual` with a constructor parameter: diff --git a/docs/user-guide/conditionals/index.md b/docs/user-guide/conditionals/index.md index 13eae1f14..a02dd9691 100644 --- a/docs/user-guide/conditionals/index.md +++ b/docs/user-guide/conditionals/index.md @@ -229,7 +229,7 @@ else ### Rules -1. **Exhaustiveness**: Match expressions must cover all possible cases +1. **Exhaustiveness**: Match expressions must cover all possible cases (for an enum selector, listing all declared entries suffices; see [Enum Matches and the Wildcard `case _`][enum-match-wildcard] for when to add a wildcard branch anyway) 2. **Pattern Order**: Patterns are evaluated in order, first match wins 3. **Type Safety**: All case branches must produce compatible types if used as an expression 4. **Hardware Implementation**: @@ -261,6 +261,25 @@ enum State(val value: UInt[3] <> CONST) extends Encoded.Manual(3): ``` /// +### Enum Matches and the Wildcard `case _` {#enum-match-wildcard} + +Listing every entry of an enum makes a `match` exhaustive, but the register underneath can often hold encodings that no entry names: the 3-entry `State` enum above occupies 2 bits, leaving `b"11"` representable but unlisted (one-hot, gray, `StartAt`, and `Manual` encodings leave even more). A wildcard branch after all the entries covers exactly those unlisted encodings, and whether to write it depends on the rules below: + +```scala +state match + case State.Idle => // idle logic + case State.Start => // start logic + case State.Data => // data logic + // wildcard branch, reached only on the unlisted b"11" encoding; + // optional in a fresh design, mandatory in a translation + // from Verilog's `default:` or VHDL's `when others =>` + // (see rules below) + case _ => state := State.Idle +``` + +- **Fresh designs:** no need to write `case _ =>` if don't-care behavior on unlisted encodings is acceptable; synthesis exploits the don't-cares for better hardware utilization, and most designs require nothing more. The trade-off: if unstable timing/reset or an environmental effect that randomly flips bits (however unlikely) lands the register on an unlisted encoding, the outcome is unknown, and without a wildcard branch that keeps the current state or jumps to a known initialization state (as above) the chances of recovery are reduced. Only high-reliability designs need it. +- **Translations of existing Verilog/VHDL:** translate a `default:` / `when others =>` branch as `case _ =>` too. Omit it only for the default binary encoding with exactly 2^n unique cases, where every encoding is named and the branch is unreachable; for any other encoding or case count, dropping it makes the translation not formally equivalent to the original. See [FSM State Encoding][fsm-state-encoding] for a worked translation. + ### Best Practices 1. **Use Match for Multi-Way Branching**: When dealing with multiple cases, match is often clearer than nested if-else @@ -302,7 +321,7 @@ class Decoder extends DFDesign: case 0 => b"0001" case 1 => b"0010" case 2 => b"0100" - case _ => b"0000" + case _ => b"0000" ``` ### Complex Pattern Matching @@ -313,9 +332,9 @@ class PixelProcessor extends DFDesign: result := pixel match case Pixel(x, y) if x == y => x - case Pixel(x, 0) => x * 2 - case Pixel(0, y) => y * 2 - case Pixel(x, y) => (x + y) / 2 + case Pixel(x, 0) => x * 2 + case Pixel(0, y) => y * 2 + case Pixel(x, y) => (x + y) / 2 ``` ### Usage Modes @@ -333,7 +352,7 @@ else if (!i) x := 2 // Match statement x match case 77 | 11 => x := 1 - case _ => + case _ => x := 3 x := 4 ``` diff --git a/docs/user-guide/type-system/index.md b/docs/user-guide/type-system/index.md index 02b28d103..7f0bd8441 100755 --- a/docs/user-guide/type-system/index.md +++ b/docs/user-guide/type-system/index.md @@ -1443,6 +1443,8 @@ state match case MyEnum.C => // handle C ``` +Listing every declared entry makes the match exhaustive, yet a wildcard `case _ =>` branch is still permitted: it covers any encoding of the underlying register that no entry names. See [Enum Matches and the Wildcard `case _`][enum-match-wildcard] for when to include it. + #### Examples ```scala From cc101aeaae14c0ed9aee3cbe34b6b5e7c39d5f07 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Fri, 7 Aug 2026 16:40:30 +0300 Subject: [PATCH 06/25] plugin: auto-@top injected fully qualified to support a design class named `top` (#458) PreTyperPhase's auto-@top injection spelled the annotation as an unqualified `top` identifier, which a design class itself named `top` (the common Verilog top-module convention) captured, failing compilation with an inscrutable "Cyclic reference involving class top". The injection is now anchored at the root (`@_root_.dfhdl.top(true)`), immune to both the class-name capture and a user package/object named `dfhdl`. TopAnnotPhase matches the annotation by resolved symbol and the plugin's rightmostName-based detection matches the qualified spelling, so no consumer changes. Regression test: issues/i458.scala keeps the design class unannotated so the auto-injection path is the one exercised; without the fix the whole lib test scope fails compilation on it. Co-Authored-By: Claude Fable 5 --- .claude/commands/bugfix.md | 21 +++++++++++++++++++ lib/src/test/scala/issues/IssueSpec.scala | 2 ++ lib/src/test/scala/issues/i458.scala | 14 +++++++++++++ .../src/main/scala/plugin/PreTyperPhase.scala | 14 ++++++++++++- 4 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 lib/src/test/scala/issues/i458.scala diff --git a/.claude/commands/bugfix.md b/.claude/commands/bugfix.md index 70ba1cd28..21ed7f696 100644 --- a/.claude/commands/bugfix.md +++ b/.claude/commands/bugfix.md @@ -72,6 +72,27 @@ Issue #427 (`out_data <> bars(0).out_data`, where the port width comes from a de one of these. It read like a macro bug and was a Scala 3 compiler bug; the DFHDL fix was six `asInstanceOf`s. Work it in this order instead. +### An untyped tree the plugin synthesizes resolves in the USER's scope + +A pre-typer phase that injects an untyped tree gets that tree name-resolved as if the user had +written it, so any unqualified `Ident` in it is captured by a same-named user definition. The +auto-`@top` injection spelled the annotation `Ident("top")`, and a design class itself named +`top` (the standard Verilog top-module name) captured it: the annotation then referred to the +class being annotated, and scalac reported `Cyclic reference involving class top` at the class +definition, with nothing pointing at the plugin (issue #458). Anchor every synthesized library +reference at the root (`Select(Select(Ident(nme.ROOTPKG), "dfhdl"), ...)`, i.e. +`_root_.dfhdl.top`) — a bare `Ident("dfhdl")` can itself be captured by a user package or object +named `dfhdl`. Rightmost-name-based detection helpers (`rightmostName`) keep matching the +qualified spelling, so only the construction site changes. + +The tell for this species: a resolution-flavored error (cyclic reference, ambiguity, "not +found") positioned on ordinary user code that appears or vanishes with the *name* of a +definition, not its body — the reporter of #458 bisected the whole class body before renaming +the class revealed it. Minimize by renaming the identifier, not by editing the body. The +regression test is a lib spec design using the colliding name with NO explicit annotation (the +auto-injection path must fire); it pins the fix at compile level, since the unfixed plugin +fails the whole test-scope compilation. + ### Minimize outside DFHDL, early Get off the DFHDL types as fast as possible. Two plugin-free sandboxes: diff --git a/lib/src/test/scala/issues/IssueSpec.scala b/lib/src/test/scala/issues/IssueSpec.scala index 2a2c48e7a..9afc37b34 100644 --- a/lib/src/test/scala/issues/IssueSpec.scala +++ b/lib/src/test/scala/issues/IssueSpec.scala @@ -88,4 +88,6 @@ class IssuesSpec extends FunSuite: i375.draw_line().compile test("i450 compiles with no exception"): i450.TopC().compile + test("i458 design class named `top` compiles with no exception"): + i458.top().compile end IssuesSpec diff --git a/lib/src/test/scala/issues/i458.scala b/lib/src/test/scala/issues/i458.scala new file mode 100644 index 000000000..17fc07cfe --- /dev/null +++ b/lib/src/test/scala/issues/i458.scala @@ -0,0 +1,14 @@ +package issues.i458 + +import dfhdl.* + +// A design class literally named `top` (the common Verilog convention for the top-level +// module). The class must carry NO explicit annotation: the auto-`@top` injection is the +// path under test. An injection spelled with an unqualified `top` resolves to this very +// class and fails compilation with "Cyclic reference involving class top", so the plugin +// must inject the fully qualified `@_root_.dfhdl.top` instead. +class top extends EDDesign: + val a = Bit <> IN + val b = Bit <> IN + val y = Bit <> OUT + y <> a && b diff --git a/plugin/src/main/scala/plugin/PreTyperPhase.scala b/plugin/src/main/scala/plugin/PreTyperPhase.scala index 032e1c613..86b3a243a 100644 --- a/plugin/src/main/scala/plugin/PreTyperPhase.scala +++ b/plugin/src/main/scala/plugin/PreTyperPhase.scala @@ -236,8 +236,20 @@ class PreTyperPhase(setting: Setting) extends CommonPhase: // lenient variant — TopAnnotPhase silently skips entry-point generation when the // annotated class turns out not to be a Design, whereas bare `@top` is strict // and would surface a compile error on a false positive. + // The annotation is fully qualified as `_root_.dfhdl.top`: an unqualified `top` + // resolves to the annotated class itself when the class is named `top` (a common + // Verilog top-module convention), yielding a baffling "Cyclic reference involving + // class top" error (#458). untpd.Apply( - untpd.Select(untpd.New(untpd.Ident("top".toTypeName)), nme.CONSTRUCTOR), + untpd.Select( + untpd.New( + untpd.Select( + untpd.Select(untpd.Ident(nme.ROOTPKG), "dfhdl".toTermName), + "top".toTypeName + ) + ), + nme.CONSTRUCTOR + ), List(untpd.Literal(Constant(true))) ).withSpan(span) From e498a38502599fe0514f873984740cd6fd1c0e65 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Fri, 7 Aug 2026 17:03:22 +0300 Subject: [PATCH 07/25] docs: Bitwise Operations subsection for elementwise `&`/`|`/`^`/`~` on vectors Fixes DFiantHDL/dfhdl_by_agents#117 Co-Authored-By: Claude Fable 5 --- docs/transitioning/from-verilog/index.md | 4 +- docs/user-guide/type-system/index.md | 62 +++++++++++++++++++----- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/docs/transitioning/from-verilog/index.md b/docs/transitioning/from-verilog/index.md index 7d045d450..377f1cc70 100644 --- a/docs/transitioning/from-verilog/index.md +++ b/docs/transitioning/from-verilog/index.md @@ -666,7 +666,7 @@ There is no `>>>` operator in DFHDL. The type of the LHS determines the shift se /// -/// admonition | Bit/Boolean Operators: `|`/`&`/`~` and `||`/`&&`/`!` +/// admonition | Bit/Boolean Operators: `|`/`&`/`^`/`~` and `||`/`&&`/`!` type: verilog In DFHDL, `||`/`&&`/`!` and `|`/`&`/`~` are interchangeable on `Bit` and `Boolean` types. The generated Verilog operator depends on the LHS type: `Bit` produces bitwise `|`/`&`/`~`, `Boolean` produces logical `||`/`&&`/`!`.
@@ -730,7 +730,7 @@ end Negate This wrap-around conversion may appear cumbersome, and that is intentional: it hints at a code smell. It usually means one of two things: either the base type was chosen badly (a value that is conceptually a signed number should be declared as `SInt`, where `-x` preserves the type directly), or the importance of the preserved sign bit in the widened `SInt[W + 1]` result is not understood (discarding it silently corrupts the value; e.g., same-width negation of `d"8'128"` yields 128 again, not -128). -See [Logical Operations][logical-ops] for the bitwise NOT and [Arithmetic Operations][arithmetic-ops] for the unary negation type rules. +See [Bitwise Operations][bitwise-ops] for the bitwise NOT and [Arithmetic Operations][arithmetic-ops] for the unary negation type rules. /// /// admonition | Reduction Operators (`&v`, `|v`, `^v`) diff --git a/docs/user-guide/type-system/index.md b/docs/user-guide/type-system/index.md index 7f0bd8441..d66c8e885 100755 --- a/docs/user-guide/type-system/index.md +++ b/docs/user-guide/type-system/index.md @@ -2078,7 +2078,7 @@ For **wider lanes**, accumulate with a Scala `#!scala var` ascribed to an unboun ### Logical Operations {#logical-ops} -Applies to: `Bit`, `Boolean`. The bitwise NOT (`~`) additionally applies to `Bits` and `UInt` vectors. +Applies to: `Bit`, `Boolean`. For the elementwise bitwise operations on `Bits`/`UInt` vectors (`&`, `|`, `^`, `~`), see [Bitwise Operations][bitwise-ops]. Logical operations' return type always matches the LHS argument's type. These operations propagate constant modifiers, meaning that if all arguments are constant, the returned value is also a constant. @@ -2093,7 +2093,6 @@ These operations propagate constant modifiers, meaning that if all arguments are | `lhs ^ rhs` | Logical XOR | The LHS argument must be a `Bit`/`Boolean` DFHDL value. The RHS must be a `Bit`/`Boolean` candidate. | LHS-Type DFHDL value | | `!lhs` | Logical NOT | The argument must be a `Bit`/`Boolean` DFHDL value. | LHS-Type DFHDL value | | `~lhs` | Logical NOT | The argument must be a `Bit`/`Boolean` DFHDL value. | LHS-Type DFHDL value | -| `~lhs` | Bitwise NOT (invert all bits) | The argument must be a `Bits`/`UInt` DFHDL value. | LHS-Type DFHDL value | /// ```scala @@ -2109,13 +2108,6 @@ val t6 = bl ^ 0 || !bt //conversions, looks like so: //(bl && bt.bool) ^ (!(bt || bl.bit)).bool val t7 = (bl && bt) ^ !(bt || bl) -//bitwise NOT on `Bits`/`UInt` vectors -//inverts all bits and preserves the -//argument's type -val v8 = Bits(8) <> VAR -val u8 = UInt(8) <> VAR -val t8 = ~v8 //result type: Bits[8] -val t9 = ~u8 //result type: UInt[8] //error: swap argument positions to have //the DFHDL value on the LHS. val e1 = 0 ^ bt @@ -2150,7 +2142,6 @@ Under the ED domain, the following operations are equivalent: | `!lhs` | `~lhs` | `!lhs` | | `~lhs` | `~lhs` | `!lhs` | -For `Bits`/`UInt` vector values, `~lhs` maps directly to Verilog's bitwise NOT `~lhs`. /// /// details | Transitioning from VHDL @@ -2164,14 +2155,61 @@ Under the ED domain, the following operations are equivalent: | `lhs ^ rhs` | `lhs xor rhs` | | `!lhs` | `not lhs` | -For `Bits`/`UInt` vector values, `~lhs` maps to VHDL's `not lhs`. +/// + +### Bitwise Operations {#bitwise-ops} + +Applies to: `Bits`, `UInt` + +Bitwise operations apply **elementwise** on their vector arguments' bits, and their return type always matches the LHS argument's type. +These operations propagate constant modifiers, meaning that if all arguments are constant, the returned value is also a constant. +Do not confuse the two-operand `&`/`|`/`^` with the single-operand postfix [reduction operators][reduction-ops] `.&`/`.|`/`.^`, which fold a vector into a single `Bit`. + +/// html | div.operations +| Operation | Description | LHS/RHS Constraints | Returns | +| ------------ | ----------- | ------------------- | ------- | +| `lhs & rhs` | Bitwise AND | The LHS argument must be a `Bits`/`UInt` DFHDL value. The RHS must match the LHS type and width (for `Bits`, any same-width `Bits` candidate). | LHS-Type DFHDL value | +| `lhs | rhs` | Bitwise OR | The LHS argument must be a `Bits`/`UInt` DFHDL value. The RHS must match the LHS type and width (for `Bits`, any same-width `Bits` candidate). | LHS-Type DFHDL value | +| `lhs ^ rhs` | Bitwise XOR | The LHS argument must be a `Bits`/`UInt` DFHDL value. The RHS must match the LHS type and width (for `Bits`, any same-width `Bits` candidate). | LHS-Type DFHDL value | +| `~lhs` | Bitwise NOT (invert all bits) | The argument must be a `Bits`/`UInt` DFHDL value. | LHS-Type DFHDL value | +/// + +```scala +val v8 = Bits(8) <> VAR +val m8 = Bits(8) <> VAR +val u8 = UInt(8) <> VAR +//bitwise NOT inverts all bits and +//preserves the argument's type +val t1 = ~v8 //result type: Bits[8] +val t2 = ~u8 //result type: UInt[8] +//AND/OR/XOR apply elementwise between +//two same-width vectors and preserve +//the LHS type +val t3 = v8 | m8 //result type: Bits[8] +val t4 = v8 & h"F0" //result type: Bits[8] +val t5 = u8 ^ d"8'85" //result type: UInt[8] +//error: an integer value cannot be a +//candidate for a Bits type +val e1 = v8 | 2 +//error: the argument widths must match +val e2 = v8 ^ b"1010" +``` + +/// details | Transitioning from Verilog + type: verilog +`lhs & rhs`/`lhs | rhs`/`lhs ^ rhs`/`~lhs` on `Bits`/`UInt` vector values map directly to Verilog's elementwise bitwise `&`/`|`/`^`/`~`. Verilog's same-symbol unary reduction operators (`&v`, `|v`, `^v`) map to the postfix [reduction operators][reduction-ops] instead. +/// + +/// details | Transitioning from VHDL + type: vhdl +`lhs & rhs`/`lhs | rhs`/`lhs ^ rhs`/`~lhs` on `Bits`/`UInt` vector values map to VHDL's `and`/`or`/`xor`/`not`. /// ### Bit Reduction Operations (`.&`, `.|`, `.^`) {#reduction-ops} Applies to: `Bits`, `UInt` (via implicit conversion to `Bits`) -Reduction operators fold all bits of a `Bits` vector into a single `Bit` value. They are the DFHDL equivalents of Verilog's unary reduction operators (`&v`, `|v`, `^v`): +Reduction operators fold all bits of a `Bits` vector into a single `Bit` value. They are the DFHDL equivalents of Verilog's unary reduction operators (`&v`, `|v`, `^v`); the infix two-operand `&`/`|`/`^` between same-width vectors are separate elementwise operations, covered under [Bitwise Operations][bitwise-ops]: /// html | div.operations | Operation | Description | Returns | From 1366e734bc63734c399d02b854000c6af6bfc9e5 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Fri, 7 Aug 2026 20:01:25 +0300 Subject: [PATCH 08/25] core+stages+docs: target-context widening replaces carry promotion; new relative `.eby` (#119) An anonymous +/-/* cone assigned or connected to a wider value now re-evaluates at the target's width and sign, matching Verilog's assignment-context width propagation: every operand widens (recursively through the anonymous cone) and the operations stay modular at the target width. The carry spelling is kept where provably identical: a binary op over leaf operands, same sign as the target, whose carry width fits the target exactly (bare `a +^ b`) or exceeds it decidably (carry + truncating resize). A promoted carry op is never extended, only truncated; extension of a carry result is sign/op-dependent and was the root of the formally confirmed miscompiles: result-level zero-extension of unsigned chains meeting signed targets, shallow (top-only) promotion of nested chains, zero-extension of a widened subtraction carry, and the illegal EXTEND_S() emission (out-of-range bit-select). `.eby(k)` is a new relative widen-by operation on UInt/SInt/Bits, sugar for `.resize(width + k)`. All printers render a widening relatively (DFHDL `.eby(k)`, Verilog `EBY_U`/`EBY_S`, VHDL `eby`) exactly when the target width is an anonymous `base + k` increment of the source width, so parametric widening stays symbol-free while named/literal widths keep their absolute spelling. The Verilog EXTEND_S/EBY_S operands are guaranteed indexable by a new NamedVerilogSelection widening criterion plus a fused sign-conversion emission; VHDL carry mul prints infix (numeric_std mul is already full-width; `cmul` was emitted but never defined in dfhdl_pkg). Verified by per-output yosys SAT miter proofs against a hand-written Verilog golden (19/19, from 10 failures before the fix) and by the full test suite, with zero reference-output churn. Co-Authored-By: Claude Fable 5 --- .../main/scala/dfhdl/compiler/ir/DFRef.scala | 54 +++++ .../compiler/printing/DFValPrinter.scala | 17 +- .../stages/src/main/resources/dfhdl_defs.svh | 8 + .../stages/src/main/resources/dfhdl_defs.vh | 6 + .../stages/src/main/resources/dfhdl_pkg.vhd | 17 ++ .../dfhdl/compiler/stages/NamedAliases.scala | 25 ++ .../stages/verilog/VerilogValPrinter.scala | 89 +++++-- .../compiler/stages/vhdl/VHDLValPrinter.scala | 40 ++-- .../ExplicitCondExprAssignSpec.scala | 2 +- .../StagesSpec/ExplicitNamedVarsSpec.scala | 2 +- .../scala/StagesSpec/NamedSelectionSpec.scala | 5 +- .../scala/StagesSpec/PrintVHDLCodeSpec.scala | 49 ++++ .../StagesSpec/PrintVerilogCodeSpec.scala | 44 +++- core/src/main/scala/dfhdl/core/DFBits.scala | 12 + .../src/main/scala/dfhdl/core/DFDecimal.scala | 223 ++++++++++++------ core/src/main/scala/dfhdl/core/IntParam.scala | 4 + .../test/scala/CoreSpec/DFDecimalSpec.scala | 67 +++--- docs/transitioning/from-verilog/index.md | 2 +- docs/user-guide/type-system/index.md | 49 ++-- lib/src/test/scala/ContextWidenSpec.scala | 82 +++++++ 20 files changed, 624 insertions(+), 173 deletions(-) create mode 100644 lib/src/test/scala/ContextWidenSpec.scala diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFRef.scala b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFRef.scala index 28623ac71..974a6f760 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFRef.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFRef.scala @@ -199,6 +199,60 @@ object IntParamRef: ) yield func(diff, 0) end compare + // The constant difference `this - that` when all symbolic terms cancel (see `compare`); + // `None` otherwise. Lets printers render a widening as a relative extension (`.eby(k)`, + // `EBY_U`/`EBY_S`, VHDL `eby`) exactly when the width delta folds to a literal. Design + // parameters stay OPAQUE here: the relative form is only used when the delta holds for + // every parameter assignment, or a printed `.eby(k)` would pin an overridable width to + // its currently applied value. + def constDiffFrom(that: IntParamRef)(using MemberGetSet): Option[Int] = + (intParamRef, that) match + case (l: Int, r: Int) => Some(l - r) + case _ => + def asDFVal(ref: IntParamRef): Option[DFVal] = ref match + case i: Int => + Some(DFVal.Const( + DFInt32, Some(BigInt(i)), DFRef.OneWay.Empty, Meta.empty, DFTags.empty + )) + case r: DFRef.TypeRef => r.getOption + for + lVal <- asDFVal(intParamRef) + rVal <- asDFVal(that) + diff <- IntExprCalc.constDiff(lVal, rVal, resolveDesignParams = false) + yield diff + end constDiffFrom + // The literal widening delta `this - that` for printers preferring the RELATIVE + // extension spelling (`.eby(k)`, `EBY_U`/`EBY_S`, VHDL `eby`): defined exactly when + // `this` is an ANONYMOUS `base + k` width increment whose base is the source width + // itself (e.g. a `W + 1` target over a `W`-wide source). A literal width, a NAMED + // width (a parameter or named constant), or any other expression shape prints + // absolutely, by value or by name, so the printed form always preserves the width + // symbols the user can see. + def widenDeltaOpt(that: IntParamRef)(using MemberGetSet): Option[Int] = + intParamRef match + case ref: DFRef.TypeRef => + ref.getOption match + case Some(func: DFVal.Func) if func.isAnonymous && func.op == DFVal.Func.Op.+ => + func.args match + case baseRef :: DFRef(konst: DFVal.Const) :: Nil => + konst.data match + case Some(k: BigInt) if k > 0 => + val thatValOpt: Option[DFVal] = that match + case thatRef: DFRef.TypeRef => thatRef.getOption + case i: Int => + Some(DFVal.Const( + DFInt32, Some(BigInt(i)), DFRef.OneWay.Empty, Meta.empty, DFTags.empty + )) + val baseEquiv = thatValOpt.exists { thatVal => + (thatVal == baseRef.get) || (IntExprCalc.constDiff( + baseRef.get, thatVal, resolveDesignParams = false + ) == Some(0)) + } + if (baseEquiv) Some(k.toInt) else None + case _ => None + case _ => None + case _ => None + case _ => None end extension given ReadWriter[IntParamRef] = readwriter[ujson.Value].bimap( diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/printing/DFValPrinter.scala b/compiler/ir/src/main/scala/dfhdl/compiler/printing/DFValPrinter.scala index b2a22a661..406f04bec 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/printing/DFValPrinter.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/printing/DFValPrinter.scala @@ -322,6 +322,11 @@ protected trait DFValPrinter extends AbstractValPrinter: end match end match end csDFValFuncExpr + // a widening whose delta folds to a literal prints as the relative `.eby(k)` form + private def csResizeOrEby(toWidthRef: IntParamRef, fromWidthRef: IntParamRef): String = + toWidthRef.widenDeltaOpt(fromWidthRef) match + case Some(k) => s".eby($k)" + case _ => s".resize(${toWidthRef.refCodeString})" def csDFValAliasAsIs(dfVal: Alias.AsIs): String = val relVal = dfVal.relValRef.get val relValStr = dfVal.relValCodeString @@ -341,18 +346,18 @@ protected trait DFValPrinter extends AbstractValPrinter: s"${relValStr}.uint" case (DFSInt(tWidthRef), DFBits(fWidthRef)) => s"${relValStr}.sint" - case (DFBits(tWidthParamRef), DFBits(_)) => - s"${relValStr}.resize(${tWidthParamRef.refCodeString})" + case (DFBits(tWidthParamRef), DFBits(fWidthRef)) => + s"${relValStr}${csResizeOrEby(tWidthParamRef, fWidthRef)}" case (DFBits(tWidthParamRef), DFBit | DFBool) => s"${relValStr}.toBits(${tWidthParamRef.refCodeString})" case (DFBits(_), _) => s"${relValStr}.bits" - case (DFUInt(tWidthParamRef), DFUInt(_)) => - s"${relValStr}.resize(${tWidthParamRef.refCodeString})" + case (DFUInt(tWidthParamRef), DFUInt(fWidthRef)) => + s"${relValStr}${csResizeOrEby(tWidthParamRef, fWidthRef)}" case (DFInt32, DFSInt(_)) => s"${relValStr}.toInt" - case (DFSInt(tWidthParamRef), DFSInt(_)) => - s"${relValStr}.resize(${tWidthParamRef.refCodeString})" + case (DFSInt(tWidthParamRef), DFSInt(fWidthRef)) => + s"${relValStr}${csResizeOrEby(tWidthParamRef, fWidthRef)}" case (DFBit, DFBool | DFEnum(widthParam = 1)) => s"${relValStr}.bit" case (DFBool, DFBit | DFEnum(widthParam = 1)) => diff --git a/compiler/stages/src/main/resources/dfhdl_defs.svh b/compiler/stages/src/main/resources/dfhdl_defs.svh index 7ad716094..571ddb56c 100644 --- a/compiler/stages/src/main/resources/dfhdl_defs.svh +++ b/compiler/stages/src/main/resources/dfhdl_defs.svh @@ -27,6 +27,14 @@ `define MAX(a, b) ((a) > (b) ? (a) : (b)) `define MIN(a, b) ((a) < (b) ? (a) : (b)) `define ABS(a) ((a) < 0 ? -(a) : (a)) +`define EXTEND_U(vec, fromW, toW) \ + /* verilator lint_off WIDTH */ \ + ((toW) == (fromW) ? vec : {{((toW) - (fromW)){1'b0}}, vec}) \ + /* verilator lint_on WIDTH */ +// Relative widening: extend `vec` by `by` bits, width-free. The sign-extending form +// bit-selects `vec` (via $bits), so `vec` must be an indexable primary (an identifier). +`define EBY_U(vec, by) {{(by){1'b0}}, vec} +`define EBY_S(vec, by) $signed({{(by){vec[$bits(vec) - 1]}}, vec}) // Fixed-point types: `M` integer (magnitude) bits and `F` fraction bits, laid out with the // binary point at index 0 so bit weights are 2^index (integer bits M-1..0, fraction bits // -1..-F). `sfix` carries the `signed` keyword so it is not repeated at the declaration. diff --git a/compiler/stages/src/main/resources/dfhdl_defs.vh b/compiler/stages/src/main/resources/dfhdl_defs.vh index f71fdca8d..a0d7724d3 100644 --- a/compiler/stages/src/main/resources/dfhdl_defs.vh +++ b/compiler/stages/src/main/resources/dfhdl_defs.vh @@ -66,6 +66,12 @@ ((toW) == (fromW) ? vec : {{((toW) - (fromW)){vec[fromW - 1]}}, vec}) \ /* verilator lint_on WIDTH */ `define EXTEND_S(vec, fromW, toW) $signed(`EXTEND_S_V95(vec, fromW, toW)) +// Relative widening: extend `vec` by `by` bits. The zero-extension needs no width at +// all; the sign-extension replicates the sign bit, indexed via the source width. +// `vec` must be an indexable primary (an identifier) for the sign-extending forms. +`define EBY_U(vec, by) {{(by){1'b0}}, vec} +`define EBY_S_V95(vec, fromW, by) {{(by){vec[(fromW) - 1]}}, vec} +`define EBY_S(vec, fromW, by) $signed(`EBY_S_V95(vec, fromW, by)) `define SIGNED_GREATER_THAN(a, b, width) \ ((a[width-1] && !b[width-1]) ? 1'b0 : /* a is negative, b is positive */ \ (!a[width-1] && b[width-1]) ? 1'b1 : /* a is positive, b is negative */ \ diff --git a/compiler/stages/src/main/resources/dfhdl_pkg.vhd b/compiler/stages/src/main/resources/dfhdl_pkg.vhd index 0b1340343..fbd4d800c 100644 --- a/compiler/stages/src/main/resources/dfhdl_pkg.vhd +++ b/compiler/stages/src/main/resources/dfhdl_pkg.vhd @@ -58,6 +58,11 @@ function cadd(A, B : unsigned) return unsigned; function cadd(A, B : signed) return signed; function csub(A, B : unsigned) return unsigned; function csub(A, B : signed) return signed; +-- extend-by: relative widening by `k` bits (zero-extension for unsigned/std_logic_vector, +-- sign-extension for signed) +function eby(A : unsigned; k : natural) return unsigned; +function eby(A : signed; k : natural) return signed; +function eby(A : std_logic_vector; k : natural) return std_logic_vector; function clog2(n : natural) return natural; function to_slv(A : unsigned) return std_logic_vector; function to_slv(A : signed) return std_logic_vector; @@ -120,6 +125,18 @@ function csub(A, B : signed) return signed is begin return signed(A(A'left) & A) - signed(B(B'left) & B); end function; +function eby(A : unsigned; k : natural) return unsigned is +begin + return resize(A, A'length + k); +end function; +function eby(A : signed; k : natural) return signed is +begin + return resize(A, A'length + k); +end function; +function eby(A : std_logic_vector; k : natural) return std_logic_vector is +begin + return resize(A, A'length + k); +end function; function clog2(n : natural) return natural is variable result : natural := 0; variable val : natural := n - 1; diff --git a/compiler/stages/src/main/scala/dfhdl/compiler/stages/NamedAliases.scala b/compiler/stages/src/main/scala/dfhdl/compiler/stages/NamedAliases.scala index 0d3e89034..0203afb99 100644 --- a/compiler/stages/src/main/scala/dfhdl/compiler/stages/NamedAliases.scala +++ b/compiler/stages/src/main/scala/dfhdl/compiler/stages/NamedAliases.scala @@ -198,6 +198,31 @@ case object NamedVerilogSelection extends NamedAliases: // so we need to name the value in old verilog if (isBasicVerilog) List(alias.relValRef.get) else Nil + // A width-WIDENING resize over an anonymous expression: basic Verilog's EXTEND macros + // embed the operand in a replication/concatenation (self-determined width, and the + // EXTEND_S variants bit-select the operand, which must be an indexable primary), while + // the SystemVerilog W'() cast evaluates its operand AT THE CAST WIDTH. An anonymous + // func operand would therefore evaluate at the wrong width (or print illegally), so it + // is named, pinning the evaluation at its declared width; in basic Verilog any other + // anonymous non-primary operand is named for the EXTEND_S syntax constraint. The + // exception: a resize over an anonymous sign-conversion alias prints FUSED as a single + // zero-extension of the conversion's own operand (see `csDFValAliasAsIs`), so it needs + // no name. + case alias @ DFVal.Alias.AsIs( + dfType = _: (DFDecimal | DFBits), + relValRef = DFRef(relVal @ (DFBits.Val(_) | DFDecimal.Val(_))) + ) + if relVal.dfType != DFInt32 && alias.compareWidths(relVal)(_ > _).getOrElse(false) => + relVal match + case signConv: DFVal.Alias.AsIs + if signConv.isAnonymous && + ((signConv.dfType, signConv.relValRef.get.dfType) match + case (DFSInt(_), DFUInt(_)) => true + case _ => false) => + Nil // fused sign-conversion emission + case func: DFVal.Func => List(func) + case _ if isBasicVerilog => List(relVal) + case _ => Nil // to/from vector conversion is used with selection case DFVal.Alias.AsIs(dfType = DFVector(_, _), relValRef = DFRef(relVal @ DFBits.Val(_))) => // in basic verilog this casting is only kept for initial values and later ignored by the backend diff --git a/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogValPrinter.scala b/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogValPrinter.scala index 16f02502f..d6b7f9d32 100644 --- a/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogValPrinter.scala +++ b/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogValPrinter.scala @@ -281,25 +281,35 @@ protected trait VerilogValPrinter extends AbstractValPrinter: if (printer.allowSignedKeywordAndOps) s"$$signed($relValStr)" else relValStr case (DFBits(toWidthRef), DFBits(fromWidthRef)) => - if (printer.allowWidthCastSyntax) - s"${toWidthRef.refCodeString.applyBrackets()}'($relValStr)" - else - val truncate = toWidthRef.compare(fromWidthRef)(_ < _).getOrElse(false) - if (truncate) s"`TRUNCATE($relValStr, ${fromWidthRef.refCodeString})" - else - s"`EXTEND_U($relValStr, ${fromWidthRef.refCodeString}, ${toWidthRef.refCodeString})" + toWidthRef.widenDeltaOpt(fromWidthRef) match + // a widening whose delta folds to a literal prints as the relative, + // width-free `EBY_U` form + case Some(k) => s"`EBY_U($relValStr, $k)" + case _ => + if (printer.allowWidthCastSyntax) + s"${toWidthRef.refCodeString.applyBrackets()}'($relValStr)" + else + val truncate = toWidthRef.compare(fromWidthRef)(_ < _).getOrElse(false) + if (truncate) s"`TRUNCATE($relValStr, ${fromWidthRef.refCodeString})" + else + s"`EXTEND_U($relValStr, ${fromWidthRef.refCodeString}, ${toWidthRef.refCodeString})" case (t, DFOpaque(actualType = ot)) if ot =~ t => relValStr case (DFOpaque(_, _, _, _), _) => relValStr case (DFUInt(toWidthRef), DFUInt(fromWidthRef)) => - if (printer.allowWidthCastSyntax) - s"${toWidthRef.refCodeString.applyBrackets()}'($relValStr)" - else - val truncate = toWidthRef.compare(fromWidthRef)(_ < _).getOrElse(false) - if (truncate) s"`TRUNCATE($relValStr, ${fromWidthRef.refCodeString})" - else - s"`EXTEND_U($relValStr, ${fromWidthRef.refCodeString}, ${toWidthRef.refCodeString})" + toWidthRef.widenDeltaOpt(fromWidthRef) match + // a widening whose delta folds to a literal prints as the relative, + // width-free `EBY_U` form + case Some(k) => s"`EBY_U($relValStr, $k)" + case _ => + if (printer.allowWidthCastSyntax) + s"${toWidthRef.refCodeString.applyBrackets()}'($relValStr)" + else + val truncate = toWidthRef.compare(fromWidthRef)(_ < _).getOrElse(false) + if (truncate) s"`TRUNCATE($relValStr, ${fromWidthRef.refCodeString})" + else + s"`EXTEND_U($relValStr, ${fromWidthRef.refCodeString}, ${toWidthRef.refCodeString})" case (DFUInt(tWidthRef), DFInt32) => if (printer.allowWidthCastSyntax) s"${tWidthRef.refCodeString.applyBrackets()}'($relValStr)" @@ -309,16 +319,47 @@ protected trait VerilogValPrinter extends AbstractValPrinter: s"${tWidthRef.refCodeString.applyBrackets()}'($relValStr)" else relValStr case (DFSInt(toWidthRef), DFSInt(fromWidthRef)) => - if (printer.allowWidthCastSyntax) - s"${toWidthRef.refCodeString.applyBrackets()}'($relValStr)" - else - val truncate = toWidthRef.compare(fromWidthRef)(_ < _).getOrElse(false) - if (truncate) s"`TRUNCATE($relValStr, ${fromWidthRef.refCodeString})" - else - if (printer.allowSignedKeywordAndOps) - s"`EXTEND_S($relValStr, ${fromWidthRef.refCodeString}, ${toWidthRef.refCodeString})" - else - s"`EXTEND_S_V95($relValStr, ${fromWidthRef.refCodeString}, ${toWidthRef.refCodeString})" + // Fused sign-conversion widening: the conversion's added sign bit is zero, so + // sign-extending its result equals zero-extending the unsigned operand, in one + // step. The fusion is also load-bearing in basic Verilog: the EXTEND_S macros + // bit-select their operand, which must be an indexable primary, while the + // conversion alone would print as a (non-indexable) concatenation. + val fused = relVal match + case signConv: Alias.AsIs if signConv.isAnonymous => + (signConv.dfType, signConv.relValRef.get.dfType) match + case (DFSInt(_), DFUInt(vWidthRef)) => + val vStr = signConv.relValRef.refCodeString + val ext = toWidthRef.widenDeltaOpt(vWidthRef) match + case Some(k) => s"`EBY_U($vStr, $k)" + case _ => + s"`EXTEND_U($vStr, ${vWidthRef.refCodeString}, ${toWidthRef.refCodeString})" + Some(if (printer.allowSignedKeywordAndOps) s"$$signed($ext)" else ext) + case _ => None + case _ => None + fused.getOrElse { + toWidthRef.widenDeltaOpt(fromWidthRef) match + // A widening whose delta folds to a literal prints as the relative `EBY_S` + // form (width-free via $bits in SystemVerilog; sign-bit index via the source + // width in basic Verilog). EBY_S bit-selects its operand, which must be an + // indexable primary: basic Verilog guarantees it by the NamedVerilogSelection + // criterion; SystemVerilog names only funcs there, so an anonymous alias + // operand keeps the (absolute) cast form instead. + case Some(k) if !(printer.allowWidthCastSyntax && relVal.isAnonymous) => + if (printer.allowWidthCastSyntax) s"`EBY_S($relValStr, $k)" + else if (printer.allowSignedKeywordAndOps) + s"`EBY_S($relValStr, ${fromWidthRef.refCodeString}, $k)" + else s"`EBY_S_V95($relValStr, ${fromWidthRef.refCodeString}, $k)" + case _ => + if (printer.allowWidthCastSyntax) + s"${toWidthRef.refCodeString.applyBrackets()}'($relValStr)" + else + val truncate = toWidthRef.compare(fromWidthRef)(_ < _).getOrElse(false) + if (truncate) s"`TRUNCATE($relValStr, ${fromWidthRef.refCodeString})" + else if (printer.allowSignedKeywordAndOps) + s"`EXTEND_S($relValStr, ${fromWidthRef.refCodeString}, ${toWidthRef.refCodeString})" + else + s"`EXTEND_S_V95($relValStr, ${fromWidthRef.refCodeString}, ${toWidthRef.refCodeString})" + } case (DFUInt(tWidthRef), DFBit | DFBool) => if (printer.allowWidthCastSyntax) s"${tWidthRef.refCodeString.applyBrackets()}'($relValStr)" diff --git a/compiler/stages/src/main/scala/dfhdl/compiler/stages/vhdl/VHDLValPrinter.scala b/compiler/stages/src/main/scala/dfhdl/compiler/stages/vhdl/VHDLValPrinter.scala index 5598b011b..0667cd91b 100644 --- a/compiler/stages/src/main/scala/dfhdl/compiler/stages/vhdl/VHDLValPrinter.scala +++ b/compiler/stages/src/main/scala/dfhdl/compiler/stages/vhdl/VHDLValPrinter.scala @@ -91,17 +91,18 @@ protected trait VHDLValPrinter extends AbstractValPrinter: case _ => infix = false "slv_srl" - // if the result width for +/-/* ops is larger than the left argument width + // if the result width for +/- ops is larger than the left argument width // then we have a carry-inclusive operation. to simplify the check given possible // parameterized widths, we will just compare the type structure and assume the - // width is larger under such conditions. - case op @ (Func.Op.+ | Func.Op.- | Func.Op.`*`) + // width is larger under such conditions. A carry `*` needs no helper: numeric_std + // multiplication is already full-width (a'length + b'length), which is exactly + // the carry-mul width, so it stays infix. + case op @ (Func.Op.+ | Func.Op.-) if !dfVal.dfType.isSimilarTo(argL.get.dfType) => infix = false op match - case Func.Op.+ => "cadd" - case Func.Op.- => "csub" - case Func.Op.`*` => "cmul" + case Func.Op.+ => "cadd" + case Func.Op.- => "csub" case _ => commonOpStr if (infix) s"${argL.refCodeString.applyBrackets()} $opStr ${argR.refCodeString.applyBrackets()}" @@ -217,23 +218,32 @@ protected trait VHDLValPrinter extends AbstractValPrinter: val fromType = relVal.dfType val toType = dfVal.dfType (toType, fromType) match - case (t, f) if t == f => relValStr - case (DFSInt(tWidthRef), DFUInt(_)) => - s"signed(resize($relValStr, ${tWidthRef.refCodeString}))" + case (t, f) if t == f => relValStr + case (DFSInt(tWidthRef), DFUInt(fWidthRef)) => + tWidthRef.widenDeltaOpt(fWidthRef) match + // a widening whose delta folds to a literal prints as the relative `eby` form + case Some(k) => s"signed(eby($relValStr, $k))" + case _ => s"signed(resize($relValStr, ${tWidthRef.refCodeString}))" case (DFUInt(tWidthRef), DFSInt(_)) => s"resize(unsigned($relValStr), ${tWidthRef.refCodeString})" - case (DFBits(tWidthRef), DFBits(_)) => - s"resize($relValStr, ${tWidthRef.refCodeString})" + case (DFBits(tWidthRef), DFBits(fWidthRef)) => + tWidthRef.widenDeltaOpt(fWidthRef) match + case Some(k) => s"eby($relValStr, $k)" + case _ => s"resize($relValStr, ${tWidthRef.refCodeString})" case (toType: DFType, fromType: DFBits) => csBitsToType(toType, relValStr) case (DFBits(tWidthRef), DFBit | DFBool) => s"to_slv($relValStr, ${tWidthRef.refCodeString})" case (DFBits(_), fromType: DFType) => csToSLV(fromType, relValStr) - case (DFUInt(tWidthRef), DFUInt(_)) => - s"resize($relValStr, ${tWidthRef.refCodeString})" - case (DFSInt(tWidthRef), DFSInt(_)) => - s"resize($relValStr, ${tWidthRef.refCodeString})" + case (DFUInt(tWidthRef), DFUInt(fWidthRef)) => + tWidthRef.widenDeltaOpt(fWidthRef) match + case Some(k) => s"eby($relValStr, $k)" + case _ => s"resize($relValStr, ${tWidthRef.refCodeString})" + case (DFSInt(tWidthRef), DFSInt(fWidthRef)) => + tWidthRef.widenDeltaOpt(fWidthRef) match + case Some(k) => s"eby($relValStr, $k)" + case _ => s"resize($relValStr, ${tWidthRef.refCodeString})" case (t, DFOpaque(actualType = ot)) if ot =~ t => relValStr case (DFOpaque(_, _, _, _), _) => diff --git a/compiler/stages/src/test/scala/StagesSpec/ExplicitCondExprAssignSpec.scala b/compiler/stages/src/test/scala/StagesSpec/ExplicitCondExprAssignSpec.scala index 2e6c941c4..5c20fd073 100644 --- a/compiler/stages/src/test/scala/StagesSpec/ExplicitCondExprAssignSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/ExplicitCondExprAssignSpec.scala @@ -85,7 +85,7 @@ class ExplicitCondExprAssignSpec extends StageSpec(stageCreatesUnrefAnons = true | case sd"16'1" => zz := sd"4'5" | case sd"16'2" => zz := sd"4'3" | end match - | if (x < sd"16'11") z2 := (zz +^ sd"4'3").resize(16) + | if (x < sd"16'11") z2 := zz.resize(16) + sd"16'3" | else z2 := zz.resize(16) | case _ => z2 := z + sd"16'12" | end match diff --git a/compiler/stages/src/test/scala/StagesSpec/ExplicitNamedVarsSpec.scala b/compiler/stages/src/test/scala/StagesSpec/ExplicitNamedVarsSpec.scala index d02eac160..f4328f959 100644 --- a/compiler/stages/src/test/scala/StagesSpec/ExplicitNamedVarsSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/ExplicitNamedVarsSpec.scala @@ -103,7 +103,7 @@ class ExplicitNamedVarsSpec extends StageSpec: | case sd"16'1" => zz := sd"4'5" | case sd"16'2" => zz := sd"4'3" | end match - | if (x < sd"16'11") z2 := (zz +^ sd"4'3").resize(16) + | if (x < sd"16'11") z2 := zz.resize(16) + sd"16'3" | else z2 := zz.resize(16) | case _ => z2 := z + sd"16'12" | end match diff --git a/compiler/stages/src/test/scala/StagesSpec/NamedSelectionSpec.scala b/compiler/stages/src/test/scala/StagesSpec/NamedSelectionSpec.scala index 5c18249a5..05ae5c13d 100644 --- a/compiler/stages/src/test/scala/StagesSpec/NamedSelectionSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/NamedSelectionSpec.scala @@ -146,7 +146,7 @@ class NamedSelectionSpec extends StageSpec(stageCreatesUnrefAnons = true): | val x = UInt(6) <> IN | val y = (x min x).resize(5) | val z = (x + x).resize(5) - | val w = ((x + x) +^ x).resize(20) + | val w = x.resize(20) + x.resize(20) + x.resize(20) |end ID""".stripMargin ) } @@ -167,8 +167,7 @@ class NamedSelectionSpec extends StageSpec(stageCreatesUnrefAnons = true): | val y = y_part.resize(5) | val z_part = x + x | val z = z_part.resize(5) - | val w_part = x + x - | val w = (w_part +^ x).resize(20) + | val w = x.resize(20) + x.resize(20) + x.resize(20) |end ID""".stripMargin ) } diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala index fb7a9fb8f..33aa59524 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala @@ -3480,4 +3480,53 @@ class PrintVHDLCodeSpec extends StageSpec: |""".stripMargin ) } + // parametric target-context widening (issue dfhdl_by_agents#119): a carry op prints + // via the cadd/csub helpers, and an eby-widened operand prints via the relative `eby` + // package function instead of repeating the symbolic target width + test("parametric context widening emission") { + class ParamWiden(val W: Int <> CONST = 8) extends EDDesign: + val a, b = SInt(W) <> IN + val ua, ub = UInt(W) <> IN + val sum = SInt(W + 1) <> OUT + val usub = UInt(W + 1) <> OUT + val acc = SInt(W + 2) <> OUT + val uacc = UInt(W + 2) <> OUT + sum <> a + b + usub <> ua - ub + acc <> a + b + uacc <> ua + ub + val top = ParamWiden().getCompiledCodeString + assertNoDiff( + top, + """|library ieee; + |use ieee.std_logic_1164.all; + |use ieee.numeric_std.all; + |use work.dfhdl_pkg.all; + | + |entity ParamWiden is + |generic ( + | W : integer := 8 + |); + |port ( + | a : in signed(W - 1 downto 0); + | b : in signed(W - 1 downto 0); + | ua : in unsigned(W - 1 downto 0); + | ub : in unsigned(W - 1 downto 0); + | sum : out signed((W + 1) - 1 downto 0); + | usub : out unsigned((W + 1) - 1 downto 0); + | acc : out signed((W + 2) - 1 downto 0); + | uacc : out unsigned((W + 2) - 1 downto 0) + |); + |end ParamWiden; + | + |architecture ParamWiden_arch of ParamWiden is + |begin + | sum <= cadd(a, b); + | usub <= csub(ua, ub); + | acc <= eby(a, 2) + eby(b, 2); + | uacc <= eby(ua, 2) + eby(ub, 2); + |end ParamWiden_arch; + |""".stripMargin + ) + } end PrintVHDLCodeSpec diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala index 15059c17e..6c1fbd1ce 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala @@ -3291,12 +3291,10 @@ class PrintVerilogCodeSpec extends StageSpec: | output logic signed [9:0] q |); | `include "dfhdl_defs.svh" - | logic [3:0] o_part; | logic [8:0] q_part; | always_comb | begin - | o_part = 2'd3 * a; - | o = 8'sd0 - 8'($signed({1'b0, o_part})); + | o = 8'sd0 - (8'sd3 * $signed(`EXTEND_U(a, 2, 8))); | q_part = b + c; | q = $signed({1'b0, q_part}); | end @@ -3304,4 +3302,44 @@ class PrintVerilogCodeSpec extends StageSpec: |""".stripMargin ) } + // parametric target-context widening (issue dfhdl_by_agents#119): a carry op prints + // bare under the assignment context, and an eby-widened operand prints via the + // relative, width-free EBY macros instead of repeating the symbolic target width + test("parametric context widening emission") { + class ParamWiden(val W: Int <> CONST = 8) extends EDDesign: + val a, b = SInt(W) <> IN + val ua, ub = UInt(W) <> IN + val sum = SInt(W + 1) <> OUT + val usub = UInt(W + 1) <> OUT + val acc = SInt(W + 2) <> OUT + val uacc = UInt(W + 2) <> OUT + sum <> a + b + usub <> ua - ub + acc <> a + b + uacc <> ua + ub + val top = ParamWiden().getCompiledCodeString + assertNoDiff( + top, + """|`default_nettype none + |`timescale 1ns/1ps + | + |module ParamWiden#(parameter int W = 8)( + | input wire logic signed [W - 1:0] a, + | input wire logic signed [W - 1:0] b, + | input wire logic [W - 1:0] ua, + | input wire logic [W - 1:0] ub, + | output logic signed [(W + 1) - 1:0] sum, + | output logic [(W + 1) - 1:0] usub, + | output logic signed [(W + 2) - 1:0] acc, + | output logic [(W + 2) - 1:0] uacc + |); + | `include "dfhdl_defs.svh" + | assign sum = a + b; + | assign usub = ua - ub; + | assign acc = `EBY_S(a, 2) + `EBY_S(b, 2); + | assign uacc = `EBY_U(ua, 2) + `EBY_U(ub, 2); + |endmodule + |""".stripMargin + ) + } end PrintVerilogCodeSpec diff --git a/core/src/main/scala/dfhdl/core/DFBits.scala b/core/src/main/scala/dfhdl/core/DFBits.scala index 16fa534c7..44ab0b0e8 100644 --- a/core/src/main/scala/dfhdl/core/DFBits.scala +++ b/core/src/main/scala/dfhdl/core/DFBits.scala @@ -751,6 +751,18 @@ object DFBits: updatedWidth.toScalaIntOpt.foreach(check(_)) lhs.resizeBits(updatedWidth) } + // extend-by: a RELATIVE zero-extension by `delta` bits, sugar over + // `.resize(width + delta)`; printed back in this relative form whenever the + // width delta folds to a literal + def eby[RK <: IntP](delta: IntParam[RK])(using + check: Arg.Positive.CheckNUB[RK], + dfc: DFCG + ): DFValTP[DFBits[IntP.ExtendByWidth[W, RK]], P] = trydf { + delta.toScalaIntOpt.foreach(check(_)) + import IntParam.+ + lhs.resizeBits(lhs.dfType.widthIntParam + delta) + .asValTP[DFBits[IntP.ExtendByWidth[W, RK]], P] + } end extension extension [T <: Int, P](iter: Iterable[DFValTP[DFBits[T], P]]) protected[core] def concatBits(using DFC): DFValTP[DFBits[Int], P] = diff --git a/core/src/main/scala/dfhdl/core/DFDecimal.scala b/core/src/main/scala/dfhdl/core/DFDecimal.scala index 1e9740b74..5953bb030 100644 --- a/core/src/main/scala/dfhdl/core/DFDecimal.scala +++ b/core/src/main/scala/dfhdl/core/DFDecimal.scala @@ -1137,7 +1137,10 @@ object DFXInt: end if case None => end match - DFXInt.Val.Ops.toDFXIntOf(rhs)(dfType).asValTP[DFXInt[LS, LW, LN], RP] + // a widened cone lands exactly at the target type with the original func's + // (anonymous) meta, so a named-val binding must be applied here, like the + // DFBits TC does (with an anonymous or positionally-foreign DFC this no-ops) + DFXInt.Val.Ops.toDFXIntOf(rhs)(dfType).nameInDFCPosition.asValTP[DFXInt[LS, LW, LN], RP] end conv end given end TC @@ -1333,111 +1336,167 @@ object DFXInt: val dfValIR = if (dfType.asIR.isDFInt32 && lhs.dfType.asIR.isDFInt32) lhs.asIR else - // Auto-promote anonymous +/-/* to carry when the target is wide enough. The - // promotion candidate is taken BEFORE any sign conversion: converting first - // wraps the func in a `.signed` alias that hides it from the promotion and - // pins the chain at its narrow width, which the Verilog backend then emits - // as a self-determined concat operand that truncates. An upstream anonymous + // Deep target-context widening, matching Verilog's assignment-context width + // propagation: an anonymous non-carry +/-/* cone converted to a WIDER type + // is re-evaluated at the target's width and sign. Every func in the cone is + // retyped to the target and every leaf is converted to it (recursively, via + // toDFXIntOf on each argument), so all intermediates evaluate at the target + // width. Truncation to the target width commutes with +/-/*, so this is the + // unique evaluation that agrees with Verilog for every input; in particular + // a sign conversion is applied to the OPERANDS, never to a narrower result + // (zero-extending a wrapped subtraction result flips its sign). + // The candidate is taken BEFORE any sign conversion: an upstream anonymous // sign-conversion alias (the commutative-arith sign alignment creates one) - // is unwrapped for the same reason. - import IntParam.+ + // is unwrapped, or it would hide the func and pin the chain at its narrow + // width. A carry func (result wider than its operands) keeps its documented + // exact semantics and converts as a leaf; so do all non-arithmetic ops + // (shifts, selections), whose evaluation this rule does not context-widen. val signFixNeeded = !lhs.dfType.asIR.isDFInt32 && dfType.signed && !lhs.dfType.signed - val (candidateIR, signWrapped) = signConversionRelVal(lhs.asIR) match - case Some(relVal) => (relVal, true) - case None => (lhs.asIR, false) + val candidateIR = signConversionRelVal(lhs.asIR).getOrElse(lhs.asIR) - // symbolic elimination keeps this consistent with the width-fit acceptance rule - // of the TC conversion: `16 > WIDTH max 16` decides as `16 > 16` (no promotion), - // so the anonymous form resolves exactly like a named intermediate value; if - // still undecidable, optimistically assume the target is large enough. The - // effective width includes the sign bit a later sign conversion adds. - def carryPromoteWidthCheck(effWidth: IntParam[Int]): Boolean = + // symbolic elimination keeps this consistent with the width-fit acceptance + // rule of the TC conversion: `16 > WIDTH max 16` decides as `16 > 16` (no + // widening), so the anonymous form resolves exactly like a named + // intermediate value; if still undecidable, optimistically assume the + // target is wider. + def contextWidenCheck(funcWidth: IntParam[Int]): Boolean = dfType.asFE[DFSInt[Int]] - .compareWidths(DFXInt(true, effWidth, BitAccurate), elimSymbolicMaxMin = true)( + .compareWidths(DFXInt(true, funcWidth, BitAccurate), elimSymbolicMaxMin = true)( _ > _ ) .getOrElse(true) - val lhsCarryPromo: DFValOf[DFSInt[Int]] = candidateIR match + val lhsConverted: DFValOf[DFSInt[Int]] = candidateIR match case func @ ir.DFVal.Func( - dfType = dt @ (ir.DFUInt(_) | ir.DFSInt(_)), - op = op @ (FuncOp.+ | FuncOp.- | FuncOp.*) + dfType = ir.DFUInt(_) | ir.DFSInt(_), + op = FuncOp.+ | FuncOp.- | FuncOp.* ) if func.isAnonymous && { - val funcWidth: IntParam[Int] = func.asValOf[DFSInt[Int]].widthIntParam - val effWidth = - if (signFixNeeded || signWrapped) funcWidth + 1 else funcWidth - carryPromoteWidthCheck(effWidth) + // non-carry (modular) func: its type equals its aligned operands' + func.dfType =~ func.args.head.get.dfType && + contextWidenCheck(func.asValOf[DFSInt[Int]].widthIntParam) } => + import IntParam.+ val funcWidth: IntParam[Int] = func.asValOf[DFSInt[Int]].widthIntParam - // The carry-promoted Func is BUILT FRESH rather than revised in place (an - // anonymous member is never revised; issue #449); the original Func becomes - // debris for the end-of-design sweep. For multi-arg merged Funcs (3+ args), - // the last arg is peeled: Func(+, [a, b, c]) becomes - // Func(+, [Func(+, [a, b]), c]), with the inner (non-carry) Func added - // before the carry Func so member order holds. The peel is skipped during - // meta-programming, where no member is registered at all (see below). - val carryArgVals: List[ir.DFVal] = - if (func.args.length > 2 && !dfc.inMetaProgramming) - val innerFunc = ir.DFVal.Func( - dt, - op, - func.args.dropRight(1).map(_.get.refTW[ir.DFVal](knownReachable = true)), + val carryWidth: IntParam[Int] = func.op match + case FuncOp.* => funcWidth + funcWidth + case _ => funcWidth + 1 + def isWidenableCone(v: ir.DFVal): Boolean = v match + case f: ir.DFVal.Func => + f.isAnonymous && + (f.dfType match + case ir.DFUInt(_) | ir.DFSInt(_) => + f.op match + case FuncOp.+ | FuncOp.- | FuncOp.* => + f.dfType =~ f.args.head.get.dfType + case _ => false + case _ => false) + case _ => signConversionRelVal(v).exists(isWidenableCone) + // The carry spelling is preferred where it is provably identical to the + // target-width evaluation: a BINARY func over leaf operands (neither is a + // widenable cone itself), with the target's own sign, whose carry width + // fits the target exactly (bare carry op) or exceeds it decidably (carry + // op + truncating resize, applied by the width fix below). A promoted + // carry op is never EXTENDED, only truncated: truncation commutes with + // +/-/* unconditionally, while extension is sign/op-dependent (an + // unsigned subtraction's carry result is a wrap pattern, not the + // difference). An undecidable symbolic comparison falls back to the + // evaluated form, which is correct either way, just more verbose. + def carryFits: Boolean = + dfType.signed.value == func.dfType.asInstanceOf[ir.DFDecimal].signed && + func.args.lengthIs == 2 && + !func.args.exists(r => isWidenableCone(r.get)) && { + def cmp(f: (Int, Int) => Boolean) = dfType.asFE[DFSInt[Int]] + .compareWidths(DFXInt(true, carryWidth, BitAccurate))(f) + cmp(_ == _).getOrElse(false) || cmp(_ < _).getOrElse(false) + } + // The widened Func is BUILT FRESH rather than revised in place (an + // anonymous member is never revised; issue #449); the original cone + // becomes debris for the end-of-design sweep. Under meta-programming + // there is no MutableDB revision (matching `setMember`'s behavior + // there): the retyped value is returned unregistered and the argument + // conversions are skipped, since no member is registered. + if (carryFits) + val newDT = func.dfType.asInstanceOf[ir.DFDecimal] + .copy(magnitudeWidthParamRef = carryWidth.ref) + if (dfc.inMetaProgramming) func.updateDFType(newDT).asValOf[DFSInt[Int]] + else + ir.DFVal.Func( + newDT, + func.op, + func.args.map(_.get.refTW[ir.DFVal](knownReachable = true)), dfc.ownerOrEmptyRef, func.meta, func.tags - ).addMember - List(innerFunc, func.args.last.get) - else func.args.map(_.get) - // No Verilog-semantics warning for this shape: the promoted chain is - // emitted under the target's width context (a size cast or the - // assignment itself), and truncation to N bits commutes with +/-/*, - // so Verilog's 32-bit evaluation agrees for every input (issue #453). - val cw: IntParam[Int] = op.runtimeChecked match - case FuncOp.+ | FuncOp.- => funcWidth + 1 - case FuncOp.* => funcWidth + funcWidth - // integer carry arithmetic (fraction width 0), so the magnitude width is - // the total width - val newDT = dt.copy(magnitudeWidthParamRef = cw.ref) - val promoted = - if (dfc.inMetaProgramming) - // no MutableDB revision under meta-programming (matching `setMember`'s - // behavior there): the retyped value is returned unregistered - func.updateDFType(newDT).asValOf[DFUInt[Int]] + ).addMember.asValOf[DFSInt[Int]] + else + // the widened evaluation type is the target itself as a bit-accurate + // type; an Int target widens the cone at its native 32-bit width + // (Verilog's `integer` context) and converts below + val newDT = dfType.asIR.asInstanceOf[ir.DFDecimal].copy( + magnitudeWidthParamRef = dfType.widthIntParam.ref, + nativeType = BitAccurate + ) + if (dfc.inMetaProgramming) func.updateDFType(newDT).asValOf[DFSInt[Int]] else + val widenedArgs = func.args.map { argRef => + DFXInt.Val.Ops.toDFXIntOf( + argRef.get.asValOf[DFXInt[Boolean, Int, NativeType]] + )(DFXInt(dfType.signed, dfType.widthIntParam, BitAccurate))(using + dfc.anonymize + ) + } ir.DFVal.Func( newDT, - op, - carryArgVals.map(_.refTW[ir.DFVal](knownReachable = true)), + func.op, + widenedArgs.map(_.asIR.refTW[ir.DFVal](knownReachable = true)), dfc.ownerOrEmptyRef, func.meta, func.tags - ).addMember.asValOf[DFUInt[Int]] - // the sign conversion is applied to the PROMOTED value, so the widening - // happens before the concat the conversion prints as - if (signFixNeeded || signWrapped) promoted.signed.asValOf[DFSInt[Int]] - else promoted.asValOf[DFSInt[Int]] + ).addMember.asValOf[DFSInt[Int]] + end if + end if case _ => - // no promotion: apply the plain sign fix when the target requires it - if (signFixNeeded) lhs.asValOf[DFUInt[Int]].signed.asValOf[DFSInt[Int]] - else lhs.asValOf[DFSInt[Int]] - end lhsCarryPromo - val nativeTypeChanged = dfType.nativeType != lhsCarryPromo.dfType.nativeType + // Fold stacked widenings: an anonymous same-kind widening resize alias + // is transparent to a further conversion (both are value-preserving + // extensions), so when the width fix below would resize anyway, it + // applies to the alias's base directly instead of stacking. + def unstack(v: ir.DFVal): ir.DFVal = v match + case alias: ir.DFVal.Alias.AsIs if alias.isAnonymous => + val relVal = alias.relValRef.get + val widening = (alias.dfType, relVal.dfType) match + case (ir.DFUInt(toW), ir.DFUInt(fromW)) => + toW.compare(fromW)(_ > _).getOrElse(false) + case (ir.DFSInt(toW), ir.DFSInt(fromW)) => + toW.compare(fromW)(_ > _).getOrElse(false) + case _ => false + if (widening) unstack(relVal) else v + case _ => v + val widthChanges = !dfType.asIR.magnitudeWidthParamRef + .isSimilarTo(lhs.dfType.asIR.magnitudeWidthParamRef) + val base = + if (widthChanges) unstack(lhs.asIR).asValOf[DFSInt[Int]] + else lhs.asValOf[DFSInt[Int]] + // no widening: apply the plain sign fix when the target requires it + if (signFixNeeded) base.asValOf[DFUInt[Int]].signed.asValOf[DFSInt[Int]] + else base + end lhsConverted + val nativeTypeChanged = dfType.nativeType != lhsConverted.dfType.nativeType if (nativeTypeChanged) dfType.asIR.nativeType match case Int32 => - lhsCarryPromo.toInt.asIR + lhsConverted.toInt.asIR case BitAccurate => - DFVal.Alias.AsIs(dfType, lhsCarryPromo)(using + DFVal.Alias.AsIs(dfType, lhsConverted)(using dfc.tag(ir.ImplicitlyFromIntTag) ).asIR else if ( // integer operands (fraction 0): the magnitude ref is the total-width ref !dfType.asIR.magnitudeWidthParamRef - .isSimilarTo(lhsCarryPromo.dfType.asIR.magnitudeWidthParamRef) + .isSimilarTo(lhsConverted.dfType.asIR.magnitudeWidthParamRef) ) - lhsCarryPromo.resize(dfType.widthIntParam).asIR - else lhsCarryPromo.asIR + lhsConverted.resize(dfType.widthIntParam).asIR + else lhsConverted.asIR end if end if end dfValIR @@ -1469,6 +1528,24 @@ object DFXInt: DFVal.Alias.AsIs(DFXInt(signed, updatedWidth, BitAccurate), lhs) } end resize + // extend-by: a RELATIVE widening by `delta` bits (zero-extension for unsigned, + // sign-extension for signed), sugar over `.resize(width + delta)`; printed back in + // this relative form whenever the width delta folds to a literal + @targetName("ebyDFXInt") + def eby[RK <: IntP]( + delta: IntParam[RK] + )(using + dfc: DFCG, + check: Arg.Positive.CheckNUB[RK] + ): DFValTP[DFXInt[S, IntP.ExtendByWidth[W, RK], BitAccurate], P] = trydf { + delta.toScalaIntOpt.foreach(check(_)) + import IntParam.+ + DFVal.Alias.AsIs( + DFXInt(lhs.dfType.signed, lhs.dfType.widthIntParam + delta, BitAccurate), + lhs + ).asValTP[DFXInt[S, IntP.ExtendByWidth[W, RK], BitAccurate], P] + } + end eby end extension private[core] val verilogSemanticsWarnMsg = diff --git a/core/src/main/scala/dfhdl/core/IntParam.scala b/core/src/main/scala/dfhdl/core/IntParam.scala index fc1ae1d77..1e7b60641 100644 --- a/core/src/main/scala/dfhdl/core/IntParam.scala +++ b/core/src/main/scala/dfhdl/core/IntParam.scala @@ -130,6 +130,10 @@ object IntP: type ArithCarryWidth[LW <: IntP, RW <: IntP] = FoldConst2[LW, RW, [X <: Int, Y <: Int] =>> int.+[int.Max[X, Y], 1]] + /** `W + K`, the width after extending a value by `K` bits (`.eby`). */ + type ExtendByWidth[W <: IntP, K <: IntP] = + FoldConst2[W, K, [X <: Int, Y <: Int] =>> int.+[X, Y]] + /** `BI + SW - 1`, the high index of an ascending part-select anchored at `BI`. */ type PartSelectHigh[BI <: IntP, SW <: IntP] = FoldConst2[BI, SW, [X <: Int, Y <: Int] =>> int.-[int.+[X, Y], 1]] diff --git a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala index 3f03f93be..019d449aa 100644 --- a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala +++ b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala @@ -907,7 +907,7 @@ class DFDecimalSpec extends DFSpec: // chained ^: merged into multi-arg, position spans from first to last operand val t3 = b8 ^ b8 ^ b8; t3.assertPosition(0, 1, 14, 26) } - test("Arithmetic auto-carry promotion") { + test("Arithmetic target-context widening") { val u8 = UInt(8) <> VAR val u5 = UInt(5) <> VAR val s8 = SInt(8) <> VAR @@ -921,7 +921,7 @@ class DFDecimalSpec extends DFSpec: """|u9 := u8 +^ u8 |u9 := u8 -^ u8 |u16 := u8 *^ u8 - |u10 := (u8 +^ u8).resize(10) + |u10 := u8.resize(10) + u8.resize(10) |u8b := u8 + u8 |val sum = u8 + u8 |u9 := sum.resize(9) @@ -930,66 +930,69 @@ class DFDecimalSpec extends DFSpec: |u9 := u8 +^ u5.resize(8) |u9 := u8 +^ d"8'200" |u12 := (u8 *^ u8).resize(12) - |u9 := (u8 + u8) +^ u8 - |u9 := (u8 + u8 + u8) +^ u8 - |u10 := ((u8 + u8) +^ d"8'1").resize(10) - |u10 := ((u8 + u8b + u8) +^ d"8'1").resize(10) - |s9 := (s8 + s8) +^ sd"8'1" + |u9 := u8.resize(9) + u8.resize(9) + u8.resize(9) + |u9 := u8.resize(9) + u8.resize(9) + u8.resize(9) + u8.resize(9) + |u10 := u8.resize(10) + u8.resize(10) + d"10'1" + |u10 := u8.resize(10) + u8b.resize(10) + u8.resize(10) + d"10'1" + |s9 := s8.resize(9) + s8.resize(9) + sd"9'1" |""".stripMargin } { - // Basic carry promotion for + + // An anonymous +/-/* cone assigned to a wider target re-evaluates at the target + // width, exactly like Verilog's assignment context. When the evaluation is + // exactly a carry operation (a binary op over leaf operands, same sign, carry + // width fitting the target), it elaborates as one; otherwise the operands are + // widened explicitly and the operations stay modular at the target width. A + // promoted carry op is never extended, only truncated. u9 := u8 + u8 - // Basic carry promotion for - u9 := u8 - u8 - // Basic carry promotion for * u16 := u8 * u8 - // Target wider than carry width: promote to 9, resize to 10 + // Target beyond the carry width: evaluation at 10 bits (extension of an + // unsigned subtraction's carry result would flip its sign, so no carry form) u10 := u8 + u8 - // Target = func width: no promotion + // Target = func width: untouched u8b := u8 + u8 - // Named value: no promotion + // Named value: a user-pinned boundary, never widened val sum = u8 + u8 u9 := sum // SInt version s9 := s8 + s8 - // Division: no carry variant, normal resize + // Division is not context-widened (zero-extension commutes with unsigned division) u9 := u8 / u8 - // Asymmetric widths: u8 + u5 → func width 8, carry = 9 + // Asymmetric widths: u5 was aligned to 8 at the op; carry fits the target exactly u9 := u8 + u5 - // Int literal: 200 is 8 bits, carry width = 9 + // Int literal: adapts at the operand width; carry fits the target exactly u9 := u8 + 200 - // Partial mul promotion: target (12) > funcWidth (8), promote to 16, resize to 12 + // Mul carry beyond the target: carry mul + truncating resize (truncation commutes) u12 := u8 * u8 - // carry promotion with 3 arguments + // widening with 3 arguments (merged func): not binary, evaluated at the target u9 := u8 + u8 + u8 - // carry promotion with 4 arguments + // widening with 4 arguments u9 := u8 + u8 + u8 + u8 - // Implicit-Int chain to a wider target: the outer op is promoted to carry - // under the target-width context, so no Verilog-semantics divergence remains - // (issue #453) and the promotion is visible in the printed code + // Implicit-Int chain to a wider target: every intermediate evaluates at the + // target width, so no Verilog-semantics divergence remains (issues #453, #119) u10 := u8 + u8 + 1 u10 := u8 + u8b + u8 + 1 s9 := s8 + s8 + 1 } } - test("Arithmetic auto-carry promotion through sign conversion") { + test("Arithmetic target-context widening through sign conversion") { val u2 = UInt(2) <> VAR val s8 = SInt(8) <> VAR val s9 = SInt(9) <> VAR assertCodeString { - """|s8 := sd"8'0" - (d"2'3" *^ u2).signed.resize(8) - |s8 := s8 - (d"2'3" *^ u2).signed.resize(8) - |s9 := (d"2'3" *^ u2).signed.resize(8) +^ s8 + """|s8 := sd"8'0" - (sd"8'3" * u2.signed.resize(8)) + |s8 := s8 - (sd"8'3" * u2.signed.resize(8)) + |s9 := (sd"8'3" *^ u2.signed.resize(8)).resize(9) + s8.resize(9) |""".stripMargin } { - // The unsigned narrow chain is promoted BEFORE the sign conversion the signed - // sibling forces, so the widening happens ahead of the conversion instead of the - // conversion pinning the chain at its narrow width + // The unsigned narrow chain is widened at the OPERANDS when a signed sibling + // forces a sign conversion: converting the result instead would zero-extend a + // wrapped value and flip its sign (issue #119) s8 := sd"8'0" - 3 * u2 s8 := s8 - 3 * u2 - // The commutative sign alignment wraps the chain in a `.signed` alias before the - // conversion; the promotion unwraps it and re-applies the conversion on top, - // keeping the written operand order + // The commutative sign alignment wraps the chain in a `.signed` alias; the + // widening unwraps it and converts the operands at the target width, keeping + // the written operand order s9 := 3 * u2 + s8 } } diff --git a/docs/transitioning/from-verilog/index.md b/docs/transitioning/from-verilog/index.md index 377f1cc70..c844a0c81 100644 --- a/docs/transitioning/from-verilog/index.md +++ b/docs/transitioning/from-verilog/index.md @@ -1024,7 +1024,7 @@ See [Arithmetic Operations][arithmetic-ops] and [Carry Arithmetic][carry-ops] fo type: verilog In Verilog, unsized integer literals are 32-bit. When combined with narrower signals, the wider literal causes the entire expression to evaluate at 32-bit width via context-dependent propagation. This prevents intermediate overflow in expressions like `(a + b + c + d) / 4`. -In DFHDL, Scala `Int` literals are implicitly converted to minimum-width bit-accurate types (e.g., `4` becomes `UInt[3]`). Each arithmetic operation independently uses the LHS width, so intermediate results can overflow before reaching a division, shift, or comparison. (An assignment to a wider target is unaffected: the chain is automatically promoted to evaluate at the target width, matching Verilog.) +In DFHDL, Scala `Int` literals are implicitly converted to minimum-width bit-accurate types (e.g., `4` becomes `UInt[3]`). Each arithmetic operation independently uses the LHS width, so intermediate results can overflow before reaching a division, shift, or comparison. (An assignment to a wider target is unaffected: the whole chain is automatically re-evaluated at the target width, matching Verilog.) DFHDL detects this pattern at elaboration and issues a warning. See [Implicit Scala `Int` and Verilog-semantics mismatch][arithmetic-ops] for the full list of warning triggers. diff --git a/docs/user-guide/type-system/index.md b/docs/user-guide/type-system/index.md index d66c8e885..58ef40d72 100755 --- a/docs/user-guide/type-system/index.md +++ b/docs/user-guide/type-system/index.md @@ -2027,6 +2027,7 @@ Applies to: `Bits`, `UInt`, `SInt` - `.resize(N)` sets the width to exactly `N` bits. For `UInt` and `Bits`, widening zero-extends; for `SInt`, widening sign-extends. Narrowing truncates the most-significant bits. - `.resize` (no argument) automatically adjusts the width to match the assignment or operation context; narrowing or widening as needed. +- `.eby(K)` extends the width by `K` bits, *relative* to the current width; sugar for `.resize(width + K)`. `K` must be positive, so `.eby` always widens (zero-extension for `UInt` and `Bits`, sign-extension for `SInt`). The relative form shines with parametric widths, where the absolute width would repeat the symbolic expression: `x.eby(1)` instead of `x.resize(W + 1)`. ```scala val b8 = Bits(8) <> VAR @@ -2043,6 +2044,12 @@ val s8 = SInt(8) <> VAR val s4 = SInt(4) <> VAR s8 := s4.resize // sign-extend to match s8's width s4 := s8.resize(4) // explicit narrow to 4 bits + +// relative widening, most useful with parametric widths +val W: Int <> CONST = 8 +val sW = SInt(W) <> VAR +val sW2 = SInt(W + 2) <> VAR +sW2 := sW.eby(2) // sign-extend by 2 bits (to W + 2) ``` ### Bit Concatenation {#bit-concat} @@ -2420,6 +2427,11 @@ Elaborates to: ```scala o := (i.uint + i.uint).bits ``` +The implicit `.bits` result conversion requires an **exact** target width, so the automatic target-context widening described above does not apply to a *wider* `Bits` target (a `Bits(9)` target for `i + i` is a width-mismatch error). `Bits` *operands* widen fine when the target is `UInt`/`SInt` (via their implicit `.uint` conversion); for a genuinely wider `Bits` target, use an explicit carry operation, whose result width then fits exactly: +```scala +val o9 = Bits(9) <> OUT +o9 := i +^ i // UInt[9] carry result converts to the exact-width Bits(9) +``` /// ```scala @@ -2459,28 +2471,37 @@ val r13 = d1 + d2 // Double val r14 = d1 / d2 // Double ``` -/// admonition | Overflow and automatic carry promotion +/// admonition | Overflow and automatic target-context widening type: warning Standard arithmetic operations wrap on overflow. For example, `d"8'255" + d"8'1"` produces `d"8'0"`. Use the carry variants (`+^`, `-^`, `*^`) described below to get a wider result that preserves the full value. -However, when an **anonymous** arithmetic expression (`+`, `-`, `*`) is assigned or connected to a variable that is **wider** than the operation's result, the operation is **automatically promoted** to a carry operation. This matches Verilog's behavior where the assignment target width determines the operation width. The carry result is then resized to fit the target if needed. +However, an **anonymous** arithmetic expression (`+`, `-`, `*`) that is assigned or connected to a variable **wider** than the operation's result is re-evaluated at the target's width and sign, exactly like Verilog's assignment-context width propagation: every operand, recursively through the anonymous expression, is widened to the target type, and the operations stay modular at that width. When that evaluation is exactly a carry operation (a binary operation over simple operands whose carry width fits the target), it elaborates as one; a promoted carry operation is never *extended*, only *truncated*, since extension of a carry result is sign-dependent (an unsigned subtraction's carry result is a two's-complement pattern, and zero-extending it would flip its sign). ```scala val u8 = UInt(8) <> VAR val u9 = UInt(9) <> VAR +val u10 = UInt(10) <> VAR val u12 = UInt(12) <> VAR val u16 = UInt(16) <> VAR -u9 := u8 + u8 // promoted to carry addition (width 9), exact fit -u16 := u8 * u8 // promoted to carry multiplication (width 16), exact fit -u12 := u8 * u8 // promoted to carry multiplication (width 16), resized to 12 - -// Implicit Int operands participate in the promotion: -u9 := u8 + u8 + 1 // elaborates to u9 := (u8 + u8) +^ d"8'1" -u12 := u8 + u8 + 1 // elaborates to u12 := ((u8 + u8) +^ d"8'1").resize(12) - -// Named expressions are NOT promoted: +val s9 = SInt(9) <> VAR +u9 := u8 + u8 // carry fit: elaborates to u8 +^ u8 +u9 := u8 - u8 // carry fit: elaborates to u8 -^ u8 +u16 := u8 * u8 // carry fit: elaborates to u8 *^ u8 +u12 := u8 * u8 // carry beyond the target: (u8 *^ u8).resize(12) +u10 := u8 + u8 // target beyond the carry width: u8.resize(10) + u8.resize(10) +s9 := u8 - u8 // unsigned to signed: operands convert, u8.signed - u8.signed + +// Implicit Int operands and whole chains evaluate at the target width: +u10 := u8 + u8 + 1 // elaborates to u10 := u8.resize(10) + u8.resize(10) + d"10'1" + +// Named expressions are NOT widened: val sum = u8 + u8 // UInt[8], named value -u9 := sum // resized from 8 to 9, no carry promotion +u9 := sum // resized from 8 to 9 + +// Parametric widths decide symbolically and print RELATIVE widenings via `.eby`: +// for a, b: SInt(W) the following hold +// SInt(W + 1) target: sum := a +^ b +// SInt(W + 2) target: acc := a.eby(2) + b.eby(2) ``` /// @@ -2549,10 +2570,10 @@ val t10c = (a +^ b +^ 0) >> 1 // OK: carry chain cannot overflow - The expression uses carry operations (`+^`, `-^`, `*^`), which widen the result. - The integer constant is an explicit bit-accurate literal (e.g., `d"3'4"`). - The bit-accurate expression width is already 32 bits or wider. -- The implicit `Int` is only used in modular operations (`+`, `-`, `*`) that feed an assignment. A same-width target wraps identically in both languages, and a wider target promotes the chain to evaluate at the target width (see the automatic carry promotion above), matching the context Verilog's assignment provides; truncation to the target width commutes with `+`/`-`/`*`, so the two evaluations agree for every input. +- The implicit `Int` is only used in modular operations (`+`, `-`, `*`) that feed an assignment. A same-width target wraps identically in both languages, and a wider target re-evaluates the chain at the target width (see the automatic target-context widening above), matching the context Verilog's assignment provides; truncation to the target width commutes with `+`/`-`/`*`, so the two evaluations agree for every input. ```scala val sum = UInt(10) <> VAR -// OK: promoted to carry, elaborates to sum := ((a + b) +^ d"8'1").resize(10) +// OK: widened to the target, elaborates to sum := a.resize(10) + b.resize(10) + d"10'1" sum := a + b + 1 val cnt = UInt(8) <> VAR cnt := cnt + 1 // OK: same-width target, modular truncation matches diff --git a/lib/src/test/scala/ContextWidenSpec.scala b/lib/src/test/scala/ContextWidenSpec.scala new file mode 100644 index 000000000..09fe3db12 --- /dev/null +++ b/lib/src/test/scala/ContextWidenSpec.scala @@ -0,0 +1,82 @@ +package dfhdl + +/** Target-context widening of anonymous arithmetic (issue dfhdl_by_agents#119): an anonymous `+`, + * `-`, `*` cone assigned or connected to a wider value evaluates at the target's width and sign, + * matching Verilog's assignment-context width propagation. A binary op over leaf operands whose + * carry form fits the target elaborates as a carry op (never extended, only truncated); any other + * widening converts the operands, printed relatively as `.eby(k)` when the target width is a + * literal increment of the source width. + * + * The parametric shapes are the ones a literal-width core spec cannot host (see + * `CoreSpec.DFDecimalSpec` for the literal-width matrix): symbolic carry-fit decisions (`W + 1` + * fits `+^`; `W + W` would fit `*^`) and the relative `.eby(k)` printing. + */ +class ContextWidenSpec extends DesignSpec: + test("parametric target-context widening") { + @top(false) class ParamWiden(val W: Int <> CONST = 8) extends EDDesign: + val a, b = SInt(W) <> IN + val ua, ub = UInt(W) <> IN + val sum = SInt(W + 1) <> OUT + val usub = UInt(W + 1) <> OUT + val acc = SInt(W + 2) <> OUT + val chain = SInt(W + 2) <> OUT + // carry fit is decided symbolically: (W max W) + 1 =~ W + 1 + sum <> a + b + // an unsigned subtraction carry-fits only EXACTLY (its carry result is a wrap + // pattern, which must never be extended) + usub <> ua - ub + // beyond the carry width: operands widen to the target width and the op stays + // modular there, exactly like Verilog's assignment context + acc <> a + b + // a merged (non-binary) chain always evaluates at the target width; the implicit + // Int operand adapts at the operand width and widens along + chain <> a + b + 1 + end ParamWiden + + ParamWiden().assertCodeString( + """|class ParamWiden(val W: Int <> CONST = 8) extends EDDesign: + | val a = SInt(W) <> IN + | val b = SInt(W) <> IN + | val ua = UInt(W) <> IN + | val ub = UInt(W) <> IN + | val sum = SInt(W + 1) <> OUT + | val usub = UInt(W + 1) <> OUT + | val acc = SInt(W + 2) <> OUT + | val chain = SInt(W + 2) <> OUT + | sum <> (a +^ b) + | usub <> (ua -^ ub) + | acc <> (a.eby(2) + b.eby(2)) + | chain <> (a.eby(2) + b.eby(2) + sd"2'1".resize(W).eby(2)) + |end ParamWiden + |""".stripMargin + ) + } + + test("explicit eby") { + @top(false) class Eby(val W: Int <> CONST = 8) extends EDDesign: + val a = SInt(W) <> IN + val ua = UInt(W) <> IN + val b = Bits(W) <> IN + val ax = SInt(W + 3) <> OUT + val ux = UInt(W + 1) <> OUT + val bx = Bits(W + 2) <> OUT + ax <> a.eby(3) + ux <> ua.eby(1) + bx <> b.eby(2) + + Eby().assertCodeString( + """|class Eby(val W: Int <> CONST = 8) extends EDDesign: + | val a = SInt(W) <> IN + | val ua = UInt(W) <> IN + | val b = Bits(W) <> IN + | val ax = SInt(W + 3) <> OUT + | val ux = UInt(W + 1) <> OUT + | val bx = Bits(W + 2) <> OUT + | ax <> a.eby(3) + | ux <> ua.eby(1) + | bx <> b.eby(2) + |end Eby + |""".stripMargin + ) + } +end ContextWidenSpec From 3f7430ba5b702e9d911542f35fff013992bc0146 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Fri, 7 Aug 2026 22:27:27 +0300 Subject: [PATCH 09/25] core+ir+stages: carry ops are purely a printed spelling; `.eby` is the canonical widening form The IR gains one invariant: an arithmetic Func's type always equals its (aligned) operands' type. The carry operators elaborate as modular funcs over operands widened by explicit aliases (`x +^ y` is `x.eby(1) + y.eby(1)` after common-width alignment), with no special-cased Func return type, and the target-context widening in toDFXIntOf loses its carry-fit branch: the widening always evaluates at the target, and the SPELLING of the result is reconstructed at print time by two new DFValAnalysis extractors: `Eby` (literal-delta widening alias) and `CarryFunc`, whose carry-width equations (max+1 for +/-, operand-width sum for *) are decided symbolically via the linear-form calculus, so a parametric `a *^ b` reconstructs even though its operand delta is not a literal. Printers: DFHDL prints CarryFunc as `x op^ y`; VHDL reconstructs cadd/csub and prints a carry mul infix (numeric_std multiplication is already full-width; the previously emitted `cmul` was never defined in dfhdl_pkg); Verilog prints a carry func bare exactly when every consumer provides its width context (a net RHS or a same-width func operand) and the explicit EBY/EXTEND operand forms otherwise, so the self-determined-context truncation genus is structurally impossible and the NamedVerilogSelection carry-naming criteria are deleted. The Verilog-semantics warning machinery derives carry-ness from the CarryFunc shape and walks through the operand widening aliases. Widening printouts prefer the RELATIVE `.eby(k)` spelling as the canonical form whenever the delta is a known bit count, including between literal widths (a widening between literals carries no spelling in the IR, so `x.resize(9)` over an 8-bit x prints back as `x.eby(1)`); a width given by a named parameter or constant keeps its absolute spelling by name. A deliberate semantic edge of the unification: an anonymous explicit-carry result consumed by a wider context now re-evaluates at that context (naming the value pins its width), matching how every other anonymous arithmetic expression composes. Verified by the 19-case yosys SAT miter matrix against the Verilog golden and the full test suite. Co-Authored-By: Claude Fable 5 --- .../compiler/analysis/DFValAnalysis.scala | 70 ++++++++ .../main/scala/dfhdl/compiler/ir/DFRef.scala | 21 ++- .../compiler/printing/DFValPrinter.scala | 25 +-- .../dfhdl/compiler/stages/NamedAliases.scala | 21 --- .../stages/verilog/VerilogValPrinter.scala | 17 ++ .../compiler/stages/vhdl/VHDLValPrinter.scala | 23 ++- .../scala/StagesSpec/ClassDesignKeySpec.scala | 2 +- .../test/scala/StagesSpec/DropBindsSpec.scala | 2 +- .../ExplicitCondExprAssignSpec.scala | 4 +- .../StagesSpec/ExplicitNamedVarsSpec.scala | 4 +- .../StagesSpec/ExplicitRegInitsSpec.scala | 2 +- .../scala/StagesSpec/ExplicitStateSpec.scala | 2 +- .../scala/StagesSpec/NameRegAliasesSpec.scala | 2 +- .../scala/StagesSpec/NamedSelectionSpec.scala | 16 +- .../StagesSpec/PrintCodeStringSpec.scala | 4 +- .../StagesSpec/PrintVerilogCodeSpec.scala | 10 +- .../src/main/scala/dfhdl/core/DFDecimal.scala | 155 ++++++++---------- core/src/test/scala/CoreSpec/DFBitsSpec.scala | 2 +- .../test/scala/CoreSpec/DFDecimalSpec.scala | 55 ++++--- .../test/scala/CoreSpec/DFVectorSpec.scala | 2 +- docs/user-guide/type-system/index.md | 15 +- .../test/scala/ArithSpec/PrioEncSpec.scala | 2 +- .../test/scala/ElaborationChecksSpec.scala | 4 +- 23 files changed, 257 insertions(+), 203 deletions(-) diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/analysis/DFValAnalysis.scala b/compiler/ir/src/main/scala/dfhdl/compiler/analysis/DFValAnalysis.scala index 18ae57ed1..9a939c0b0 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/analysis/DFValAnalysis.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/analysis/DFValAnalysis.scala @@ -32,6 +32,76 @@ object Ident: if (alias.hasTagOf[IdentTag]) Some(alias.relValRef.get) else None +// A relative widening alias: a same-kind (UInt/SInt/Bits) width extension whose delta folds +// to a positive literal `k` for every parameter assignment. This is the value-level twin of +// `IntParamRef.widenDeltaOpt`, which additionally constrains the printed SPELLING to the +// anonymous `base + k` width form; this extractor recognizes the widening SHAPE itself, +// whether the target width is spelled relatively or folded to a literal. +object Eby: + def unapply(alias: DFVal.Alias.AsIs)(using MemberGetSet): Option[(DFVal, Int)] = + val relVal = alias.relValRef.get + val deltaOpt = (alias.dfType, relVal.dfType) match + case (DFUInt(toW), DFUInt(fromW)) => toW.constDiffFrom(fromW) + case (DFSInt(toW), DFSInt(fromW)) => toW.constDiffFrom(fromW) + case (DFBits(toW), DFBits(fromW)) => toW.constDiffFrom(fromW) + case _ => None + deltaOpt.filter(_ > 0).map((relVal, _)) + +// A carry-spelled arithmetic func: a binary `+`/`-`/`*` over two anonymous same-kind widening +// aliases whose bases sit at a common width `cw`, with the func landing exactly at the carry +// width (`cw + 1` for `+`/`-`, `2 * cw` for `*`). This is precisely the shape the carry +// operators (`+^`, `-^`, `*^`) elaborate to, so the printers reconstruct the carry spelling +// from it. The width equations are decided symbolically (linear forms), so a parametric +// `a *^ b` over `SInt(W)` operands reconstructs even though its operand delta `W` is not a +// literal. Extraction is conservative: an undecidable equation simply does not match, and the +// func prints in its structural (operand-widened) form, which is always equivalent. +object CarryFunc: + // A same-kind conversion alias operand candidate. No widening check here: the carry-width + // equations below imply the widening (widths are positive, so `fw == max + 1` or + // `fw == xw + yw` cannot hold for a narrowing operand), and a decidability gate would + // wrongly reject symbolic deltas such as the `W` of a parametric carry mul. + private def widenBase(v: DFVal)(using MemberGetSet): Option[DFVal] = v match + case alias: DFVal.Alias.AsIs if alias.isAnonymous => + val relVal = alias.relValRef.get + val sameKind = (alias.dfType, relVal.dfType) match + case (DFUInt(_), DFUInt(_)) => true + case (DFSInt(_), DFSInt(_)) => true + case _ => false + if (sameKind) Some(relVal) else None + case _ => None + private def widthLinear(v: DFVal)(using MemberGetSet): Option[IntExprCalc.Linear] = + IntExprCalc.DataCalc.linearOfTypeWidth(v.dfType) + def unapply(func: DFVal.Func)(using MemberGetSet): Option[(DFVal, DFVal)] = + import IntExprCalc.DataCalc.* + func.op match + case FuncOp.+ | FuncOp.- | FuncOp.`*` => + func.args match + case aRef :: bRef :: Nil => + for + x <- widenBase(aRef.get) + y <- widenBase(bRef.get) + fwL <- widthLinear(func) + xwL <- widthLinear(x) + ywL <- widthLinear(y) + carryL <- func.op match + case FuncOp.`*` => + // a carry mul keeps its operands' own widths: fw == xw + yw + Some(add(xwL, ywL)) + case _ => + // the aligned (common) width is the wider operand's; require decidability + val dxy = sub(xwL, ywL) + if (isConst(dxy) && dxy.offset >= 0) Some(addConst(xwL, 1)) + else if (isConst(dxy)) Some(addConst(ywL, 1)) + else None + d = sub(fwL, carryL) + if isConst(d) && d.offset == 0 + yield (x, y) + case _ => None + case _ => None + end match + end unapply +end CarryFunc + extension (member: DFMember) // The kind-level half of the unreferenced-anonymous sweeps: whether this member MAY be // dropped when nothing reads it. SHARED by the `DropUnreferencedAnons` compiler stage and diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFRef.scala b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFRef.scala index 974a6f760..e8a709fae 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFRef.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFRef.scala @@ -222,14 +222,16 @@ object IntParamRef: yield diff end constDiffFrom // The literal widening delta `this - that` for printers preferring the RELATIVE - // extension spelling (`.eby(k)`, `EBY_U`/`EBY_S`, VHDL `eby`): defined exactly when - // `this` is an ANONYMOUS `base + k` width increment whose base is the source width - // itself (e.g. a `W + 1` target over a `W`-wide source). A literal width, a NAMED - // width (a parameter or named constant), or any other expression shape prints - // absolutely, by value or by name, so the printed form always preserves the width - // symbols the user can see. + // extension spelling (`.eby(k)`, `EBY_U`/`EBY_S`, VHDL `eby`): defined when `this` is + // a LITERAL width sitting `k` above a literal source width (a widening between + // literal widths carries no spelling in the IR, and the relative form is the + // canonical one), or when `this` is an ANONYMOUS `base + k` width increment whose + // base is the source width itself (e.g. a `W + 1` target over a `W`-wide source). A + // NAMED width (a parameter or named constant) or any other expression shape prints + // absolutely, by name, so the width symbols the user can see are preserved. def widenDeltaOpt(that: IntParamRef)(using MemberGetSet): Option[Int] = intParamRef match + case _: Int => constDiffFrom(that).filter(_ > 0) case ref: DFRef.TypeRef => ref.getOption match case Some(func: DFVal.Func) if func.isAnonymous && func.op == DFVal.Func.Op.+ => @@ -244,8 +246,11 @@ object IntParamRef: DFInt32, Some(BigInt(i)), DFRef.OneWay.Empty, Meta.empty, DFTags.empty )) val baseEquiv = thatValOpt.exists { thatVal => - (thatVal == baseRef.get) || (IntExprCalc.constDiff( - baseRef.get, thatVal, resolveDesignParams = false + (thatVal == baseRef.get) || + (IntExprCalc.constDiff( + baseRef.get, + thatVal, + resolveDesignParams = false ) == Some(0)) } if (baseEquiv) Some(k.toInt) else None diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/printing/DFValPrinter.scala b/compiler/ir/src/main/scala/dfhdl/compiler/printing/DFValPrinter.scala index 406f04bec..10ef5c2d5 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/printing/DFValPrinter.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/printing/DFValPrinter.scala @@ -258,18 +258,19 @@ protected trait DFValPrinter extends AbstractValPrinter: s"${csArgL.applyBrackets()}.repeat${csArgR.applyBrackets(onlyIfRequired = false)}" // infix func case argL :: argR :: Nil if dfVal.op != Func.Op.++ => - val csArgL = argL.refCodeString(typeCS) - val csArgR = argR.refCodeString(typeCS) - val opStr = dfVal.op match - // if the result width for +/-/* ops is larger than the left argument width - // then we have a carry-inclusive operation. to simplify the check given possible - // parameterized widths, we will just compare the type structure and assume the - // width is larger under such conditions. - case Func.Op.+ | Func.Op.- | Func.Op.`*` - if !dfVal.dfType.isUnbounded && !dfVal.dfType.isSimilarTo(argL.get.dfType) => - s"${dfVal.op}^" - case op => commonOpStr - s"${csArgL.applyBrackets()} $opStr ${csArgR.applyBrackets()}" + dfVal match + // a func in the carry SHAPE (operands widened by exactly the carry bit, see + // `CarryFunc`) prints as the carry-operator sugar it elaborated from + case CarryFunc(_, _) => + val csArgL = + argL.get.asInstanceOf[Alias.AsIs].relValRef.refCodeString(typeCS) + val csArgR = + argR.get.asInstanceOf[Alias.AsIs].relValRef.refCodeString(typeCS) + s"${csArgL.applyBrackets()} ${dfVal.op}^ ${csArgR.applyBrackets()}" + case _ => + val csArgL = argL.refCodeString(typeCS) + val csArgR = argR.refCodeString(typeCS) + s"${csArgL.applyBrackets()} $commonOpStr ${csArgR.applyBrackets()}" // unary/postfix func case arg :: Nil => val csArg = arg.refCodeString(typeCS) diff --git a/compiler/stages/src/main/scala/dfhdl/compiler/stages/NamedAliases.scala b/compiler/stages/src/main/scala/dfhdl/compiler/stages/NamedAliases.scala index 0203afb99..39835716b 100644 --- a/compiler/stages/src/main/scala/dfhdl/compiler/stages/NamedAliases.scala +++ b/compiler/stages/src/main/scala/dfhdl/compiler/stages/NamedAliases.scala @@ -141,14 +141,6 @@ end NamedAliases // making sure the names will be unique. case object NamedVerilogSelection extends NamedAliases: override def runCondition(using co: CompilerOptions): Boolean = co.backend.isVerilog - private val carryOps = Set(FuncOp.`*`, FuncOp.+, FuncOp.-) - // A carry-widened unsigned func: its width exceeds its first operand's, so its printed - // Verilog form relies on consumer context to evaluate at the full width. - private def isCarryWidenedUInt(func: DFVal.Func)(using MemberGetSet): Boolean = - carryOps.contains(func.op) && - (func.dfType match - case DFUInt(_) => !func.dfType.isSimilarTo(func.args.head.get.dfType) - case _ => false) extension (dfVal: DFVal)(using MemberGetSet) def hasVerilogName: Boolean = dfVal match @@ -177,15 +169,6 @@ case object NamedVerilogSelection extends NamedAliases: case alias: DFVal.Alias.ApplyRange if alias.compareWidths(alias.relValRef.get)(_ != _).getOrElse(true) => List(alias.relValRef.get) - // A carry-widened func (its width exceeds its operands') consumed by an - // unsigned-to-signed conversion must be named: the conversion prints as a - // `{1'b0, ...}` concatenation, whose operands are self-determined in Verilog, so - // an inline func would evaluate at its narrow operand width and truncate ahead of - // the sign extension. A named value's self-determined width is its declared width, - // which carries the widening through the concat. - case DFVal.Alias.AsIs(dfType = DFSInt(_), relValRef = DFRef(relVal: DFVal.Func)) - if isCarryWidenedUInt(relVal) => - List(relVal) case alias @ DFVal.Alias.AsIs( dfType = _: (DFDecimal | DFBits), relValRef = DFRef(relVal @ (DFBits.Val(_) | DFDecimal.Val(_))) @@ -233,10 +216,6 @@ case object NamedVerilogSelection extends NamedAliases: List(relVal) case alias: DFVal.Alias.ApplyIdx => List(alias.relValRef.get) - case func @ DFVal.Func(op = op, args = DFRef(lhs) :: _ :: Nil) - if isBasicVerilog && !lhs.hasVerilogName && carryOps.contains(op) && - !func.dfType.isSimilarTo(lhs.dfType) => - List(lhs) case func: DFVal.Func => func.getReadDeps.headOption match case Some(dfVal: DFVal) => criteria(dfVal) diff --git a/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogValPrinter.scala b/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogValPrinter.scala index d6b7f9d32..fdbd021b8 100644 --- a/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogValPrinter.scala +++ b/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogValPrinter.scala @@ -157,6 +157,23 @@ protected trait VerilogValPrinter extends AbstractValPrinter: s"${literalGroupOpen}default: ${argL.refCodeString}}" case _ => s"{${argR.refCodeString.applyBrackets()}{${argL.refCodeString}}}" + // A carry-shaped arithmetic func (operands widened by exactly the carry bit, see + // `CarryFunc`) prints BARE (`x - y`) when every consumer provides an evaluation + // context at least as wide as the func: a net's RHS takes the target's width, and a + // same-width func operand is context-determined at the parent's width. Under any + // other consumer (an alias: a sign-conversion concatenation or a resize macro, + // where the operand would be self-determined) the operands keep their explicit + // widened forms, which are width-correct in every context. + case argL :: argR :: Nil if CarryFunc.unapply(dfVal).nonEmpty && { + dfVal.getReadDeps.forall { + case _: DFNet => true + case _: DFVal.Func => true + case _ => false + } + } => + val csX = argL.get.asInstanceOf[Alias.AsIs].relValRef.refCodeString + val csY = argR.get.asInstanceOf[Alias.AsIs].relValRef.refCodeString + s"${csX.applyBrackets()} ${dfVal.op} ${csY.applyBrackets()}" // infix func case argL :: argR :: Nil if dfVal.op != Func.Op.++ => val isInfix = dfVal.op match diff --git a/compiler/stages/src/main/scala/dfhdl/compiler/stages/vhdl/VHDLValPrinter.scala b/compiler/stages/src/main/scala/dfhdl/compiler/stages/vhdl/VHDLValPrinter.scala index 0667cd91b..cedb7d299 100644 --- a/compiler/stages/src/main/scala/dfhdl/compiler/stages/vhdl/VHDLValPrinter.scala +++ b/compiler/stages/src/main/scala/dfhdl/compiler/stages/vhdl/VHDLValPrinter.scala @@ -66,6 +66,17 @@ protected trait VHDLValPrinter extends AbstractValPrinter: case _ => println(dfVal) ??? + // carry-shaped arithmetic (operands widened by exactly the carry bit, see + // `CarryFunc`): `+`/`-` reconstruct the cadd/csub carry helpers; a carry `*` + // prints its bases bare, numeric_std multiplication being naturally full-width + // (a'length + b'length) + case argL :: argR :: Nil if CarryFunc.unapply(dfVal).nonEmpty => + val csX = argL.get.asInstanceOf[Alias.AsIs].relValRef.refCodeString + val csY = argR.get.asInstanceOf[Alias.AsIs].relValRef.refCodeString + dfVal.op match + case Func.Op.+ => s"cadd($csX, $csY)" + case Func.Op.- => s"csub($csX, $csY)" + case _ => s"${csX.applyBrackets()} * ${csY.applyBrackets()}" // infix/regular func case argL :: argR :: Nil if dfVal.op != Func.Op.++ => var infix = true @@ -91,18 +102,6 @@ protected trait VHDLValPrinter extends AbstractValPrinter: case _ => infix = false "slv_srl" - // if the result width for +/- ops is larger than the left argument width - // then we have a carry-inclusive operation. to simplify the check given possible - // parameterized widths, we will just compare the type structure and assume the - // width is larger under such conditions. A carry `*` needs no helper: numeric_std - // multiplication is already full-width (a'length + b'length), which is exactly - // the carry-mul width, so it stays infix. - case op @ (Func.Op.+ | Func.Op.-) - if !dfVal.dfType.isSimilarTo(argL.get.dfType) => - infix = false - op match - case Func.Op.+ => "cadd" - case Func.Op.- => "csub" case _ => commonOpStr if (infix) s"${argL.refCodeString.applyBrackets()} $opStr ${argR.refCodeString.applyBrackets()}" diff --git a/compiler/stages/src/test/scala/StagesSpec/ClassDesignKeySpec.scala b/compiler/stages/src/test/scala/StagesSpec/ClassDesignKeySpec.scala index 8bf23e338..16347e909 100644 --- a/compiler/stages/src/test/scala/StagesSpec/ClassDesignKeySpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/ClassDesignKeySpec.scala @@ -41,7 +41,7 @@ class ClassDesignKeySpec extends StageSpec: | val c1 = Child(width = 8) | val c2 = Child(width = 16) | c1.x <> x - | c2.x <> c1.y.resize(16) + | c2.x <> c1.y.eby(8) | y <> c2.y.resize(8) |end Top""".stripMargin ) diff --git a/compiler/stages/src/test/scala/StagesSpec/DropBindsSpec.scala b/compiler/stages/src/test/scala/StagesSpec/DropBindsSpec.scala index 2109a73ac..fdc297554 100644 --- a/compiler/stages/src/test/scala/StagesSpec/DropBindsSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/DropBindsSpec.scala @@ -46,7 +46,7 @@ class DropBindsSpec extends StageSpec(stageCreatesUnrefAnons = true): | val hi = x(11, 8) | val hi = x(7, 4) | x match - | case h"8?88" => z := hi.resize(8) + | case h"8?88" => z := hi.eby(4) | case h"fb?e" => | end match | val same = x(11, 8) diff --git a/compiler/stages/src/test/scala/StagesSpec/ExplicitCondExprAssignSpec.scala b/compiler/stages/src/test/scala/StagesSpec/ExplicitCondExprAssignSpec.scala index 5c20fd073..7f3a607a4 100644 --- a/compiler/stages/src/test/scala/StagesSpec/ExplicitCondExprAssignSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/ExplicitCondExprAssignSpec.scala @@ -85,8 +85,8 @@ class ExplicitCondExprAssignSpec extends StageSpec(stageCreatesUnrefAnons = true | case sd"16'1" => zz := sd"4'5" | case sd"16'2" => zz := sd"4'3" | end match - | if (x < sd"16'11") z2 := zz.resize(16) + sd"16'3" - | else z2 := zz.resize(16) + | if (x < sd"16'11") z2 := zz.eby(12) + sd"16'3" + | else z2 := zz.eby(12) | case _ => z2 := z + sd"16'12" | end match | y := z diff --git a/compiler/stages/src/test/scala/StagesSpec/ExplicitNamedVarsSpec.scala b/compiler/stages/src/test/scala/StagesSpec/ExplicitNamedVarsSpec.scala index f4328f959..28c75b08f 100644 --- a/compiler/stages/src/test/scala/StagesSpec/ExplicitNamedVarsSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/ExplicitNamedVarsSpec.scala @@ -103,8 +103,8 @@ class ExplicitNamedVarsSpec extends StageSpec: | case sd"16'1" => zz := sd"4'5" | case sd"16'2" => zz := sd"4'3" | end match - | if (x < sd"16'11") z2 := zz.resize(16) + sd"16'3" - | else z2 := zz.resize(16) + | if (x < sd"16'11") z2 := zz.eby(12) + sd"16'3" + | else z2 := zz.eby(12) | case _ => z2 := z + sd"16'12" | end match | y := z diff --git a/compiler/stages/src/test/scala/StagesSpec/ExplicitRegInitsSpec.scala b/compiler/stages/src/test/scala/StagesSpec/ExplicitRegInitsSpec.scala index 9a552d061..0bdfc7651 100644 --- a/compiler/stages/src/test/scala/StagesSpec/ExplicitRegInitsSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/ExplicitRegInitsSpec.scala @@ -48,7 +48,7 @@ class ExplicitRegInitsSpec extends StageSpec(stageCreatesUnrefAnons = true): | val y2 = Bits(16) <> OUT | y1 := (x1 + sd"16'1").reg(1, init = ?) | val z = (x2 << 1).reg(1, init = h"????") - | y2 := x2(7, 0).reg(1, init = h"??").resize(16).reg(2, init = h"????") | z + | y2 := x2(7, 0).reg(1, init = h"??").eby(8).reg(2, init = h"????") | z |end ID |""".stripMargin ) diff --git a/compiler/stages/src/test/scala/StagesSpec/ExplicitStateSpec.scala b/compiler/stages/src/test/scala/StagesSpec/ExplicitStateSpec.scala index 5af13f287..fa76a9c12 100644 --- a/compiler/stages/src/test/scala/StagesSpec/ExplicitStateSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/ExplicitStateSpec.scala @@ -298,7 +298,7 @@ class ExplicitStateSpec extends StageSpec: | shamt := op(4, 0) | val outCalc = Bits(32) <> VAR | op match - | case _ => outCalc := shamt.resize(32) + | case _ => outCalc := shamt.eby(27) | end match | aluOut := outCalc |end ALU diff --git a/compiler/stages/src/test/scala/StagesSpec/NameRegAliasesSpec.scala b/compiler/stages/src/test/scala/StagesSpec/NameRegAliasesSpec.scala index 23e2cee45..313f17b4d 100644 --- a/compiler/stages/src/test/scala/StagesSpec/NameRegAliasesSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/NameRegAliasesSpec.scala @@ -83,7 +83,7 @@ class NameRegAliasesSpec extends StageSpec(stageCreatesUnrefAnons = true): | y1.din := x1 + sd"16'1" | z.din := x2 << 1 | y2_part1_reg.din := x2(7, 0) - | y2_part2_reg1.din := y2_part1_reg.resize(16) + | y2_part2_reg1.din := y2_part1_reg.eby(8) | y2_part2_reg2.din := y2_part2_reg1 | y2 := y2_part2_reg2 | z |end ID diff --git a/compiler/stages/src/test/scala/StagesSpec/NamedSelectionSpec.scala b/compiler/stages/src/test/scala/StagesSpec/NamedSelectionSpec.scala index 05ae5c13d..5d6c08a5b 100644 --- a/compiler/stages/src/test/scala/StagesSpec/NamedSelectionSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/NamedSelectionSpec.scala @@ -146,7 +146,7 @@ class NamedSelectionSpec extends StageSpec(stageCreatesUnrefAnons = true): | val x = UInt(6) <> IN | val y = (x min x).resize(5) | val z = (x + x).resize(5) - | val w = x.resize(20) + x.resize(20) + x.resize(20) + | val w = x.eby(14) + x.eby(14) + x.eby(14) |end ID""".stripMargin ) } @@ -167,7 +167,7 @@ class NamedSelectionSpec extends StageSpec(stageCreatesUnrefAnons = true): | val y = y_part.resize(5) | val z_part = x + x | val z = z_part.resize(5) - | val w = x.resize(20) + x.resize(20) + x.resize(20) + | val w = x.eby(14) + x.eby(14) + x.eby(14) |end ID""".stripMargin ) } @@ -262,10 +262,11 @@ class NamedSelectionSpec extends StageSpec(stageCreatesUnrefAnons = true): ) } // a carry-widened func consumed by an unsigned-to-signed conversion is named, so the - // Verilog `{1'b0, ...}` sign-extension concat (whose operands are self-determined) - // sees a declared identifier instead of an inline func pinned at its narrow operand - // width (issue #452) - test("Sign-converted carry func is named") { + // With carry ops elaborating as modular funcs over explicitly widened operands, a + // sign conversion re-evaluates the cone at the converted width and no naming is needed: + // every operand carries its own widening, so Verilog's self-determined contexts can no + // longer truncate it (issue #452 became structurally impossible) + test("Sign-converted carry func needs no naming") { class SignedCarry extends EDDesign: val a = UInt(8) <> IN val b = UInt(8) <> IN @@ -279,8 +280,7 @@ class NamedSelectionSpec extends StageSpec(stageCreatesUnrefAnons = true): | val a = UInt(8) <> IN | val b = UInt(8) <> IN | val o = SInt(10) <> OUT - | val o_part = a +^ b - | o <> o_part.signed + | o <> (a.signed +^ b.signed) |end SignedCarry |""".stripMargin ) diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala index e2ae87490..5f6796ea1 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala @@ -2291,14 +2291,14 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): test("cascade aliasing regression") { class Foo() extends DFDesign: val x = UInt(3) <> IN - val y = x.resize(16).bits.sint + val y = x.eby(13).bits.sint end Foo val top = (new Foo) assertCodeString( top, """|class Foo extends DFDesign: | val x = UInt(3) <> IN - | val y = x.resize(16).bits.sint + | val y = x.eby(13).bits.sint |end Foo""".stripMargin ) } diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala index 6c1fbd1ce..da16131bb 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala @@ -3267,7 +3267,9 @@ class PrintVerilogCodeSpec extends StageSpec: // inline func inside the `$signed({1'b0, ...})` sign extension would be evaluated at // its narrow operand width and truncate; the named variable's assignment provides the // widening context and the concat sees a declared identifier (issue #452) - test("sign-converted carry func is named ahead of the concat") { + // the sign-converted carry cone re-evaluates at the converted width, so its emission + // is per-operand sign extensions under the assignment context, with no named part + test("sign-converted carry cone emission") { class SignedCarry extends EDDesign: val a = UInt(2) <> IN val b = UInt(8) <> IN @@ -3291,12 +3293,10 @@ class PrintVerilogCodeSpec extends StageSpec: | output logic signed [9:0] q |); | `include "dfhdl_defs.svh" - | logic [8:0] q_part; | always_comb | begin - | o = 8'sd0 - (8'sd3 * $signed(`EXTEND_U(a, 2, 8))); - | q_part = b + c; - | q = $signed({1'b0, q_part}); + | o = 8'sd0 - (8'sd3 * $signed(`EBY_U(a, 6))); + | q = $signed({1'b0, b}) + $signed({1'b0, c}); | end |endmodule |""".stripMargin diff --git a/core/src/main/scala/dfhdl/core/DFDecimal.scala b/core/src/main/scala/dfhdl/core/DFDecimal.scala index 5953bb030..4bc07a1a0 100644 --- a/core/src/main/scala/dfhdl/core/DFDecimal.scala +++ b/core/src/main/scala/dfhdl/core/DFDecimal.scala @@ -1377,85 +1377,39 @@ object DFXInt: func.dfType =~ func.args.head.get.dfType && contextWidenCheck(func.asValOf[DFSInt[Int]].widthIntParam) } => - import IntParam.+ - val funcWidth: IntParam[Int] = func.asValOf[DFSInt[Int]].widthIntParam - val carryWidth: IntParam[Int] = func.op match - case FuncOp.* => funcWidth + funcWidth - case _ => funcWidth + 1 - def isWidenableCone(v: ir.DFVal): Boolean = v match - case f: ir.DFVal.Func => - f.isAnonymous && - (f.dfType match - case ir.DFUInt(_) | ir.DFSInt(_) => - f.op match - case FuncOp.+ | FuncOp.- | FuncOp.* => - f.dfType =~ f.args.head.get.dfType - case _ => false - case _ => false) - case _ => signConversionRelVal(v).exists(isWidenableCone) - // The carry spelling is preferred where it is provably identical to the - // target-width evaluation: a BINARY func over leaf operands (neither is a - // widenable cone itself), with the target's own sign, whose carry width - // fits the target exactly (bare carry op) or exceeds it decidably (carry - // op + truncating resize, applied by the width fix below). A promoted - // carry op is never EXTENDED, only truncated: truncation commutes with - // +/-/* unconditionally, while extension is sign/op-dependent (an - // unsigned subtraction's carry result is a wrap pattern, not the - // difference). An undecidable symbolic comparison falls back to the - // evaluated form, which is correct either way, just more verbose. - def carryFits: Boolean = - dfType.signed.value == func.dfType.asInstanceOf[ir.DFDecimal].signed && - func.args.lengthIs == 2 && - !func.args.exists(r => isWidenableCone(r.get)) && { - def cmp(f: (Int, Int) => Boolean) = dfType.asFE[DFSInt[Int]] - .compareWidths(DFXInt(true, carryWidth, BitAccurate))(f) - cmp(_ == _).getOrElse(false) || cmp(_ < _).getOrElse(false) - } // The widened Func is BUILT FRESH rather than revised in place (an // anonymous member is never revised; issue #449); the original cone - // becomes debris for the end-of-design sweep. Under meta-programming - // there is no MutableDB revision (matching `setMember`'s behavior - // there): the retyped value is returned unregistered and the argument - // conversions are skipped, since no member is registered. - if (carryFits) - val newDT = func.dfType.asInstanceOf[ir.DFDecimal] - .copy(magnitudeWidthParamRef = carryWidth.ref) - if (dfc.inMetaProgramming) func.updateDFType(newDT).asValOf[DFSInt[Int]] - else - ir.DFVal.Func( - newDT, - func.op, - func.args.map(_.get.refTW[ir.DFVal](knownReachable = true)), - dfc.ownerOrEmptyRef, - func.meta, - func.tags - ).addMember.asValOf[DFSInt[Int]] + // becomes debris for the end-of-design sweep. The spelling of the result + // (a carry op or explicit operand widenings) is purely a PRINTING + // decision, reconstructed from this shape by the CarryFunc/Eby + // extractors. The widened evaluation type is the target itself as a + // bit-accurate type; an Int target widens the cone at its native 32-bit + // width (Verilog's `integer` context) and converts below. + val newDT = dfType.asIR.asInstanceOf[ir.DFDecimal].copy( + magnitudeWidthParamRef = dfType.widthIntParam.ref, + nativeType = BitAccurate + ) + if (dfc.inMetaProgramming) + // no MutableDB revision under meta-programming (matching `setMember`'s + // behavior there): the retyped value is returned unregistered and the + // argument conversions are skipped, since no member is registered + func.updateDFType(newDT).asValOf[DFSInt[Int]] else - // the widened evaluation type is the target itself as a bit-accurate - // type; an Int target widens the cone at its native 32-bit width - // (Verilog's `integer` context) and converts below - val newDT = dfType.asIR.asInstanceOf[ir.DFDecimal].copy( - magnitudeWidthParamRef = dfType.widthIntParam.ref, - nativeType = BitAccurate - ) - if (dfc.inMetaProgramming) func.updateDFType(newDT).asValOf[DFSInt[Int]] - else - val widenedArgs = func.args.map { argRef => - DFXInt.Val.Ops.toDFXIntOf( - argRef.get.asValOf[DFXInt[Boolean, Int, NativeType]] - )(DFXInt(dfType.signed, dfType.widthIntParam, BitAccurate))(using - dfc.anonymize - ) - } - ir.DFVal.Func( - newDT, - func.op, - widenedArgs.map(_.asIR.refTW[ir.DFVal](knownReachable = true)), - dfc.ownerOrEmptyRef, - func.meta, - func.tags - ).addMember.asValOf[DFSInt[Int]] - end if + val widenedArgs = func.args.map { argRef => + DFXInt.Val.Ops.toDFXIntOf( + argRef.get.asValOf[DFXInt[Boolean, Int, NativeType]] + )(DFXInt(dfType.signed, dfType.widthIntParam, BitAccurate))(using + dfc.anonymize + ) + } + ir.DFVal.Func( + newDT, + func.op, + widenedArgs.map(_.asIR.refTW[ir.DFVal](knownReachable = true)), + dfc.ownerOrEmptyRef, + func.meta, + func.tags + ).addMember.asValOf[DFSInt[Int]] end if case _ => // Fold stacked widenings: an anonymous same-kind widening resize alias @@ -1619,7 +1573,9 @@ object DFXInt: case func: ir.DFVal.Func if func.isAnonymous => func.op match case FuncOp.+ | FuncOp.- | FuncOp.* => - val isNonCarry = func.dfType =~ func.args.head.get.dfType + // carry-ness is a SHAPE property now (operand-widened funcs, see CarryFunc) + val isNonCarry = + dfhdl.compiler.analysis.CarryFunc.unapply(func).isEmpty val isNarrowNonCarry = isNonCarry && resolvedWidthIsNarrow(func) isNarrowNonCarry || func.args.exists(ref => containsNarrowNonCarryArith(ref.get)) @@ -1628,7 +1584,13 @@ object DFXInt: case _ => signConversionRelVal(dfVal) match case Some(relVal) => containsNarrowNonCarryArith(relVal) - case None => false + case None => + dfVal match + case alias: ir.DFVal.Alias.AsIs if alias.isAnonymous => + dfhdl.compiler.analysis.Eby.unapply(alias) match + case Some(relVal, _) => containsNarrowNonCarryArith(relVal) + case None => false + case _ => false // Check if an anonymous sub-tree contains narrow non-carry arith that // also has an ImplicitlyFromIntTag operand (Verilog "Forcing Larger @@ -1640,7 +1602,8 @@ object DFXInt: case func: ir.DFVal.Func if func.isAnonymous => func.op match case FuncOp.+ | FuncOp.- | FuncOp.* => - val isNonCarry = func.dfType =~ func.args.head.get.dfType + val isNonCarry = + dfhdl.compiler.analysis.CarryFunc.unapply(func).isEmpty val isNarrowNonCarry = isNonCarry && resolvedWidthIsNarrow(func) (isNarrowNonCarry && func.args.exists(ref => hasImplicitlyFromIntTag(ref.get))) || func.args.exists(ref => @@ -1653,7 +1616,14 @@ object DFXInt: case _ => signConversionRelVal(dfVal) match case Some(relVal) => containsNarrowNonCarryArithWithTaggedOperand(relVal) - case None => false + case None => + dfVal match + case alias: ir.DFVal.Alias.AsIs if alias.isAnonymous => + dfhdl.compiler.analysis.Eby.unapply(alias) match + case Some(relVal, _) => + containsNarrowNonCarryArithWithTaggedOperand(relVal) + case None => false + case _ => false // Unified Verilog-semantics warning trigger shared by `/`, `%` (arithOp) // and comparison operations (DFXIntCompare). Warns when a narrow non-carry @@ -1972,12 +1942,19 @@ object DFXInt: else if (lhsIsWildcard) rhsVal.widthIntParam else lhsVal.widthIntParam.max(rhsVal.widthIntParam) val width = commonWidth + 1 - val dfType = DFXInt(resultSigned, width, BitAccurate) - // Resize both operands to common width, converting to signed if needed + // Align both operands to the common width (converting sign if needed), then widen + // them BY THE CARRY BIT as explicit aliases: a carry operation IS the modular + // operation over carry-widened operands, with no special-cased Func type; the + // printers reconstruct the `op^` spelling from this shape (see `CarryFunc`). The + // widening is an explicit alias, never a re-evaluation, so a nested anonymous + // chain operand keeps its own width semantics. val commonType = DFXInt(resultSigned, commonWidth, BitAccurate) val lhsFix = lhsVal.toDFXIntOf(commonType)(using dfcAnon) val rhsFix = rhsVal.toDFXIntOf(commonType)(using dfcAnon) - DFVal.Func(dfType, op.value, List(lhsFix, rhsFix)) + def wideType = DFXInt(resultSigned, width, BitAccurate) + val lhsWide = DFVal.Alias.AsIs(wideType, lhsFix)(using dfcAnon) + val rhsWide = DFVal.Alias.AsIs(wideType, rhsFix)(using dfcAnon) + DFVal.Func(wideType, op.value, List(lhsWide, rhsWide)) .asInstanceOf[Out] }(using dfc, CTName(op.value.toString + "^")) end evOpCarryAddSubDFXInt @@ -2023,15 +2000,17 @@ object DFXInt: val baWidth: IntParam[Int] = if (rhsIsWildcard) lhsVal.widthIntParam else rhsVal.widthIntParam val commonType = DFXInt(baSigned, baWidth, BitAccurate) - val dfType = DFXInt(baSigned, baWidth + baWidth, BitAccurate) + def wideType = DFXInt(baSigned, baWidth + baWidth, BitAccurate) val lhsFix = lhsVal.toDFXIntOf(commonType)(using dfcAnon) val rhsFix = rhsVal.toDFXIntOf(commonType)(using dfcAnon) - DFVal.Func(dfType, FuncOp.`*`, List(lhsFix, rhsFix)) + val lhsWide = DFVal.Alias.AsIs(wideType, lhsFix)(using dfcAnon) + val rhsWide = DFVal.Alias.AsIs(wideType, rhsFix)(using dfcAnon) + DFVal.Func(wideType, FuncOp.`*`, List(lhsWide, rhsWide)) .asInstanceOf[Out] else val resultSigned = lhsVal.dfType.signed || rhsVal.dfType.signed val width = lhsVal.widthIntParam + rhsVal.widthIntParam - val dfType = DFXInt(resultSigned, width, BitAccurate) + def wideType = DFXInt(resultSigned, width, BitAccurate) // Convert unsigned operand to signed if needed val lhsFix = if (resultSigned && !lhsVal.dfType.signed) @@ -2045,7 +2024,9 @@ object DFXInt: dfcAnon ) else rhsVal - DFVal.Func(dfType, FuncOp.`*`, List(lhsFix, rhsFix)) + val lhsWide = DFVal.Alias.AsIs(wideType, lhsFix)(using dfcAnon) + val rhsWide = DFVal.Alias.AsIs(wideType, rhsFix)(using dfcAnon) + DFVal.Func(wideType, FuncOp.`*`, List(lhsWide, rhsWide)) .asInstanceOf[Out] end if }(using dfc, CTName("*^")) diff --git a/core/src/test/scala/CoreSpec/DFBitsSpec.scala b/core/src/test/scala/CoreSpec/DFBitsSpec.scala index 04d21979a..bec03f38e 100644 --- a/core/src/test/scala/CoreSpec/DFBitsSpec.scala +++ b/core/src/test/scala/CoreSpec/DFBitsSpec.scala @@ -114,7 +114,7 @@ class DFBitsSpec extends DFSpec: |b8 := h"??" |b8 := u8.bits |b8 := u8.bits - |b8 := b3M.resize(8) + |b8 := b3M.eby(5) |b3M := b8.resize(3) |b8 := (h"1", b"1", b"0", b"11").toBits |b4M := h"1" diff --git a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala index 019d449aa..b06da875a 100644 --- a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala +++ b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala @@ -58,7 +58,7 @@ class DFDecimalSpec extends DFSpec: test("DFVal Conversion") { assertCodeString { """|val t0: Bits[6] <> CONST = h"6'00" - |val t1: UInt[8] <> CONST = t0.uint.resize(8) + |val t1: UInt[8] <> CONST = t0.uint.eby(2) |val t2 = UInt(8) <> VAR |val t3: UInt[6] <> CONST = t0.uint |val t4: SInt[7] <> CONST = t0.uint.signed @@ -170,19 +170,19 @@ class DFDecimalSpec extends DFSpec: |u8 := d"8'0" |u8 := ? |u8 := d"8'7" - |u8 := b6.uint.resize(8) - |u8 := u6.resize(8) - |s8 := (-u6.signed).resize(8) + |u8 := b6.uint.eby(2) + |u8 := u6.eby(2) + |s8 := (-u6.signed).eby(1) |s8 := -s8 - |s8 := (-b6.uint.signed).resize(8) + |s8 := (-b6.uint.signed).eby(1) |s8 := sd"8'0" |s8 := sd"8'127" |s8 := sd"8'0" |s8 := ? |s8 := sd"8'-1" |s8 := sd"8'-127" - |s8 := u6.signed.resize(8) - |s8 := s6.resize(8) + |s8 := u6.signed.eby(1) + |s8 := s6.eby(2) |u6 := u8.resize(6) |s6 := s8.resize(6) |u6 := u6 ^ u6 @@ -580,9 +580,9 @@ class DFDecimalSpec extends DFSpec: |val t5 = u8 % d"8'9" |val t6 = u8 * d"8'22" |val t7 = s8 + sd"8'22" - |val t8 = s8 +^ sd"8'1" - |val t9 = u8 -^ d"8'22" - |val t10 = d"7'100" *^ u8 + |val t8 = s8.eby(1) + sd"9'1" + |val t9 = u8.eby(1) - d"9'22" + |val t10 = d"15'100" * u8.eby(7) |""".stripMargin } { val t1 = u8 + u8 @@ -921,20 +921,20 @@ class DFDecimalSpec extends DFSpec: """|u9 := u8 +^ u8 |u9 := u8 -^ u8 |u16 := u8 *^ u8 - |u10 := u8.resize(10) + u8.resize(10) + |u10 := u8.eby(2) + u8.eby(2) |u8b := u8 + u8 |val sum = u8 + u8 - |u9 := sum.resize(9) + |u9 := sum.eby(1) |s9 := s8 +^ s8 - |u9 := (u8 / u8).resize(9) - |u9 := u8 +^ u5.resize(8) - |u9 := u8 +^ d"8'200" - |u12 := (u8 *^ u8).resize(12) - |u9 := u8.resize(9) + u8.resize(9) + u8.resize(9) - |u9 := u8.resize(9) + u8.resize(9) + u8.resize(9) + u8.resize(9) - |u10 := u8.resize(10) + u8.resize(10) + d"10'1" - |u10 := u8.resize(10) + u8b.resize(10) + u8.resize(10) + d"10'1" - |s9 := s8.resize(9) + s8.resize(9) + sd"9'1" + |u9 := (u8 / u8).eby(1) + |u9 := u8 +^ u5 + |u9 := u8.eby(1) + d"9'200" + |u12 := u8.eby(4) * u8.eby(4) + |u9 := u8.eby(1) + u8.eby(1) + u8.eby(1) + |u9 := u8.eby(1) + u8.eby(1) + u8.eby(1) + u8.eby(1) + |u10 := u8.eby(2) + u8.eby(2) + d"10'1" + |u10 := u8.eby(2) + u8b.eby(2) + u8.eby(2) + d"10'1" + |s9 := s8.eby(1) + s8.eby(1) + sd"9'1" |""".stripMargin } { // An anonymous +/-/* cone assigned to a wider target re-evaluates at the target @@ -958,11 +958,12 @@ class DFDecimalSpec extends DFSpec: s9 := s8 + s8 // Division is not context-widened (zero-extension commutes with unsigned division) u9 := u8 / u8 - // Asymmetric widths: u5 was aligned to 8 at the op; carry fits the target exactly + // Asymmetric widths: u5 aligns at the op and the carry spelling reconstructs u9 := u8 + u5 - // Int literal: adapts at the operand width; carry fits the target exactly + // Int literal: the const folds at the target width, so the modular (equivalent) + // spelling prints instead of a carry reconstruction u9 := u8 + 200 - // Mul carry beyond the target: carry mul + truncating resize (truncation commutes) + // Mul beyond the carry fit: evaluation at the target width u12 := u8 * u8 // widening with 3 arguments (merged func): not binary, evaluated at the target u9 := u8 + u8 + u8 @@ -980,9 +981,9 @@ class DFDecimalSpec extends DFSpec: val s8 = SInt(8) <> VAR val s9 = SInt(9) <> VAR assertCodeString { - """|s8 := sd"8'0" - (sd"8'3" * u2.signed.resize(8)) - |s8 := s8 - (sd"8'3" * u2.signed.resize(8)) - |s9 := (sd"8'3" *^ u2.signed.resize(8)).resize(9) + s8.resize(9) + """|s8 := sd"8'0" - (sd"8'3" * u2.signed.eby(5)) + |s8 := s8 - (sd"8'3" * u2.signed.eby(5)) + |s9 := (sd"9'3" * u2.signed.eby(6)) + s8.eby(1) |""".stripMargin } { // The unsigned narrow chain is widened at the OPERANDS when a signed sibling diff --git a/core/src/test/scala/CoreSpec/DFVectorSpec.scala b/core/src/test/scala/CoreSpec/DFVectorSpec.scala index 7bb7a119f..fa35dff4a 100644 --- a/core/src/test/scala/CoreSpec/DFVectorSpec.scala +++ b/core/src/test/scala/CoreSpec/DFVectorSpec.scala @@ -19,7 +19,7 @@ class DFVectorSpec extends DFSpec: |val i2 = UInt(2) <> VAR |val i4 = UInt(4) <> VAR |val t4 = v1(i.toInt) - |val o2 = v1(i2.resize(3).toInt) + |val o2 = v1(i2.eby(1).toInt) |val o4 = v1(i4.resize(3).toInt) |val v3 = UInt(8) X 4 X 4 <> VAR |v3 := all(all(d"8'0")) diff --git a/docs/user-guide/type-system/index.md b/docs/user-guide/type-system/index.md index 58ef40d72..2d89fcffe 100755 --- a/docs/user-guide/type-system/index.md +++ b/docs/user-guide/type-system/index.md @@ -2027,7 +2027,7 @@ Applies to: `Bits`, `UInt`, `SInt` - `.resize(N)` sets the width to exactly `N` bits. For `UInt` and `Bits`, widening zero-extends; for `SInt`, widening sign-extends. Narrowing truncates the most-significant bits. - `.resize` (no argument) automatically adjusts the width to match the assignment or operation context; narrowing or widening as needed. -- `.eby(K)` extends the width by `K` bits, *relative* to the current width; sugar for `.resize(width + K)`. `K` must be positive, so `.eby` always widens (zero-extension for `UInt` and `Bits`, sign-extension for `SInt`). The relative form shines with parametric widths, where the absolute width would repeat the symbolic expression: `x.eby(1)` instead of `x.resize(W + 1)`. +- `.eby(K)` extends the width by `K` bits, *relative* to the current width; sugar for `.resize(width + K)`. `K` must be positive, so `.eby` always widens (zero-extension for `UInt` and `Bits`, sign-extension for `SInt`). This is the **canonical widening spelling**: elaboration prints any widening whose delta is a known number of bits in this relative form (a user-written `x.resize(9)` over an 8-bit `x` prints back as `x.eby(1)` -- the two produce identical designs). It is also the form that scales to parametric widths, where the absolute spelling would repeat the symbolic expression: `x.eby(1)` instead of `x.resize(W + 1)`. Absolute `.resize` remains the spelling for narrowing and for widths given by a named parameter. ```scala val b8 = Bits(8) <> VAR @@ -2050,6 +2050,7 @@ val W: Int <> CONST = 8 val sW = SInt(W) <> VAR val sW2 = SInt(W + 2) <> VAR sW2 := sW.eby(2) // sign-extend by 2 bits (to W + 2) +b8 := b4.eby(4) // zero-extend by 4 bits; same design as b4.resize(8) ``` ### Bit Concatenation {#bit-concat} @@ -2475,7 +2476,7 @@ val r14 = d1 / d2 // Double type: warning Standard arithmetic operations wrap on overflow. For example, `d"8'255" + d"8'1"` produces `d"8'0"`. Use the carry variants (`+^`, `-^`, `*^`) described below to get a wider result that preserves the full value. -However, an **anonymous** arithmetic expression (`+`, `-`, `*`) that is assigned or connected to a variable **wider** than the operation's result is re-evaluated at the target's width and sign, exactly like Verilog's assignment-context width propagation: every operand, recursively through the anonymous expression, is widened to the target type, and the operations stay modular at that width. When that evaluation is exactly a carry operation (a binary operation over simple operands whose carry width fits the target), it elaborates as one; a promoted carry operation is never *extended*, only *truncated*, since extension of a carry result is sign-dependent (an unsigned subtraction's carry result is a two's-complement pattern, and zero-extending it would flip its sign). +However, an **anonymous** arithmetic expression (`+`, `-`, `*`) that is assigned or connected to a variable **wider** than the operation's result is re-evaluated at the target's width and sign, exactly like Verilog's assignment-context width propagation: every operand, recursively through the anonymous expression, is widened to the target type, and the operations stay modular at that width. The carry operators are themselves shorthand for exactly this operand-widened evaluation (`x +^ y` is `x.eby(1) + y.eby(1)` with the operands first aligned to a common width), so when a widening lands exactly on a carry shape it prints back as the carry operator. ```scala val u8 = UInt(8) <> VAR @@ -2487,16 +2488,16 @@ val s9 = SInt(9) <> VAR u9 := u8 + u8 // carry fit: elaborates to u8 +^ u8 u9 := u8 - u8 // carry fit: elaborates to u8 -^ u8 u16 := u8 * u8 // carry fit: elaborates to u8 *^ u8 -u12 := u8 * u8 // carry beyond the target: (u8 *^ u8).resize(12) -u10 := u8 + u8 // target beyond the carry width: u8.resize(10) + u8.resize(10) +u12 := u8 * u8 // beyond the carry fit: u8.eby(4) * u8.eby(4) +u10 := u8 + u8 // target beyond the carry width: u8.eby(2) + u8.eby(2) s9 := u8 - u8 // unsigned to signed: operands convert, u8.signed - u8.signed // Implicit Int operands and whole chains evaluate at the target width: -u10 := u8 + u8 + 1 // elaborates to u10 := u8.resize(10) + u8.resize(10) + d"10'1" +u10 := u8 + u8 + 1 // elaborates to u10 := u8.eby(2) + u8.eby(2) + d"10'1" // Named expressions are NOT widened: val sum = u8 + u8 // UInt[8], named value -u9 := sum // resized from 8 to 9 +u9 := sum // extended by 1: sum.eby(1) // Parametric widths decide symbolically and print RELATIVE widenings via `.eby`: // for a, b: SInt(W) the following hold @@ -2573,7 +2574,7 @@ val t10c = (a +^ b +^ 0) >> 1 // OK: carry chain cannot overflow - The implicit `Int` is only used in modular operations (`+`, `-`, `*`) that feed an assignment. A same-width target wraps identically in both languages, and a wider target re-evaluates the chain at the target width (see the automatic target-context widening above), matching the context Verilog's assignment provides; truncation to the target width commutes with `+`/`-`/`*`, so the two evaluations agree for every input. ```scala val sum = UInt(10) <> VAR -// OK: widened to the target, elaborates to sum := a.resize(10) + b.resize(10) + d"10'1" +// OK: widened to the target, elaborates to sum := a.eby(2) + b.eby(2) + d"10'1" sum := a + b + 1 val cnt = UInt(8) <> VAR cnt := cnt + 1 // OK: same-width target, modular truncation matches diff --git a/lib/src/test/scala/ArithSpec/PrioEncSpec.scala b/lib/src/test/scala/ArithSpec/PrioEncSpec.scala index f208993ad..f95697a74 100644 --- a/lib/src/test/scala/ArithSpec/PrioEncSpec.scala +++ b/lib/src/test/scala/ArithSpec/PrioEncSpec.scala @@ -100,7 +100,7 @@ class PrioEncSpec extends DesignSpec: |end prioEncRecur_3 | |def prioEncRecur_4(value: Bits[31] <> VAL): (Bit, Bits[5]) <> DFRET = - | val lsPrio = prioEncRecur_3(value(14, 0).resize(16)) + | val lsPrio = prioEncRecur_3(value(14, 0).eby(1)) | val msPrio = prioEncRecur_3(value(30, 15)) | val selPrio: Bits[4] <> VAL = | if (msPrio._1) msPrio._2 diff --git a/lib/src/test/scala/ElaborationChecksSpec.scala b/lib/src/test/scala/ElaborationChecksSpec.scala index f05980cb9..27a008273 100644 --- a/lib/src/test/scala/ElaborationChecksSpec.scala +++ b/lib/src/test/scala/ElaborationChecksSpec.scala @@ -413,8 +413,8 @@ class ElaborationChecksSpec extends DesignSpec: |Position: ${currentFilePos}ElaborationChecksSpec.scala:406:9 - 406:15 |Hierarchy: Top |LHS: x - |RHS: y.resize(8) - |Message: Unexpected write access to the immutable value y.resize(8).""".stripMargin + |RHS: y.eby(1) + |Message: Unexpected write access to the immutable value y.eby(1).""".stripMargin ) test("no need for clock location constraint check in internal designs"): object Test: From ca1d98df9c83225d810e1183b4414510d5f6fece Mon Sep 17 00:00:00 2001 From: Oron Port Date: Fri, 7 Aug 2026 23:05:27 +0300 Subject: [PATCH 10/25] ir+core: symbolic width-fit proofs accept provable parametric relations (#116) The TC width-fit check decided only when the width difference folded to a constant, so `SInt(2 * W) <> a * b` (RHS width W) errored as undefined. The decision now falls back to a linear-form non-negativity proof over the validity domain (every width >= 1): `2 * W >= W` is accepted for a free parameter W, and a provably violated relation (`W >= 2 * W`) upgrades the vague undefined error to the definitive larger-than one. A literal target against a free parameter (`16 >= W`) stays undecidable and is still conservatively rejected. The proof core moves into the shared Calc so it runs in the same linearization mode as the diff; equality/similarity queries remain proof-free. Co-Authored-By: Claude Fable 5 --- .../main/scala/dfhdl/compiler/ir/DFRef.scala | 24 ++++++ .../scala/dfhdl/compiler/ir/IntExprCalc.scala | 84 +++++++++++++------ .../scala/StagesSpec/PrintVHDLCodeSpec.scala | 19 +++-- .../StagesSpec/PrintVerilogCodeSpec.scala | 19 +++-- .../src/main/scala/dfhdl/core/DFDecimal.scala | 12 +-- docs/user-guide/type-system/index.md | 3 + lib/src/test/scala/ContextWidenSpec.scala | 46 +++++++++- .../test/scala/ElaborationChecksSpec.scala | 33 ++++++++ 8 files changed, 199 insertions(+), 41 deletions(-) diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFRef.scala b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFRef.scala index e8a709fae..e8ec1de03 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFRef.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFRef.scala @@ -199,6 +199,30 @@ object IntParamRef: ) yield func(diff, 0) end compare + // Width-fit decision `this >= that` (see `IntExprCalc.widthFitCompare`): the constant + // difference rule of `compare` (with the max/min symbolic elimination) plus a + // non-negativity proof over the validity domain, where both sides are widths and hence + // `>= 1` for every valid elaboration. Relations that hold for every valid parameter + // assignment (e.g. `2 * W >= W`) are accepted, and provably violated ones + // (e.g. `W >= 2 * W`) decide `Some(false)`. Width-fit check sites only, never for + // equality/similarity. + def widthFitGE(that: IntParamRef)(using MemberGetSet): Option[Boolean] = + (intParamRef, that) match + // Fast path: both refs are already concrete Ints. + case (l: Int, r: Int) => Some(l >= r) + case _ => + def asDFVal(ref: IntParamRef): Option[DFVal] = ref match + case i: Int => + Some(DFVal.Const( + DFInt32, Some(BigInt(i)), DFRef.OneWay.Empty, Meta.empty, DFTags.empty + )) + case r: DFRef.TypeRef => r.getOption + for + lVal <- asDFVal(intParamRef) + rVal <- asDFVal(that) + decision <- IntExprCalc.widthFitCompare(lVal, rVal) + yield decision + end widthFitGE // The constant difference `this - that` when all symbolic terms cancel (see `compare`); // `None` otherwise. Lets printers render a widening as a relative extension (`.eby(k)`, // `EBY_U`/`EBY_S`, VHDL `eby`) exactly when the width delta folds to a literal. Design diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/ir/IntExprCalc.scala b/compiler/ir/src/main/scala/dfhdl/compiler/ir/IntExprCalc.scala index 2ae24ae2a..a096771ad 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/ir/IntExprCalc.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/ir/IntExprCalc.scala @@ -46,6 +46,32 @@ object IntExprCalc: elimSymbolicMaxMin ).constDiff(a, b) + /** Decides the width-fit acceptance `a >= b` between two width expressions. A constant difference + * (with the max/min symbolic elimination of [[constDiff]]) decides directly; an undecidable + * difference falls back to a non-negativity proof over the validity domain, using the fact that + * both sides are widths and hence `>= 1` for every valid elaboration (`Arg.Width` rejects + * non-positive widths). So `2 * W >= W` is accepted for a free parameter `W`, `W >= 2 * W` is + * definitively rejected, and `16 >= W` stays undecidable (`W` may exceed 16). Width-fit check + * sites only: like the max/min elimination, the proof rules must never back equality/similarity + * queries (`=~`, `isSimilarTo`). + */ + def widthFitCompare(a: DFVal, b: DFVal)(using MemberGetSet): Option[Boolean] = + val calc = Calc(ParamResolve.AppliedExpr, elimSymbolicMaxMin = true) + val la = calc.linear(a) + val lb = calc.linear(b) + val diff = calc.sub(la, lb) + if (diff.terms.isEmpty) Some(diff.offset >= 0) + else + // both sides are widths: `>= 1` on the valid domain + val facts = List(la, lb) + if (calc.proveNonNeg(diff, facts)) Some(true) + else + // the negative direction: `b - a - 1 >= 0` proves `b > a`, deciding `a >= b` as false + val negDiffM1 = Linear(diff.terms.map((c, b) => (-c, b)), -diff.offset - 1) + if (calc.proveNonNeg(negDiffM1, facts)) Some(false) + else None + end widthFitCompare + /** How the calculus treats a [[DFVal.DesignParam]] it reaches. */ private enum ParamResolve derives CanEqual: /** Stays an opaque base, so a decision holds for any parameter assignment (elaboration-time @@ -115,33 +141,11 @@ object IntExprCalc: /** Proves `e >= 0` for every valid parameter assignment. Each fact in `facts` is a linear form * known to be `>= 1` on the valid domain (slice widths: a slice of zero or negative width is - * never a valid elaboration). Two proof rules: a constant `e` decides directly, and a - * single-fact proportional bound: if `e == λ*f + c` with rational `λ >= 0`, then - * `e >= λ*1 + c`, so `λ + c >= 0` proves it. This covers the equal-bin pattern (`k*W`-based - * slices of width `W`) at any distance. + * never a valid elaboration). This covers the equal-bin pattern (`k*W`-based slices of width + * `W`) at any distance. See [[Calc.proveNonNeg]] for the proof rules. */ def proveNonNeg(e: Linear, facts: List[Linear])(using MemberGetSet): Boolean = - if (e.terms.isEmpty) e.offset >= 0 - else - val c = calc - facts.exists { f => - f.terms.nonEmpty && f.terms.length == e.terms.length && { - // pair each e-term with its baseEq f-term and derive λ = p/q from the first pair - val paired = e.terms.map { (ce, be) => - f.terms.collectFirst { case (cf, bf) if c.baseEq(be, bf) => (ce, cf) } - } - paired.forall(_.nonEmpty) && { - val pairs = paired.flatten - val (p0, q0) = pairs.head - // normalize the denominator positive; λ >= 0 then requires p >= 0 - val (p, q) = if (q0 < 0) (-p0, -q0) else (p0, q0) - p >= 0 && - pairs.forall((ce, cf) => ce * q == cf * p) && - // λ + c >= 0 with c = e.offset - λ*f.offset, scaled by q > 0 - p + q * e.offset - p * f.offset >= 0 - } - } - } + calc.proveNonNeg(e, facts) end DataCalc private object ConstInt: @@ -244,6 +248,8 @@ object IntExprCalc: private def sameTerms(l: Linear, r: Linear): Boolean = canonical(l.terms ++ negate(r).terms).isEmpty + def sub(l: Linear, r: Linear): Linear = add(l, negate(r)) + def equivalent(a: DFVal, b: DFVal): Boolean = constDiff(a, b).contains(0) @@ -252,6 +258,34 @@ object IntExprCalc: val lb = linear(b) Option.when(sameTerms(la, lb))(la.offset - lb.offset) + /** Proves `e >= 0` for every valid parameter assignment, where each fact in `facts` is a linear + * form known to be `>= 1` on the valid domain. Two proof rules: a constant `e` decides + * directly, and a single-fact proportional bound: if `e == λ*f + c` with rational `λ >= 0`, + * then `e >= λ*1 + c`, so `λ + c >= 0` proves it. The proof runs in this calc's own + * linearization mode, so `e` and the facts must be produced by the same calc. + */ + def proveNonNeg(e: Linear, facts: List[Linear]): Boolean = + if (e.terms.isEmpty) e.offset >= 0 + else + facts.exists { f => + f.terms.nonEmpty && f.terms.length == e.terms.length && { + // pair each e-term with its baseEq f-term and derive λ = p/q from the first pair + val paired = e.terms.map { (ce, be) => + f.terms.collectFirst { case (cf, bf) if baseEq(be, bf) => (ce, cf) } + } + paired.forall(_.nonEmpty) && { + val pairs = paired.flatten + val (p0, q0) = pairs.head + // normalize the denominator positive; λ >= 0 then requires p >= 0 + val (p, q) = if (q0 < 0) (-p0, -q0) else (p0, q0) + p >= 0 && + pairs.forall((ce, cf) => ce * q == cf * p) && + // λ + c >= 0 with c = e.offset - λ*f.offset, scaled by q > 0 + p + q * e.offset - p * f.offset >= 0 + } + } + } + def linear(v: DFVal): Linear = strip(v) match case ConstInt(i) => Linear(Nil, i) case DFVal.Func(op = FuncOp.+, args = args) => diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala index 33aa59524..4e56ceb02 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala @@ -3491,10 +3491,15 @@ class PrintVHDLCodeSpec extends StageSpec: val usub = UInt(W + 1) <> OUT val acc = SInt(W + 2) <> OUT val uacc = UInt(W + 2) <> OUT - sum <> a + b - usub <> ua - ub - acc <> a + b - uacc <> ua + ub + val prod = SInt(2 * W) <> OUT + val uprod = UInt(2 * W) <> OUT + sum <> a + b + usub <> ua - ub + acc <> a + b + uacc <> ua + ub + prod <> a * b + uprod <> ua * ub + end ParamWiden val top = ParamWiden().getCompiledCodeString assertNoDiff( top, @@ -3515,7 +3520,9 @@ class PrintVHDLCodeSpec extends StageSpec: | sum : out signed((W + 1) - 1 downto 0); | usub : out unsigned((W + 1) - 1 downto 0); | acc : out signed((W + 2) - 1 downto 0); - | uacc : out unsigned((W + 2) - 1 downto 0) + | uacc : out unsigned((W + 2) - 1 downto 0); + | prod : out signed((2 * W) - 1 downto 0); + | uprod : out unsigned((2 * W) - 1 downto 0) |); |end ParamWiden; | @@ -3525,6 +3532,8 @@ class PrintVHDLCodeSpec extends StageSpec: | usub <= csub(ua, ub); | acc <= eby(a, 2) + eby(b, 2); | uacc <= eby(ua, 2) + eby(ub, 2); + | prod <= a * b; + | uprod <= ua * ub; |end ParamWiden_arch; |""".stripMargin ) diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala index da16131bb..4317f809b 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala @@ -3313,10 +3313,15 @@ class PrintVerilogCodeSpec extends StageSpec: val usub = UInt(W + 1) <> OUT val acc = SInt(W + 2) <> OUT val uacc = UInt(W + 2) <> OUT - sum <> a + b - usub <> ua - ub - acc <> a + b - uacc <> ua + ub + val prod = SInt(2 * W) <> OUT + val uprod = UInt(2 * W) <> OUT + sum <> a + b + usub <> ua - ub + acc <> a + b + uacc <> ua + ub + prod <> a * b + uprod <> ua * ub + end ParamWiden val top = ParamWiden().getCompiledCodeString assertNoDiff( top, @@ -3331,13 +3336,17 @@ class PrintVerilogCodeSpec extends StageSpec: | output logic signed [(W + 1) - 1:0] sum, | output logic [(W + 1) - 1:0] usub, | output logic signed [(W + 2) - 1:0] acc, - | output logic [(W + 2) - 1:0] uacc + | output logic [(W + 2) - 1:0] uacc, + | output logic signed [(2 * W) - 1:0] prod, + | output logic [(2 * W) - 1:0] uprod |); | `include "dfhdl_defs.svh" | assign sum = a + b; | assign usub = ua - ub; | assign acc = `EBY_S(a, 2) + `EBY_S(b, 2); | assign uacc = `EBY_U(ua, 2) + `EBY_U(ub, 2); + | assign prod = a * b; + | assign uprod = ua * ub; |endmodule |""".stripMargin ) diff --git a/core/src/main/scala/dfhdl/core/DFDecimal.scala b/core/src/main/scala/dfhdl/core/DFDecimal.scala index 4bc07a1a0..2309290d1 100644 --- a/core/src/main/scala/dfhdl/core/DFDecimal.scala +++ b/core/src/main/scala/dfhdl/core/DFDecimal.scala @@ -1120,11 +1120,13 @@ object DFXInt: val rhsWidthRef = rhs.dfType.asIR.magnitudeWidthParamRef def dfTypeWidthStr = dfTypeWidthRef.refErrorString def rhsWidthStr = rhsWidthRef.refErrorString - // width-fit acceptance rule: LHS >= RHS after symbolic elimination, so a - // mixed max/min drops its symbolic operands (`16 >= WIDTH max 16` decides - // as `16 >= 16`); a residual plain-symbol comparison stays undecidable - // and is conservatively rejected below - dfTypeWidthRef.compare(rhsWidthRef, elimSymbolicMaxMin = true)(_ >= _) match + // width-fit acceptance rule: LHS >= RHS after symbolic elimination (a + // mixed max/min drops its symbolic operands, so `16 >= WIDTH max 16` + // decides as `16 >= 16`), falling back to a non-negativity proof over + // the validity domain (all widths are >= 1), so `2 * W >= W` accepts + // for a free parameter `W`; a residual undecidable comparison (e.g. + // `16 >= W`) is conservatively rejected below + dfTypeWidthRef.widthFitGE(rhsWidthRef) match case Some(false) => throw new IllegalArgumentException( s"""The applied RHS value width ($rhsWidthStr) is larger than the LHS variable width ($dfTypeWidthStr).""" diff --git a/docs/user-guide/type-system/index.md b/docs/user-guide/type-system/index.md index 2d89fcffe..2b5419a3b 100755 --- a/docs/user-guide/type-system/index.md +++ b/docs/user-guide/type-system/index.md @@ -2503,7 +2503,10 @@ u9 := sum // extended by 1: sum.eby(1) // for a, b: SInt(W) the following hold // SInt(W + 1) target: sum := a +^ b // SInt(W + 2) target: acc := a.eby(2) + b.eby(2) +// SInt(2 * W) target: prod := a *^ b ``` + +A parametric width relation is accepted when it holds for **every valid parameter assignment**, using the fact that widths are positive: `SInt(2 * W)` accepts a `W`-wide operation because `2 * W >= W` for any valid `W`. A relation that a valid assignment can violate is definitively rejected (`SInt(W)` never fits a `2 * W`-wide value), and an undecidable one (e.g. a literal target such as `SInt(16)` against a free `W`, which may exceed 16) is conservatively rejected as well; both still require an explicit carry op or `.resize` to state the intent. /// /// admonition | Implicit Scala `Int` and Verilog-semantics mismatch diff --git a/lib/src/test/scala/ContextWidenSpec.scala b/lib/src/test/scala/ContextWidenSpec.scala index 09fe3db12..995e99b07 100644 --- a/lib/src/test/scala/ContextWidenSpec.scala +++ b/lib/src/test/scala/ContextWidenSpec.scala @@ -9,7 +9,12 @@ package dfhdl * * The parametric shapes are the ones a literal-width core spec cannot host (see * `CoreSpec.DFDecimalSpec` for the literal-width matrix): symbolic carry-fit decisions (`W + 1` - * fits `+^`; `W + W` would fit `*^`) and the relative `.eby(k)` printing. + * fits `+^`; `W + W` fits `*^`) and the relative `.eby(k)` printing. + * + * The width-fit acceptance itself is proof-backed (issue dfhdl_by_agents#116): a parametric + * relation such as `2 * W >= W` is accepted because it holds for every valid (positive) width + * assignment, while a relation that a valid assignment can violate (e.g. `16 >= W`) still requires + * an explicit carry op or `.resize` (see `ElaborationChecksSpec` for the rejections). */ class ContextWidenSpec extends DesignSpec: test("parametric target-context widening") { @@ -52,6 +57,45 @@ class ContextWidenSpec extends DesignSpec: ) } + test("parametric mul target-context widening (width-fit proofs)") { + @top(false) class ParamMul(val W: Int <> CONST = 8) extends EDDesign: + val a, b = SInt(W) <> IN + val ua, ub = UInt(W) <> IN + val prod = SInt(2 * W) <> OUT + val prod2 = SInt(W + W) <> OUT + val uprod = UInt(2 * W) <> OUT + val named = SInt(2 * W) <> OUT + // the width-fit check accepts by proof: 2 * W >= W for every valid (positive) W + prod <> a * b + // ref-shape independence: the carry fit decides symbolically, W + W =~ 2 * W + prod2 <> a * b + uprod <> ua * ub + // a NAMED product evaluates at the operand width and resizes to the target, printed + // absolutely (the width delta is symbolic, so no relative `.eby` spelling) + val p = a * b + named <> p + end ParamMul + + ParamMul().assertCodeString( + """|class ParamMul(val W: Int <> CONST = 8) extends EDDesign: + | val a = SInt(W) <> IN + | val b = SInt(W) <> IN + | val ua = UInt(W) <> IN + | val ub = UInt(W) <> IN + | val prod = SInt(2 * W) <> OUT + | val prod2 = SInt(W + W) <> OUT + | val uprod = UInt(2 * W) <> OUT + | val named = SInt(2 * W) <> OUT + | prod <> (a *^ b) + | prod2 <> (a *^ b) + | uprod <> (ua *^ ub) + | val p = a * b + | named <> p.resize(2 * W) + |end ParamMul + |""".stripMargin + ) + } + test("explicit eby") { @top(false) class Eby(val W: Int <> CONST = 8) extends EDDesign: val a = SInt(W) <> IN diff --git a/lib/src/test/scala/ElaborationChecksSpec.scala b/lib/src/test/scala/ElaborationChecksSpec.scala index 27a008273..aa2d4fa6b 100644 --- a/lib/src/test/scala/ElaborationChecksSpec.scala +++ b/lib/src/test/scala/ElaborationChecksSpec.scala @@ -1552,4 +1552,37 @@ class ElaborationChecksSpec extends DesignSpec: // a parametric width that resolves to 32 bits or wider stays suppressed assertWarns(ParW(31)) + test("parametric width-fit proof rejections"): + object Test: + @top(false) class MulTooNarrow(val W: Int <> CONST = 8) extends EDDesign: + val a, b = SInt(W) <> IN + val prod16 = SInt(16) <> OUT + prod16 <> a * b + end MulTooNarrow + @top(false) class ProvablyNarrow(val W: Int <> CONST = 8) extends RTDesign: + val x = SInt(2 * W) <> IN + val narrow = SInt(W) <> OUT + narrow := x + end ProvablyNarrow + import Test.* + // a literal target against a free parameter stays undecidable: a valid W may exceed it + assertElaborationErrors(MulTooNarrow())( + s"""|Elaboration errors found! + |DFiant HDL elaboration error! + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1560:9 - 1560:24 + |Hierarchy: MulTooNarrow + |Operation: `apply` + |Message: The applied RHS value width (W) is undefined compared to the LHS variable width (16).""".stripMargin + ) + // the width-fit proof decides the negative direction definitively: W >= 2 * W is + // violated for every valid W, so the vague "undefined" error upgrades to "larger than" + assertElaborationErrors(ProvablyNarrow())( + s"""|Elaboration errors found! + |DFiant HDL elaboration error! + |Position: ${currentFilePos}ElaborationChecksSpec.scala:1565:9 - 1565:20 + |Hierarchy: ProvablyNarrow + |Operation: `:=` + |Message: The applied RHS value width (2 * W) is larger than the LHS variable width (W).""".stripMargin + ) + end ElaborationChecksSpec From 3028784d8efb2e9ec58749da7c7d62864f40e753 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Fri, 7 Aug 2026 23:13:02 +0300 Subject: [PATCH 11/25] remove redundant case --- compiler/ir/src/main/scala/dfhdl/compiler/ir/DFRef.scala | 1 - 1 file changed, 1 deletion(-) diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFRef.scala b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFRef.scala index e8ec1de03..139b60f51 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFRef.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFRef.scala @@ -281,7 +281,6 @@ object IntParamRef: case _ => None case _ => None case _ => None - case _ => None end extension given ReadWriter[IntParamRef] = readwriter[ujson.Value].bimap( From 7e0549dfbf228507e70f0a025fc213df69e2a98f Mon Sep 17 00:00:00 2001 From: Oron Port Date: Sat, 8 Aug 2026 01:15:04 +0300 Subject: [PATCH 12/25] plugin+core: non-literal widths collapse at the IntParam boundary; DFHDL-aware diagnostics (#455) Fixes https://github.com/DFiantHDL/DFHDL/issues/455 A `reduce(_ ++ _)` over raw port part-selects can never typecheck: `reduce` fixes its type parameter to the element type, which carries the port's own modifier and, until now, the width argument's singleton type (`Bits[LANE.type]`), and no operation result can land back on either. Three independent defects hid behind the one report: - A non-literal width survived as a singleton type. It now collapses where the value ENTERS the width algebra: `IntParam.fromValue`'s precise conversion is restricted to `Int & Singleton` (a `DFConstInt32` parameter falls to the wide conversion, now monomorphic at `IntParam[Int]`), so `Bits(LANE)` is `Bits[Int]` while `Bits(8)` stays `Bits[8]`, and the element ascription `Seq[Bits[Int] <> VAL]` makes the reduce work by plain subsumption, with no conversions and no adapters in the elaborated IR. An abstract `Int & Singleton` type parameter (generic code over literal widths, e.g. `Matrix`) still gets the precise conversion, deferring precision rather than dropping it. The explicitly written singleton type (`Bits[LANE.type]`) remains available through its `ValueOf` given. - The plugin's `CustomReporter` re-rendered the same inline-expansion error up to three times, once at a corrupt position: a macro-synthesized tree's innermost frame pairs the user's source file with the span of the macro's own splice, and dropping the outer position chain exposed it while also bypassing the compiler's position-keyed dedup. The position is now normalized to the first inline frame that belongs to the compiled unit, and the normalized (position, message) pair is deduplicated in `isHidden` so the error count matches what is rendered. - The type printers hid the modifier: a port-typed requirement printed as `<> VAL`, rendering the remaining mismatch identically on both sides. Both twins (the plugin's `modifierText` and core's `ShowType`) now name a port by its direction, and a type mismatch whose required side is a DFHDL value is re-issued without the compiler's postscript (the transparent-inline note and the import suggestions never apply to a DFHDL mismatch). The repro now reports once, at the user's expression: `Found: Bits[Int] <> VAL / Required: Bits[Int] <> IN`. The workaround reported in the issue (ascribing the singleton element type) compiled but failed elaboration with a width error; the collapsed ascription and `.bits` are the correct spellings. Regression tests pin the collapse and the working reduce in `SameWidthArithSpec` and the printed types in `TypePrinterSpec`; the reporter behavior is manually verified, since `assertPluginError` renders without going through `CustomReporter`. Co-Authored-By: Claude Fable 5 --- .claude/commands/bugfix.md | 39 ++++++++++ core/src/main/scala/dfhdl/core/IntParam.scala | 14 +++- core/src/main/scala/dfhdl/core/ShowType.scala | 15 +++- .../scala/CoreSpec/SameWidthArithSpec.scala | 48 ++++++++++-- .../test/scala/CoreSpec/TypePrinterSpec.scala | 23 ++++-- .../main/scala/plugin/DFHDLTypePrinter.scala | 12 ++- .../src/main/scala/plugin/PreTyperPhase.scala | 75 +++++++++++++++++-- 7 files changed, 202 insertions(+), 24 deletions(-) diff --git a/.claude/commands/bugfix.md b/.claude/commands/bugfix.md index 21ed7f696..62aa28b9a 100644 --- a/.claude/commands/bugfix.md +++ b/.claude/commands/bugfix.md @@ -341,6 +341,45 @@ Two mechanism notes from that fix: above does not always cost you the diagnostic; verify per case with `assertCompileError` plus one manual compile for the position. +### A rule about a value's type belongs where the value ENTERS the algebra + +Issue #455's collapse rule ("a non-literal width is `Int` at the type level") was first +implemented per operation, rewriting each return type to a guarded fold, and that shape fails +structurally: every *internal* call site that constructs with an exact type parameter +(`DFBits(updatedWidth)` inside `resizeBits`, `repeat`, `.bits`) is left stuck on the fold and +needs an exact-typed `raw` twin. Implementing the same rule at the single point where a value +*converts into* the algebra's carrier (`IntParam.fromValue`) needed three lines and no signature +changes: only user-facing entries collapse, and the typed internal plumbing never sees the rule. +Mechanism notes from getting the conversion family right: + +- **A conversion whose RESULT type contains a match type is dead on arrival, in both + directions.** Expected-type-driven eligibility cannot invert the match type (`IntParam[8]` + wanted, `IntParam[Collapse[?T]]` offered: the candidate is rejected before the argument is + tried), and an arg-driven term singleton (`cellDim.type` of an `Int` method parameter) leaves + it STUCK in the result (`IsConst` on a TermRef does not reduce, not even to `false`). +- **Bound-narrowing beats evidence-gating.** Restricting the precise conversion's bound + (`IntP & Singleton` to `Int & Singleton`) excludes exactly the species that must collapse (a + `DFConstInt32` parameter's singleton). An `=:=`-to-match-type evidence gate also excludes + ABSTRACT type parameters (`CN <: Int & Singleton` in generic library code, `Matrix`), which + must *defer* precision, not drop it: the evidence is unprovable for both, and only the + reflection level could tell them apart. Such generic code lives in `lib`, so core+stages + green is NOT enough blast radius for a conversion-family change. + +### A custom reporter that rewrites positions must re-own rendering-position sanity AND dedup + +`CustomReporter` drops a diagnostic's outer position chain to suppress inline-stack printing. +That is only sound when the innermost frame is trustworthy, and a diagnostic raised on a +macro-synthesized tree is not: its innermost frame pairs the CURRENT unit's source file with the +span of the `${...}` splice in the macro's own source (`Exact.scala`), so the rendered position +clamps to the unit's last line with an offset-sized column (`Playground.scala:13:12843`). The raw +compiler never shows this frame because it renders *and dedups* through the outer chain +(`UniqueMessagePositions`), which the re-reporting also bypassed (it forwarded straight to +`orig.doReport`), so one inline-expansion error rendered up to three times. The fix is paired, +and each half needs the other: normalize the position (walk innermost to outermost, keep the +first frame whose source IS the compiled unit and whose span fits inside it), and dedup the +normalized (position, message) in `isHidden`, where a swallowed duplicate is also never counted, +keeping the "N errors found" summary consistent with what is rendered. + ### Probing type-level behaviour Two traps, each of which cost several cycles here: diff --git a/core/src/main/scala/dfhdl/core/IntParam.scala b/core/src/main/scala/dfhdl/core/IntParam.scala index 1e7b60641..588e729d9 100644 --- a/core/src/main/scala/dfhdl/core/IntParam.scala +++ b/core/src/main/scala/dfhdl/core/IntParam.scala @@ -149,6 +149,7 @@ object IntP: /** `BI - SW + 1`, the low index of a descending part-select anchored at `BI`. */ type PartSelectLow[BI <: IntP, SW <: IntP] = RangeWidth[BI, SW] + end IntP into opaque type IntParam[V <: IntP] = Int | DFConstInt32 @@ -161,14 +162,21 @@ object IntParam extends IntParamLP: given [T <: IntP]: CanEqual[IntParam[T], Int] = CanEqual.derived given [T <: IntP]: CanEqual[Int, IntParam[T]] = CanEqual.derived - inline implicit def fromValue[T <: IntP & Singleton](inline value: T): IntParam[T] = + // A `DFConstInt32` value (a parameter constant) enters the `IntParam` algebra COLLAPSED: only + // `Int`-typed singletons take the precise conversion, so `Bits(LANE)` is `Bits[Int]` rather + // than `Bits[LANE.type]`, a singleton type no operation result can land back on (issue #455). + // The wide conversion below is deliberately monomorphic, which also lets any value be accepted + // where a collapsed `IntParam[Int]` is expected. Note the precise conversion still serves an + // ABSTRACT `Int & Singleton` type parameter (generic code over literal widths, e.g. + // `Matrix[CN <: Int & Singleton]`), where precision must be deferred, not dropped. + inline implicit def fromValue[T <: Int & Singleton](inline value: T): IntParam[T] = value.asInstanceOf[IntParam[T]] @targetName("fromValueInlined") inline implicit def fromValue[T <: Int](inline value: Inlined[T]): IntParam[T] = value.asInstanceOf[IntParam[T]] @targetName("fromValueWide") - inline implicit def fromValue[Wide <: IntP](inline value: Wide): IntParam[Wide] = - value.asInstanceOf[IntParam[Wide]] + inline implicit def fromValue(inline value: IntP): IntParam[Int] = + value.asInstanceOf[IntParam[Int]] inline def apply[T <: IntP](inline value: T): IntParam[T] = value match case sig: IntP.Sig => sig.value.asInstanceOf[IntParam[T]] case _ => value.asInstanceOf[IntParam[T]] diff --git a/core/src/main/scala/dfhdl/core/ShowType.scala b/core/src/main/scala/dfhdl/core/ShowType.scala index 9494d9fda..7951cb6dc 100644 --- a/core/src/main/scala/dfhdl/core/ShowType.scala +++ b/core/src/main/scala/dfhdl/core/ShowType.scala @@ -52,12 +52,21 @@ extension [T](using quotes: Quotes)(tpe: quotes.reflect.TypeRepr) end match end showDFType + // keep in sync with the plugin's `DFHDLTypePrinter.modifierText`, its compile-time twin: + // a constant value is a `CONST`, a port is its direction, an assignable non-port a `VAR`, + // and anything else a plain readable `VAL` def showModifier: String = import quotes.reflect.* tpe.asTypeOf[ModifierAny] match - case '[Modifier.CONST] => "CONST" - case '[Modifier.Mutable] => "VAR" - case _ => "VAL" + case '[Modifier.CONST] => "CONST" + case '[Modifier[a, c, i, p]] => + val access = TypeRepr.of[a] + if (access <:< TypeRepr.of[Modifier.PortINOUT]) "INOUT" + else if (access <:< TypeRepr.of[Modifier.PortOUT]) "OUT" + else if (access <:< TypeRepr.of[Modifier.PortIN]) "IN" + else if (access <:< TypeRepr.of[Modifier.Assignable]) "VAR" + else "VAL" + case _ => "VAL" def showDFVal: String = import quotes.reflect.* diff --git a/core/src/test/scala/CoreSpec/SameWidthArithSpec.scala b/core/src/test/scala/CoreSpec/SameWidthArithSpec.scala index fa5787106..4bc2c79e1 100644 --- a/core/src/test/scala/CoreSpec/SameWidthArithSpec.scala +++ b/core/src/test/scala/CoreSpec/SameWidthArithSpec.scala @@ -3,11 +3,11 @@ import dfhdl.* import dfhdl.compiler.printing.DefaultPrinter // A commutative arithmetic operation is as wide as its wider operand, so on two operands of the -// same parametric width its result width is the symbolic `Max[W, W]`, a different type from `W` -// even though it stands for it (the fold happens only for literal widths, where `Max` bottoms out -// in `compiletime.ops.int.Max`). `DFVal.MaxOfSameWidth` is the conversion that closes the gap, and -// `reduce` is the shape that demands it: it fixes its type parameter to the element type before -// the operator is typed, so the operator has to land back on exactly that type. +// same parametric width its result width would be the symbolic `Max[W, W]`, a different type from +// `W` even though it stands for it. The width algebra therefore collapses every non-literal width +// to `Int` (the fold to a precise width happens only for literals), so the result of an operation +// over collapsed operands lands back on their own type. `reduce` is the shape that demands it: it +// fixes its type parameter to the element type before the operator is typed. // See https://github.com/DFiantHDL/DFHDL/issues/431 class SameWidthArithSpec extends NoDFCSpec: // the freshly elaborated design, before any of the stages that rename and reorder members @@ -94,4 +94,42 @@ class SameWidthArithSpec extends NoDFCSpec: |end Top""".stripMargin ) } + + // The collapse happens once, where a width VALUE enters the algebra (`IntParam.fromValue`): + // a non-literal width enters as `IntParam[Int]`, so `Bits(LANE)` is `Bits[Int]` rather than + // `Bits[LANE.type]`, while a literal keeps its precise type. The part-select elements below + // are therefore `Bits[Int]`-typed and the `Seq[Bits[Int] <> VAL]` ascription conforms by + // plain subsumption, which is what lets a width-growing operator (`++`) reduce: its collapsed + // result is the elements' own type. Without the ascription the elements keep the port's + // modifier, which no operation result can land back on, and that is the correct error. + // See https://github.com/DFiantHDL/DFHDL/issues/455 + test("reduce-concat over parametric port slices") { + class Top extends EDDesign: + val LANE: Int <> CONST = 3 + val LANES: Int <> CONST = 3 + // compile-level pins of the boundary collapse: a parametric width constructs a + // `Bits[Int]`/`UInt[Int]`/`SInt[Int]`, a literal width stays precise + val cb: Bits[Int] = Bits(LANE) + val cu: UInt[Int] = UInt(LANE) + val cs: SInt[Int] = SInt(LANE) + val lb: Bits[8] = Bits(8) + val data = Bits(LANE * LANES) <> IN + val out = Bits(LANE * LANES) <> OUT + val list: Seq[Bits[Int] <> VAL] = + for (i <- 0 until LANES) yield data.lsbitsAt(i * LANE, LANE) + out <> list.reduce(_ ++ _) + assertNoDiff( + codeString(Top()), + """|class Top extends EDDesign: + | val LANE: Int <> CONST = 3 + | val LANES: Int <> CONST = 3 + | val data = Bits(LANE * LANES) <> IN + | val out = Bits(LANE * LANES) <> OUT + | val list = data(LANE - 1, 0) + | val list = data((LANE + LANE) - 1, LANE) + | val list = data(((2 * LANE) + LANE) - 1, 2 * LANE) + | out <> (list, list, list).toBits + |end Top""".stripMargin + ) + } end SameWidthArithSpec diff --git a/core/src/test/scala/CoreSpec/TypePrinterSpec.scala b/core/src/test/scala/CoreSpec/TypePrinterSpec.scala index c7c53758c..03ce9c9aa 100644 --- a/core/src/test/scala/CoreSpec/TypePrinterSpec.scala +++ b/core/src/test/scala/CoreSpec/TypePrinterSpec.scala @@ -43,14 +43,24 @@ class TypePrinterSpec extends DFSpec: val e: Int = x """ ) - // a width parameter is named after the parameter it refers to - assertPluginError(foundRequiredInt("Bits[WIDTH] <> VAR"))( + // a non-literal width collapses to `Int` where the value enters the width algebra + // (`IntParam.fromValue`), so a parameter-constructed width prints as `Int` + assertPluginError(foundRequiredInt("Bits[Int] <> VAR"))( """ class Foo(val WIDTH: Int <> CONST = 8) extends DFDesign: val x = Bits(WIDTH) <> VAR val e: Int = x """ ) + // an explicitly written singleton width is kept, and is named after the parameter it + // refers to + assertPluginError(foundRequiredInt("Bits[WIDTH] <> VAR"))( + """ + class Foo(val WIDTH: Int <> CONST = 8) extends DFDesign: + val x = Bits[WIDTH.type] <> VAR + val e: Int = x + """ + ) // a computed width names no value, so it prints as an unbounded `Int` assertPluginError(foundRequiredInt("Bits[Int] <> VAR"))( """ @@ -182,16 +192,17 @@ class TypePrinterSpec extends DFSpec: ) test("modifiers"): - // ports are named by what they grant rather than by their direction, exactly as `ShowType` - // names them: an input is a readable value, an output an assignable variable - assertPluginError(foundRequiredInt("Bits[8] <> VAL"))( + // a port is named by its direction, exactly as `ShowType` names it. Naming it by what it + // grants instead (an input as a readable `VAL`) rendered a reduce-over-port-slices + // mismatch with `Bits[Int] <> VAL` on BOTH sides of the error (issue #455). + assertPluginError(foundRequiredInt("Bits[8] <> IN"))( """ class Foo extends DFDesign: val x = Bits(8) <> IN val e: Int = x """ ) - assertPluginError(foundRequiredInt("Bits[8] <> VAR"))( + assertPluginError(foundRequiredInt("Bits[8] <> OUT"))( """ class Foo extends DFDesign: val x = Bits(8) <> OUT diff --git a/plugin/src/main/scala/plugin/DFHDLTypePrinter.scala b/plugin/src/main/scala/plugin/DFHDLTypePrinter.scala index b7c9dc485..8ef727369 100644 --- a/plugin/src/main/scala/plugin/DFHDLTypePrinter.scala +++ b/plugin/src/main/scala/plugin/DFHDLTypePrinter.scala @@ -41,6 +41,9 @@ final class DFHDLSymbols(using Context): val dfVal: Symbol = getClassIfDefined("dfhdl.core.DFVal") val modifier: Symbol = getClassIfDefined("dfhdl.core.Modifier") val assignable: Symbol = getClassIfDefined("dfhdl.core.Modifier.Assignable") + val portIN: Symbol = getClassIfDefined("dfhdl.core.Modifier.PortIN") + val portOUT: Symbol = getClassIfDefined("dfhdl.core.Modifier.PortOUT") + val portINOUT: Symbol = getClassIfDefined("dfhdl.core.Modifier.PortINOUT") val isConst: Symbol = getClassIfDefined("dfhdl.core.ISCONST") val timeNumber: Symbol = irClass("TimeNumber") val freqNumber: Symbol = irClass("FreqNumber") @@ -232,9 +235,14 @@ class DFHDLTypePrinter(_ctx: Context, syms: DFHDLSymbols) extends RefinedPrinter private def modifierText(tp: Type)(using Context): Text = tp.dealias match case AppliedType(tycon, List(access, _, _, param)) if tycon.typeSymbol == syms.modifier => - // the same three names `ShowType` reports, and for the same reasons: a constant value - // is a `CONST`, an assignable one a `VAR`, and anything else a plain readable `VAL` + // the same names `ShowType` reports, and for the same reasons: a constant value is a + // `CONST`, a port is its direction (so a mismatch against a port-typed value is not + // rendered identically to the plain readable value that failed to conform to it), an + // assignable non-port a `VAR`, and anything else a plain readable `VAL` if (isConstParam(param)) "CONST" + else if (access.derivesFrom(syms.portINOUT)) "INOUT" + else if (access.derivesFrom(syms.portOUT)) "OUT" + else if (access.derivesFrom(syms.portIN)) "IN" else if (access.derivesFrom(syms.assignable)) "VAR" else "VAL" case _ => "VAL" diff --git a/plugin/src/main/scala/plugin/PreTyperPhase.scala b/plugin/src/main/scala/plugin/PreTyperPhase.scala index 86b3a243a..7ae20479f 100644 --- a/plugin/src/main/scala/plugin/PreTyperPhase.scala +++ b/plugin/src/main/scala/plugin/PreTyperPhase.scala @@ -31,15 +31,80 @@ import reporting.* * so never consults the one this phase installs, whereas `toString` renders under the context the * message captured, where that printer is live. Re-reporting also drops the diagnostic's outer * position, which suppresses inline-stack error printing. + * + * Dropping the outer chain is only sound when the innermost position is trustworthy, and a + * diagnostic raised on a macro-synthesized tree is not: its innermost frame carries the span of + * the quote inside the macro's own source paired with the CURRENT unit's source file, so the + * rendered position lands past the unit's end (`Playground.scala:13:12843`-style). The position is + * therefore normalized first: walk the inline chain innermost to outermost and keep the first + * frame that belongs to the compiled unit (the outermost frame's source, by construction the call + * site being typed) with a span that fits inside it. For every well-formed diagnostic the + * innermost frame qualifies, so this changes nothing; only corrupt or library-positioned frames + * are skipped. + * + * Re-reporting also bypasses the original reporter's `UniqueMessagePositions` dedup (that dedup + * keys on the positions this reporter rewrites), so the same inline-expansion error re-raised at + * several positions would render several times. The normalized (position, message) pair is + * deduplicated here instead. + * + * Finally, a type mismatch whose REQUIRED side is a DFHDL value is read by a user thinking in + * DFHDL types, where the compiler's own trailing guidance (`msgPostscript`) is noise or worse: the + * transparent-inline note explains the Scala mechanics behind the DFHDL operators, and the import + * suggestions (`InitValue.fromValue` and friends) never fix a DFHDL mismatch. Such a diagnostic is + * re-issued with an EMPTY postscript: a fresh message rather than `mapMsg`, since `mapMsg` + * deliberately carries the original postscript, and the postscript itself is protected so it + * cannot be filtered piecewise. The `-explain` explanation is kept. */ class CustomReporter( - val orig: Reporter + val orig: Reporter, + symbols: DFHDLSymbols.Cache ) extends Reporter: + private val reported = collection.mutable.HashSet.empty[(String, Int, Int, Int, String)] override def flush()(using ctx: Context): Unit = orig.flush() + private def updatedMsg(base: Message)(using Context): Message = + // `toString` rather than `message`: it renders the message proper (without the postscript) + // under the context the message captured, where the DFHDL type printer is live + val rendered = base.toString + val dropPostscript = base match + case tm: TypeMismatchMsg => + val syms = symbols() + syms.available && tm.expected.derivesFrom(syms.dfVal) + case _ => false + if (dropPostscript) + new Message(base.errorId): + val kind = base.kind + def msg(using Context) = rendered + override def msgPostscript(using Context) = "" + def explain(using Context) = base.explanation + override def canExplain = base.canExplain + else base.mapMsg(_ => rendered) + end updatedMsg + private def normalizedPos(pos: util.SourcePosition): util.SourcePosition = + val frames = Iterator + .iterate(pos)(_.outer) + .takeWhile(p => p != null && p.exists) + .toList + if (frames.isEmpty) pos + else + val unitSource = frames.last.source + def sane(p: util.SourcePosition): Boolean = + p.span.exists && p.span.end <= p.source.content().length + frames.find(p => (p.source eq unitSource) && sane(p)).getOrElse(frames.last) + end normalizedPos + private def dedupKey(dia: Diagnostic)(using Context): (String, Int, Int, Int, String) = + val diaPos = normalizedPos(dia.pos) + val (spanStart, spanEnd) = + if (diaPos.span.exists) (diaPos.span.start, diaPos.span.end) else (-1, -1) + (diaPos.source.file.path, spanStart, spanEnd, dia.level, dia.msg.toString) + // the dedup lives in `isHidden` rather than `doReport` so a swallowed duplicate is also + // never counted, keeping the "N errors found" summary consistent with what is rendered + // (the same reason the compiler's own dedup, `UniqueMessagePositions`, works at this hook) + override def isHidden(dia: Diagnostic)(using Context): Boolean = + super.isHidden(dia) || + dia.level >= interfaces.Diagnostic.WARNING && !reported.add(dedupKey(dia)) override def doReport(dia: Diagnostic)(using ctx: Context): Unit = - val updatedMsg = dia.msg.toString - val diaPos = dia.pos.copy(outer = null) // disable inline stack error printing - val updatedDia = Diagnostic(dia.msg.mapMsg(x => updatedMsg), diaPos, dia.level) + val diaPos = normalizedPos(dia.pos).copy(outer = null) // disable inline stack error printing + val updatedDia = Diagnostic(updatedMsg(dia.msg), diaPos, dia.level) orig.doReport(updatedDia) end doReport end CustomReporter @@ -399,7 +464,7 @@ class PreTyperPhase(setting: Setting) extends CommonPhase: ctx.setPrinterFn(printerCtx => DFHDLTypePrinter(printerCtx, printerSymbols()(using printerCtx)) ) - val typerState = ctx.typerState.setReporter(new CustomReporter(ctx.reporter)) + val typerState = ctx.typerState.setReporter(new CustomReporter(ctx.reporter, printerSymbols)) ctx.setTyperState(typerState) end initContext From e102b66fc096a17ecfb58e0627007918c4c9c905 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Sat, 8 Aug 2026 01:56:06 +0300 Subject: [PATCH 13/25] plugin+core: `reduce`-over-slices guide rail, testable through the real diagnostic rewriting Follow-up to the issue #455 diagnostics: the one error the un-ascribed reduce-over-port-slices shape still (correctly) produces now carries a dedicated remedy instead of a bare mismatch. The rewriter recognizes the shape (a plain computed value against a declaration-modified requirement of the SAME DFHDL type), identifies the enclosing call by name (the typed tree does not exist yet at reporting time, but the unit's parse tree does, and the innermost `Apply` with an argument containing the error span is the call whose type parameter was committed), and spells the one-token fix with the actual type: `.reduce[Bits[Int] <> VAL](...)`. An unidentified caller keeps a conditional wording of the same note. The rewriting itself (position normalization, dedup identity, postscript drop, guide rails) moves out of `CustomReporter` into a shared `DiagnosticRewriter` that `PluginTestPhase` now applies to its nested snippet compilations, so `assertPluginError` specs assert on exactly what a user reads; previously the reporter's behavior was manually verifiable only. The normalization's unit-source anchor becomes an explicit parameter: a nested diagnostic's position chain extends past the snippet's virtual source into the enclosing real unit (the marker call site), so the outermost frame does not identify the compiled unit there. The new `assertSinglePluginError` asserts a snippet produces EXACTLY one error, which pins the dedup on top of the message text: the typer re-raises this mismatch through the inline expansion of `++`, once at a corrupt macro-splice position, and all re-raises must collapse into one rendered diagnostic. `TypePrinterSpec` pins the full guide-railed message that way. Co-Authored-By: Claude Fable 5 --- .../test/scala/CoreSpec/TypePrinterSpec.scala | 27 ++ core/src/test/scala/NoDFCSpec.scala | 14 + devdocs/plugin-error-testing.md | 29 +- .../main/scala/plugin/PluginTestPhase.scala | 34 ++- .../src/main/scala/plugin/PreTyperPhase.scala | 253 +++++++++++++----- 5 files changed, 274 insertions(+), 83 deletions(-) diff --git a/core/src/test/scala/CoreSpec/TypePrinterSpec.scala b/core/src/test/scala/CoreSpec/TypePrinterSpec.scala index 03ce9c9aa..40793d1f0 100644 --- a/core/src/test/scala/CoreSpec/TypePrinterSpec.scala +++ b/core/src/test/scala/CoreSpec/TypePrinterSpec.scala @@ -265,4 +265,31 @@ class TypePrinterSpec extends DFSpec: val e: Int = x """ ) + + test("reduce over declaration slices guide rail"): + // The issue #455 shape: `reduce` commits its type parameter to the port-modified slice + // element type, which no operation result can conform to. The rewriter identifies the + // enclosing fold-family call from the parse tree and spells the pinned-type remedy with + // the actual element type. The single-error assertion also pins the diagnostic dedup: + // the typer re-raises this mismatch through the inline expansion of `++`, once with a + // corrupt macro-splice position, and all re-raises must collapse into this one message. + assertSinglePluginError( + """|Found: Bits[Int] <> VAL + |Required: Bits[Int] <> IN + | + |Note: `reduce` inferred its type parameter from the declaration (port or + |variable) slice elements, so the operator must land back on the declaration + |type, and an operation result is a plain value that never can. Set the type + |parameter to the plain value type explicitly: + | + | .reduce[Bits[Int] <> VAL](...)""".stripMargin + )( + """ + class Foo(val LANE: Int <> CONST = 3, val LANES: Int <> CONST = 3) extends EDDesign: + val data = Bits(LANE * LANES) <> IN + val out = Bits(LANE * LANES) <> OUT + val list = for (i <- 0 until LANES) yield data.lsbitsAt(i * LANE, LANE) + out <> list.reduce(_ ++ _) + """ + ) end TypePrinterSpec diff --git a/core/src/test/scala/NoDFCSpec.scala b/core/src/test/scala/NoDFCSpec.scala index 36d488806..6211922cf 100644 --- a/core/src/test/scala/NoDFCSpec.scala +++ b/core/src/test/scala/NoDFCSpec.scala @@ -50,6 +50,20 @@ abstract class NoDFCSpec extends FunSuite, NoTopAnnotIsRequired: ) end assertPluginError + // Like `assertPluginError`, but asserts the snippet produces EXACTLY one error with the given + // user-facing text: on top of the message itself, this pins the diagnostic dedup (an + // inline-expansion error re-raised at several positions must render once). + transparent inline def assertSinglePluginError(expectedErr: String)( + inline code: String + ): Unit = + val errs = internals.PluginErrCheck.pluginCheckErrors(code) + val actual = errs match + case single :: Nil => single + case Nil => noErrMsg + case many => many.mkString("\n===== MULTIPLE ERRORS =====\n") + assertNoDiff(actual, expectedErr) + end assertSinglePluginError + inline def assertRuntimeError(expectedErr: String)(runTimeCode: => Unit): Unit = val err = try diff --git a/devdocs/plugin-error-testing.md b/devdocs/plugin-error-testing.md index 07a27f45b..481c7b90b 100644 --- a/devdocs/plugin-error-testing.md +++ b/devdocs/plugin-error-testing.md @@ -143,18 +143,23 @@ mapping. Metals/BSP export `Test / scalacOptions`, so the gating applies in the - Single-phase `MegaPhase` wrapping means `transformFollowing`/`transformAllDeep` inside a nested phase sees only that phase, whereas the real pipeline may fuse consecutive minis. Fine for diagnostics; a known fidelity gap for tree shapes. -- **Messages are collected through `Message.toString`, not `Diagnostic.message`.** The fresh - typer state bypasses the `CustomReporter` that `PreTyperPhase.initContext` installs for the - real run, so the collection step has to reproduce what that reporter does. It matters: - `message` renders under `Message.inMessageContext`, which pins the printer to the compiler's - own `Message.Printer` and therefore never sees the DFHDL type printer, whereas `toString` - renders under the context the message captured, where that printer is live. Going through - `toString` is what lets a snippet assert the text a user actually reads (`Bits[8] <> VAR` - rather than `dfhdl.core.DFVal[...]`), and it is the only way to test the printer at all: - `typeCheckErrors` packs its diagnostics with `message` and cannot be made to do otherwise. - `toString` also leaves out the `msgPostscript` addenda (import suggestions and the like), - which keeps expected strings to the diagnostic itself; ANSI colour escapes are stripped the - same way `Diagnostic.message` strips them. +- **Diagnostics go through the run-wide rewriting, then render through `Message.toString`, not + `Diagnostic.message`.** The fresh typer state bypasses the `CustomReporter` that + `PreTyperPhase.initContext` installs for the real run, so the collection step applies the + SAME rewriting through the shared `DiagnosticRewriter`: position normalization, dedup (an + inline-expansion error re-raised at several positions must render once; asserted with + `assertSinglePluginError`), the DFHDL-mismatch postscript drop, and the guide rails (which + name the enclosing call from the snippet's parse tree). The rewriter's `unitSource` must be + the snippet's virtual source: a nested diagnostic's position chain extends past it into the + real unit (the marker call site), so the outermost frame does not identify the unit. The + rendering itself matters too: `message` renders under `Message.inMessageContext`, which pins + the printer to the compiler's own `Message.Printer` and therefore never sees the DFHDL type + printer, whereas `toString` renders under the context the message captured, where that + printer is live. Going through `toString` is what lets a snippet assert the text a user + actually reads (`Bits[8] <> VAR` rather than `dfhdl.core.DFVal[...]`), and it is the only + way to test the printer at all: `typeCheckErrors` packs its diagnostics with `message` and + cannot be made to do otherwise. ANSI colour escapes are stripped the same way + `Diagnostic.message` strips them. ## Beyond plugin errors: the DFHDL type printer diff --git a/plugin/src/main/scala/plugin/PluginTestPhase.scala b/plugin/src/main/scala/plugin/PluginTestPhase.scala index 18592029c..f995608ea 100644 --- a/plugin/src/main/scala/plugin/PluginTestPhase.scala +++ b/plugin/src/main/scala/plugin/PluginTestPhase.scala @@ -34,6 +34,10 @@ class PluginTestPhase(setting: Setting) extends CommonPhase: private var markerClass: Symbol = NoSymbol private val preTyperRewriter = new PreTyperPhase(setting) + // the same rewriting the real run's CustomReporter applies, so specs assert on exactly what a + // user reads (its symbol cache is per run, hence an instance here rather than a shared global) + private val testerSymbols = DFHDLSymbols.Cache() + private val diagRewriter = DiagnosticRewriter(testerSymbols) override def prepareForUnit(tree: Tree)(using Context): Context = super.prepareForUnit(tree) @@ -156,9 +160,13 @@ class PluginTestPhase(setting: Setting) extends CommonPhase: inContext(newContext) { def noErrors = ctx.reporter.allErrors.isEmpty + // the snippet's parse tree, kept for the diagnostic rewriting below (the guide rails + // name the enclosing call from it); empty when parsing itself failed + var snippetUntpd: untpd.Tree = untpd.EmptyTree val parsed = new Parser(source2).block() if (noErrors) val untpdTree = preTyperRewriter.rewriteParsed(parsed) + snippetUntpd = untpdTree val tpdTree = ctx.typer.typed(untpdTree) if (noErrors) // Every run below is constructed INSIDE this nested context on purpose: the @@ -214,16 +222,22 @@ class PluginTestPhase(setting: Setting) extends CommonPhase: if (noErrors) transformTree = run(transformTree) end if end if - // `Message.toString` rather than `Diagnostic.message`, so a snippet's diagnostics - // read exactly as the real run's do. `message` renders under `inMessageContext`, - // which pins the printer to the compiler's own `Message.Printer` and therefore never - // sees the DFHDL type printer; `toString` renders under the context the message - // captured, which is where `PreTyperPhase.initContext` installed that printer. It is - // the same path the real run takes, since `CustomReporter` re-renders every reported - // diagnostic through `toString` (see DFHDLTypePrinter). `toString` also leaves out the - // `msgPostscript` addenda (import suggestions and the like), which are noise here. - // The colour escapes `Diagnostic.message` would have dropped are stripped the same way. - ctx.reporter.allErrors.map(_.msg.toString.replaceAll("\\e\\[[;\\d]*m", "")) + // Every diagnostic goes through the SAME rewriting the real run's CustomReporter + // applies (position normalization, dedup, postscript drop, guide rails), then renders + // through `Message.toString` rather than `Diagnostic.message`: `message` renders under + // `inMessageContext`, which pins the printer to the compiler's own `Message.Printer` + // and therefore never sees the DFHDL type printer, whereas `toString` renders under + // the context the message captured, which is where `PreTyperPhase.initContext` + // installed that printer. The colour escapes `Diagnostic.message` would have dropped + // are stripped the same way. + val seen = collection.mutable.HashSet.empty[(String, Int, Int, Int, String)] + ctx.reporter.allErrors.collect { + case dia if seen.add(diagRewriter.dedupKey(dia, source2)) => + val userPos = diagRewriter.normalizedPos(dia.pos, source2) + diagRewriter + .updatedMsg(dia.msg, userPos, snippetUntpd) + .toString.replaceAll("\\e\\[[;\\d]*m", "") + } } } end snippetErrors diff --git a/plugin/src/main/scala/plugin/PreTyperPhase.scala b/plugin/src/main/scala/plugin/PreTyperPhase.scala index 7ae20479f..31e0e9a21 100644 --- a/plugin/src/main/scala/plugin/PreTyperPhase.scala +++ b/plugin/src/main/scala/plugin/PreTyperPhase.scala @@ -22,46 +22,59 @@ import collection.mutable import annotation.tailrec import reporting.* -/** Re-renders every reported diagnostic before passing it on, which is what puts DFHDL's own type - * printer in front of the user (see [[DFHDLTypePrinter]]). - * - * The rendering swap is `Message.toString` rather than `Diagnostic.message`. They produce the same - * text out of the same message, but by different routes: `message` renders under - * `Message.inMessageContext`, which pins the printer to the compiler's own `Message.Printer` and - * so never consults the one this phase installs, whereas `toString` renders under the context the - * message captured, where that printer is live. Re-reporting also drops the diagnostic's outer - * position, which suppresses inline-stack error printing. - * - * Dropping the outer chain is only sound when the innermost position is trustworthy, and a - * diagnostic raised on a macro-synthesized tree is not: its innermost frame carries the span of - * the quote inside the macro's own source paired with the CURRENT unit's source file, so the - * rendered position lands past the unit's end (`Playground.scala:13:12843`-style). The position is - * therefore normalized first: walk the inline chain innermost to outermost and keep the first - * frame that belongs to the compiled unit (the outermost frame's source, by construction the call - * site being typed) with a span that fits inside it. For every well-formed diagnostic the - * innermost frame qualifies, so this changes nothing; only corrupt or library-positioned frames - * are skipped. - * - * Re-reporting also bypasses the original reporter's `UniqueMessagePositions` dedup (that dedup - * keys on the positions this reporter rewrites), so the same inline-expansion error re-raised at - * several positions would render several times. The normalized (position, message) pair is - * deduplicated here instead. - * - * Finally, a type mismatch whose REQUIRED side is a DFHDL value is read by a user thinking in - * DFHDL types, where the compiler's own trailing guidance (`msgPostscript`) is noise or worse: the - * transparent-inline note explains the Scala mechanics behind the DFHDL operators, and the import - * suggestions (`InitValue.fromValue` and friends) never fix a DFHDL mismatch. Such a diagnostic is - * re-issued with an EMPTY postscript: a fresh message rather than `mapMsg`, since `mapMsg` - * deliberately carries the original postscript, and the postscript itself is protected so it - * cannot be filtered piecewise. The `-explain` explanation is kept. +/** The single home of DFHDL's user-facing diagnostic rewriting, applied by [[CustomReporter]] on + * the real compilation and by [[PluginTestPhase]] on nested snippet compilations, so specs assert + * on exactly what a user reads. */ -class CustomReporter( - val orig: Reporter, - symbols: DFHDLSymbols.Cache -) extends Reporter: - private val reported = collection.mutable.HashSet.empty[(String, Int, Int, Int, String)] - override def flush()(using ctx: Context): Unit = orig.flush() - private def updatedMsg(base: Message)(using Context): Message = +final class DiagnosticRewriter(symbols: DFHDLSymbols.Cache): + /** The frame of the diagnostic's inline position chain to report at. Dropping the outer chain + * (see [[CustomReporter]]) is only sound when the innermost position is trustworthy, and a + * diagnostic raised on a macro-synthesized tree is not: its innermost frame carries the span of + * the quote inside the macro's own source paired with the CURRENT unit's source file, so the + * rendered position lands past the unit's end (`Playground.scala:13:12843`-style). The chain is + * walked innermost to outermost, keeping the first frame that belongs to the compiled unit + * (`unitSource`: passed explicitly, since in a NESTED snippet compilation the chain extends past + * the snippet's virtual source into the enclosing real unit, so the outermost frame does not + * identify it) with a span that fits inside it. For every well-formed diagnostic the innermost + * frame qualifies, so this changes nothing; only corrupt or library-positioned frames are + * skipped. + */ + def normalizedPos(pos: util.SourcePosition, unitSource: util.SourceFile): util.SourcePosition = + val frames = Iterator + .iterate(pos)(_.outer) + .takeWhile(p => p != null && p.exists) + .toList + if (frames.isEmpty) pos + else + def sane(p: util.SourcePosition): Boolean = + p.span.exists && p.span.end <= p.source.content().length + frames.find(p => (p.source eq unitSource) && sane(p)).getOrElse(frames.last) + end normalizedPos + + /** The identity of a diagnostic AS RENDERED: the same inline-expansion error re-raised at several + * positions collapses onto one normalized position, so it must render once. + */ + def dedupKey(dia: Diagnostic, unitSource: util.SourceFile)(using + Context + ): (String, Int, Int, Int, String) = + val diaPos = normalizedPos(dia.pos, unitSource) + val (spanStart, spanEnd) = + if (diaPos.span.exists) (diaPos.span.start, diaPos.span.end) else (-1, -1) + (diaPos.source.file.path, spanStart, spanEnd, dia.level, dia.msg.toString) + + /** The message to report in place of `base`. Every message is re-rendered through the DFHDL type + * printer. A type mismatch whose REQUIRED side is a DFHDL value is additionally re-issued with + * an EMPTY postscript, since the compiler's own trailing guidance is noise or worse there (the + * transparent-inline note explains the Scala mechanics behind the DFHDL operators, and the + * import suggestions, `InitValue.fromValue` and friends, never fix a DFHDL mismatch): a fresh + * message rather than `mapMsg`, since `mapMsg` deliberately carries the original postscript, and + * the postscript itself is protected so it cannot be filtered piecewise. The `-explain` + * explanation is kept. `untpdRoot` is the compiled unit's parse tree, used to name the enclosing + * call in [[reduceGuideRail]] (pass `untpd.EmptyTree` when unavailable). + */ + def updatedMsg(base: Message, userPos: util.SourcePosition, untpdRoot: untpd.Tree)(using + Context + ): Message = // `toString` rather than `message`: it renders the message proper (without the postscript) // under the context the message captured, where the DFHDL type printer is live val rendered = base.toString @@ -71,41 +84,159 @@ class CustomReporter( syms.available && tm.expected.derivesFrom(syms.dfVal) case _ => false if (dropPostscript) + val withGuideRail = rendered ++ reduceGuideRail(base, userPos, untpdRoot) new Message(base.errorId): val kind = base.kind - def msg(using Context) = rendered + def msg(using Context) = withGuideRail override def msgPostscript(using Context) = "" def explain(using Context) = base.explanation override def canExplain = base.canExplain else base.mapMsg(_ => rendered) end updatedMsg - private def normalizedPos(pos: util.SourcePosition): util.SourcePosition = - val frames = Iterator - .iterate(pos)(_.outer) - .takeWhile(p => p != null && p.exists) - .toList - if (frames.isEmpty) pos - else - val unitSource = frames.last.source - def sane(p: util.SourcePosition): Boolean = - p.span.exists && p.span.end <= p.source.content().length - frames.find(p => (p.source eq unitSource) && sane(p)).getOrElse(frames.last) - end normalizedPos - private def dedupKey(dia: Diagnostic)(using Context): (String, Int, Int, Int, String) = - val diaPos = normalizedPos(dia.pos) - val (spanStart, spanEnd) = - if (diaPos.span.exists) (diaPos.span.start, diaPos.span.end) else (-1, -1) - (diaPos.source.file.path, spanStart, spanEnd, dia.level, dia.msg.toString) + + // The `(dfType, modifier args)` decomposition of a DFHDL value type, or None for anything else. + private def dfValParts(tp: Type)(using Context): Option[(Type, List[Type])] = + val syms = symbols() + tp.dealias match + case AppliedType(tycon, List(t, mod)) if tycon.typeSymbol == syms.dfVal => + mod.dealias match + case AppliedType(modTycon, args @ List(_, _, _, _)) + if modTycon.typeSymbol == syms.modifier => + Some((t, args)) + case _ => None + case _ => None + + private val foldFamily = Set( + "reduce", "reduceLeft", "reduceRight", "reduceOption", "reduceLeftOption", + "reduceRightOption", "fold", "foldLeft", "foldRight", "scan", "scanLeft", "scanRight" + ) + + // The simple name of the innermost call in `untpdRoot` one of whose arguments contains `pos` + // (the typed tree does not exist yet at reporting time, but the parse tree does). A parent is + // visited before its children, so the last match recorded is the innermost. Purely cosmetic, + // so any failure to answer is just `None`. + private def enclosingCallName(pos: util.SourcePosition, untpdRoot: untpd.Tree)(using + Context + ): Option[String] = + try + if (untpdRoot.isEmpty || !pos.span.exists) None + else + var found: Option[String] = None + def nameOf(fun: untpd.Tree): Option[String] = fun match + case untpd.Select(_, name) => Some(name.show) + case untpd.Ident(name) => Some(name.show) + case untpd.TypeApply(f, _) => nameOf(f) + case untpd.Apply(f, _) => nameOf(f) + case _ => None + val traverser = new untpd.UntypedTreeTraverser: + def traverse(tree: untpd.Tree)(using Context): Unit = + tree match + case untpd.Apply(fun, args) + if args.exists(a => a.span.exists && a.span.contains(pos.span)) => + nameOf(fun).foreach(n => found = Some(n)) + case _ => + traverseChildren(tree) + traverser.traverse(untpdRoot) + found + end if + catch case scala.util.control.NonFatal(_) => None + end enclosingCallName + + /** The guide rail for a plain computed value found where a declaration-modified value of the SAME + * DFHDL type is required (`Found: Bits[Int] <> VAL` vs `Required: Bits[Int] <> IN`): the + * signature of a `reduce`-style method that inferred its type parameter from port/variable slice + * elements before the operator was typed, where pinning the type parameter to the plain value + * type is the fix. When the enclosing call is identified as a known fold-family method the note + * asserts and names it; otherwise it stays conditional. Empty for every other mismatch. + */ + private def reduceGuideRail( + base: Message, + userPos: util.SourcePosition, + untpdRoot: untpd.Tree + )(using Context): String = + base match + case tm: TypeMismatch => + val syms = symbols() + val hint = + for + (foundT, foundMod) <- dfValParts(tm.found) + (expectedT, expectedMod) <- dfValParts(tm.expected) + // found is a plain value (Any access), required is declaration-modified, and the + // DFHDL type parts agree, so retyping the requirement as a plain value must succeed + if foundMod.head.isRef(defn.AnyClass) && !expectedMod.head.isRef(defn.AnyClass) && + (foundT =:= expectedT) + yield + val plainMod = syms.modifier.typeRef.appliedTo(List.fill(4)(defn.AnyType)) + val plainVal = syms.dfVal.typeRef.appliedTo(List(expectedT, plainMod)).show + enclosingCallName(userPos, untpdRoot).filter(foldFamily) match + case Some(name) => + s"""| + | + |Note: `$name` inferred its type parameter from the declaration (port or + |variable) slice elements, so the operator must land back on the declaration + |type, and an operation result is a plain value that never can. Set the type + |parameter to the plain value type explicitly: + | + | .$name[$plainVal](...)""".stripMargin + case None => + s"""| + | + |Note: the required type belongs to a declaration (a port or a variable), and an + |operation result is a plain value that can never take its place. If this is the + |operator of a method like `reduce`, the method inferred its type parameter from + |the declaration slices before the operator was typed; set it to the plain value + |type explicitly: + | + | .reduce[$plainVal](...)""".stripMargin + end match + hint.getOrElse("") + case _ => "" + end reduceGuideRail +end DiagnosticRewriter + +/** Re-renders every reported diagnostic before passing it on, which is what puts DFHDL's own type + * printer in front of the user (see [[DFHDLTypePrinter]]). + * + * The rendering swap is `Message.toString` rather than `Diagnostic.message`. They produce the same + * text out of the same message, but by different routes: `message` renders under + * `Message.inMessageContext`, which pins the printer to the compiler's own `Message.Printer` and + * so never consults the one this phase installs, whereas `toString` renders under the context the + * message captured, where that printer is live. Re-reporting also drops the diagnostic's outer + * position, which suppresses inline-stack error printing. + * + * The rewriting itself (position normalization, dedup identity, postscript handling and the DFHDL + * guide rails) lives in [[DiagnosticRewriter]], which the nested snippet compilations of + * [[PluginTestPhase]] share, so `assertPluginError` specs assert on exactly what a user reads. + * Re-reporting bypasses the original reporter's `UniqueMessagePositions` dedup (that dedup keys on + * the positions the rewriter rewrites), so the rewriter's own dedup is applied in `isHidden`. + */ +class CustomReporter( + val orig: Reporter, + symbols: DFHDLSymbols.Cache +) extends Reporter: + private val rewriter = DiagnosticRewriter(symbols) + private val reported = collection.mutable.HashSet.empty[(String, Int, Int, Int, String)] + override def flush()(using ctx: Context): Unit = orig.flush() + // the compiled unit's parse tree, for naming the enclosing call in the guide rail; the + // reporting context is the typing context, so its unit is the one holding the error + private def untpdRootFor(pos: util.SourcePosition)(using Context): untpd.Tree = + try + val unit = ctx.compilationUnit + if ((unit ne null) && (pos.source eq unit.source)) unit.untpdTree else untpd.EmptyTree + catch + case scala.util.control.NonFatal(_) => untpd.EmptyTree // the dedup lives in `isHidden` rather than `doReport` so a swallowed duplicate is also // never counted, keeping the "N errors found" summary consistent with what is rendered // (the same reason the compiler's own dedup, `UniqueMessagePositions`, works at this hook) override def isHidden(dia: Diagnostic)(using Context): Boolean = super.isHidden(dia) || - dia.level >= interfaces.Diagnostic.WARNING && !reported.add(dedupKey(dia)) + dia.level >= interfaces.Diagnostic.WARNING && + !reported.add(rewriter.dedupKey(dia, ctx.source)) override def doReport(dia: Diagnostic)(using ctx: Context): Unit = - val diaPos = normalizedPos(dia.pos).copy(outer = null) // disable inline stack error printing - val updatedDia = Diagnostic(updatedMsg(dia.msg), diaPos, dia.level) - orig.doReport(updatedDia) + val userPos = rewriter.normalizedPos(dia.pos, ctx.source) + val diaPos = userPos.copy(outer = null) // disable inline stack error printing + val newMsg = rewriter.updatedMsg(dia.msg, userPos, untpdRootFor(userPos)) + orig.doReport(Diagnostic(newMsg, diaPos, dia.level)) end doReport end CustomReporter From 703bb348e520ac46b949c38780a8f3848055d737 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Sat, 8 Aug 2026 15:51:38 +0300 Subject: [PATCH 14/25] plugin+core: not-a-member errors keep only their core sentence In a DFHDL compilation the compiler's selection-error addenda mislead rather than help: the import-suggestion machinery proposes DFHDL's internal conversions (`InitValue.fromValue`, `Exact.fromValue` and friends) for EVERY receiver, including plain Scala ones, and the extension-attempt transcript restates the receiver in raw types over dozens of lines. The diagnostic rewriter now reduces a `NotAMember` error to its core sentence, `value mem is not a member of UInt[Int] <> OUT`, cutting at the exact addendum openers `ErrorReporting.selectErrorAddendum` can append (the leading period there belongs to the addendum). A tried-but-failed extension keeps that one fact as a bare ` (extension method tried)`; the did-you-mean hint, which only appears when no other addendum does, is kept, and so is the `-explain` explanation. `TypePrinterSpec` pins all three shapes through the real rewriting path: no extension involved, extension tried, and a plain `Int` receiver. Co-Authored-By: Claude Fable 5 --- .../test/scala/CoreSpec/TypePrinterSpec.scala | 32 +++++++++ .../src/main/scala/plugin/PreTyperPhase.scala | 70 ++++++++++++++----- 2 files changed, 84 insertions(+), 18 deletions(-) diff --git a/core/src/test/scala/CoreSpec/TypePrinterSpec.scala b/core/src/test/scala/CoreSpec/TypePrinterSpec.scala index 40793d1f0..4ac7e45ba 100644 --- a/core/src/test/scala/CoreSpec/TypePrinterSpec.scala +++ b/core/src/test/scala/CoreSpec/TypePrinterSpec.scala @@ -266,6 +266,38 @@ class TypePrinterSpec extends DFSpec: """ ) + test("not-a-member errors keep only their core sentence"): + // in a DFHDL compilation the compiler's own selection-error addenda mislead rather than + // help (the import-suggestion machinery proposes DFHDL's internal conversions for every + // receiver, and the extension-attempt transcript restates the receiver in raw types), so + // the rewriter reduces the error to its core sentence; a tried-but-failed extension is + // kept as a bare parenthetical + assertSinglePluginError("value mem is not a member of UInt[8] <> OUT")( + """ + class Foo extends EDDesign: + val o = UInt(8) <> OUT + o.mem + """ + ) + assertSinglePluginError( + "value length is not a member of UInt[8] <> OUT (extension method tried)" + )( + """ + class Foo extends EDDesign: + val o = UInt(8) <> OUT + o.length + """ + ) + // the reduction applies to any receiver, not just DFHDL values: the suggested-import noise + // is compilation-wide once DFHDL's conversions are on the classpath + assertSinglePluginError("value zzz is not a member of Int")( + """ + class Foo extends EDDesign: + val x: Int = 1 + x.zzz + """ + ) + test("reduce over declaration slices guide rail"): // The issue #455 shape: `reduce` commits its type parameter to the port-modified slice // element type, which no operation result can conform to. The rewriter identifies the diff --git a/plugin/src/main/scala/plugin/PreTyperPhase.scala b/plugin/src/main/scala/plugin/PreTyperPhase.scala index 31e0e9a21..2a88c420c 100644 --- a/plugin/src/main/scala/plugin/PreTyperPhase.scala +++ b/plugin/src/main/scala/plugin/PreTyperPhase.scala @@ -66,10 +66,14 @@ final class DiagnosticRewriter(symbols: DFHDLSymbols.Cache): * printer. A type mismatch whose REQUIRED side is a DFHDL value is additionally re-issued with * an EMPTY postscript, since the compiler's own trailing guidance is noise or worse there (the * transparent-inline note explains the Scala mechanics behind the DFHDL operators, and the - * import suggestions, `InitValue.fromValue` and friends, never fix a DFHDL mismatch): a fresh - * message rather than `mapMsg`, since `mapMsg` deliberately carries the original postscript, and - * the postscript itself is protected so it cannot be filtered piecewise. The `-explain` - * explanation is kept. `untpdRoot` is the compiled unit's parse tree, used to name the enclosing + * import suggestions, `InitValue.fromValue` and friends, never fix a DFHDL mismatch). A + * `NotAMember` selection error is reduced to its core sentence (`value mem is not a member of + * UInt[Int] <> OUT`): in a DFHDL compilation the import-suggestion machinery proposes DFHDL's + * internal conversions for EVERY receiver, and the extension-attempt transcript restates the + * receiver in raw types over dozens of lines, so both mislead rather than help; when extension + * methods were tried, that fact is kept as a bare ` (extension method tried)`. The did-you-mean + * hint, which only appears when no other addendum does, is kept. The `-explain` explanation is + * kept everywhere. `untpdRoot` is the compiled unit's parse tree, used to name the enclosing * call in [[reduceGuideRail]] (pass `untpd.EmptyTree` when unavailable). */ def updatedMsg(base: Message, userPos: util.SourcePosition, untpdRoot: untpd.Tree)(using @@ -78,22 +82,52 @@ final class DiagnosticRewriter(symbols: DFHDLSymbols.Cache): // `toString` rather than `message`: it renders the message proper (without the postscript) // under the context the message captured, where the DFHDL type printer is live val rendered = base.toString - val dropPostscript = base match - case tm: TypeMismatchMsg => - val syms = symbols() - syms.available && tm.expected.derivesFrom(syms.dfVal) - case _ => false - if (dropPostscript) - val withGuideRail = rendered ++ reduceGuideRail(base, userPos, untpdRoot) - new Message(base.errorId): - val kind = base.kind - def msg(using Context) = withGuideRail - override def msgPostscript(using Context) = "" - def explain(using Context) = base.explanation - override def canExplain = base.canExplain - else base.mapMsg(_ => rendered) + val syms = symbols() + base match + case tm: TypeMismatchMsg if syms.available && tm.expected.derivesFrom(syms.dfVal) => + freshMsg(base, rendered ++ reduceGuideRail(base, userPos, untpdRoot)) + case nam: NotAMember if syms.available => + freshMsg(base, notAMemberText(rendered)) + case _ => base.mapMsg(_ => rendered) end updatedMsg + // a fresh message rather than `mapMsg`: `mapMsg` deliberately carries the original postscript, + // and the postscript itself is protected so it cannot be filtered piecewise; the `-explain` + // explanation is kept + private def freshMsg(base: Message, text: String)(using Context): Message = + new Message(base.errorId): + val kind = base.kind + def msg(using Context) = text + override def msgPostscript(using Context) = "" + def explain(using Context) = base.explanation + override def canExplain = base.canExplain + + // the exact addendum openers `ErrorReporting.selectErrorAddendum` can append to a `NotAMember` + // core sentence (the leading `.` there belongs to the addendum, not the sentence), plus the + // import-suggestion openers for defensive coverage of any other route into the message + private val extTriedMarkers = List( + ".\nAn extension method was tried, but could not be fully constructed:", + ".\nExtension methods were tried, but could not be fully constructed:", + ".\nExtension methods were tried, but the search failed with:" + ) + private val availableMarker = ", but could be made available as an extension method." + private val importMarkers = List( + "\nOne of the following imports might", + "\nThe following import might" + ) + + private def notAMemberText(rendered: String): String = + def firstIdx(markers: List[String]): Option[Int] = + markers.map(rendered.indexOf).filter(_ >= 0).minOption + val extIdx = firstIdx(extTriedMarkers) + val cutIdx = (extIdx ++ firstIdx(availableMarker :: Nil) ++ firstIdx(importMarkers)).minOption + cutIdx match + case Some(cut) => + val core = rendered.take(cut) + if (extIdx.contains(cut)) core ++ " (extension method tried)" else core + case None => rendered + end notAMemberText + // The `(dfType, modifier args)` decomposition of a DFHDL value type, or None for anything else. private def dfValParts(tp: Type)(using Context): Option[(Type, List[Type])] = val syms = symbols() From dbf374fcb0c9447b98ffacd993ca5be2e0122ae9 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Sat, 8 Aug 2026 16:09:06 +0300 Subject: [PATCH 15/25] plugin+core: restore the did-you-mean hint on stripped not-a-member errors Upstream computes the hint only when no other addendum exists, and in a DFHDL compilation the import-suggestion addendum is never empty: the Exact/DFVal conversions make every selection on every receiver look "available as an extension method", which is the same mechanism behind the stripped import spam. So `x.toStrig` never said "did you mean x.toString?" to a DFHDL user at all; stripping alone cannot restore what was never computed. The rewriter recomputes the hint after the strip, exactly the way `NotAMember.msg` would have (`DidYouMean.memberCandidates`, `closestTo`, `didYouMean`, all public API). The message's `site` and `proto` are private constructor parameters, reached by reflection; any failure there just means no hint. The extension-tried variant keeps its bare parenthetical without a hint, matching upstream, where an attempted extension also suppresses it. Co-Authored-By: Claude Fable 5 --- .../test/scala/CoreSpec/TypePrinterSpec.scala | 10 +++++ .../src/main/scala/plugin/PreTyperPhase.scala | 45 ++++++++++++++++--- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/core/src/test/scala/CoreSpec/TypePrinterSpec.scala b/core/src/test/scala/CoreSpec/TypePrinterSpec.scala index 4ac7e45ba..f958ff995 100644 --- a/core/src/test/scala/CoreSpec/TypePrinterSpec.scala +++ b/core/src/test/scala/CoreSpec/TypePrinterSpec.scala @@ -297,6 +297,16 @@ class TypePrinterSpec extends DFSpec: x.zzz """ ) + // the did-you-mean hint is recomputed after the strip: upstream computes it only when no + // other addendum exists, and the conversions make the import-suggestion addendum non-empty + // for every selection, so without the recomputation DFHDL users would never see it + assertSinglePluginError("value toStrig is not a member of Int - did you mean x.toString?")( + """ + class Foo extends EDDesign: + val x: Int = 1 + x.toStrig + """ + ) test("reduce over declaration slices guide rail"): // The issue #455 shape: `reduce` commits its type parameter to the port-modified slice diff --git a/plugin/src/main/scala/plugin/PreTyperPhase.scala b/plugin/src/main/scala/plugin/PreTyperPhase.scala index 2a88c420c..92e2fe0d8 100644 --- a/plugin/src/main/scala/plugin/PreTyperPhase.scala +++ b/plugin/src/main/scala/plugin/PreTyperPhase.scala @@ -72,9 +72,11 @@ final class DiagnosticRewriter(symbols: DFHDLSymbols.Cache): * internal conversions for EVERY receiver, and the extension-attempt transcript restates the * receiver in raw types over dozens of lines, so both mislead rather than help; when extension * methods were tried, that fact is kept as a bare ` (extension method tried)`. The did-you-mean - * hint, which only appears when no other addendum does, is kept. The `-explain` explanation is - * kept everywhere. `untpdRoot` is the compiled unit's parse tree, used to name the enclosing - * call in [[reduceGuideRail]] (pass `untpd.EmptyTree` when unavailable). + * hint is RESTORED rather than merely kept: upstream computes it only when no other addendum + * exists, and here the (garbage) import-suggestion addendum always does, so it is recomputed + * after the strip (see [[didYouMeanHint]]). The `-explain` explanation is kept everywhere. + * `untpdRoot` is the compiled unit's parse tree, used to name the enclosing call in + * [[reduceGuideRail]] (pass `untpd.EmptyTree` when unavailable). */ def updatedMsg(base: Message, userPos: util.SourcePosition, untpdRoot: untpd.Tree)(using Context @@ -87,7 +89,7 @@ final class DiagnosticRewriter(symbols: DFHDLSymbols.Cache): case tm: TypeMismatchMsg if syms.available && tm.expected.derivesFrom(syms.dfVal) => freshMsg(base, rendered ++ reduceGuideRail(base, userPos, untpdRoot)) case nam: NotAMember if syms.available => - freshMsg(base, notAMemberText(rendered)) + freshMsg(base, notAMemberText(nam, rendered)) case _ => base.mapMsg(_ => rendered) end updatedMsg @@ -116,7 +118,7 @@ final class DiagnosticRewriter(symbols: DFHDLSymbols.Cache): "\nThe following import might" ) - private def notAMemberText(rendered: String): String = + private def notAMemberText(nam: NotAMember, rendered: String)(using Context): String = def firstIdx(markers: List[String]): Option[Int] = markers.map(rendered.indexOf).filter(_ >= 0).minOption val extIdx = firstIdx(extTriedMarkers) @@ -124,10 +126,41 @@ final class DiagnosticRewriter(symbols: DFHDLSymbols.Cache): cutIdx match case Some(cut) => val core = rendered.take(cut) - if (extIdx.contains(cut)) core ++ " (extension method tried)" else core + if (extIdx.contains(cut)) core ++ " (extension method tried)" + else core ++ didYouMeanHint(nam) case None => rendered end notAMemberText + // The did-you-mean hint for a stripped `NotAMember`, recomputed the way `NotAMember.msg` + // computes it. Upstream only computes the hint when NO other addendum exists, and in a DFHDL + // compilation the (stripped) import-suggestion addendum always exists, so the hint the user + // deserves never gets a chance there. The message's `site` and `proto` are private constructor + // parameters, hence the reflection; any failure just means no hint. + private def didYouMeanHint(nam: NotAMember)(using Context): String = + try + import DidYouMean.* + def field[T](fname: String): T = + val f = classOf[NotAMember].getDeclaredField(fname) + f.setAccessible(true) + f.get(nam).asInstanceOf[T] + val site = field[Type]("site") + val proto = field[Type]("proto") + didYouMean( + memberCandidates( + site, + nam.name.isTypeName, + isApplied = proto.isInstanceOf[typer.ProtoTypes.FunProto] + ) + .closestTo(nam.name.show) + .map((d, sym) => (d, Binding(sym.name, sym, site))), + proto, + prefix = site match + case site: NamedType => i"${site.name}." + case site => i"$site." + ) + catch case scala.util.control.NonFatal(_) => "" + end didYouMeanHint + // The `(dfType, modifier args)` decomposition of a DFHDL value type, or None for anything else. private def dfValParts(tp: Type)(using Context): Option[(Type, List[Type])] = val syms = symbols() From 3fe8269ecc786c5ed6b3c475c47ab22608a7db4e Mon Sep 17 00:00:00 2001 From: Oron Port Date: Sat, 8 Aug 2026 17:59:39 +0300 Subject: [PATCH 16/25] core+docs: `.width`/`.length` queries on DFTypes and bit-accurate values Fixes #457 Applying `.width` on a DFType constructor is now the DFHDL counterpart of Verilog's `$clog2` width derivation: construct the type with `.until`/`.to` and recover the width the constructor computed, as a constant DFHDL `Int` rather than a plain Scala `Int`, so a parametric width stays symbolic: val ADDR_WIDTH = UInt.until(DEPTH).width // elaborates as clog2(DEPTH) `.length` joins it for the types where "how many" is a natural question: for `Bits`/`UInt`/`SInt` DFTypes and values it is the number of bits, identical to `.width`; for vector DFTypes it is the number of elements, matching the value-level vector `length` that already existed. The user guide gains a "Width and Length Queries" section in the type-system Operations reference; the `clog2` anti-pattern warning now completes its own story with the recovery idiom; and the Vector Element Access table drops a stale `vec.size` row (no such member exists) in favor of the verified `length`/`width` rows. `WidthLengthQueriesSpec` pins the elaborated constants, including the symbolic parametric width and the vector length/width distinction. `TypePrinterSpec`'s extension-tried diagnostic case moves from `o.length`, which this change makes legal, to `Bit`'s `length`, which no extension serves. Co-Authored-By: Claude Fable 5 --- core/src/main/scala/dfhdl/core/DFType.scala | 14 ++++++ core/src/main/scala/dfhdl/core/DFVector.scala | 5 ++ core/src/main/scala/dfhdl/hdl.scala | 1 + .../test/scala/CoreSpec/TypePrinterSpec.scala | 8 +-- .../CoreSpec/WidthLengthQueriesSpec.scala | 49 +++++++++++++++++++ docs/user-guide/type-system/index.md | 47 +++++++++++++++++- 6 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 core/src/test/scala/CoreSpec/WidthLengthQueriesSpec.scala diff --git a/core/src/main/scala/dfhdl/core/DFType.scala b/core/src/main/scala/dfhdl/core/DFType.scala index d53b86f90..264498834 100644 --- a/core/src/main/scala/dfhdl/core/DFType.scala +++ b/core/src/main/scala/dfhdl/core/DFType.scala @@ -284,6 +284,20 @@ object DFType: widthRef(lhs).refErrorString end extension + object Ops: + extension (dfType: DFTypeAny) + @targetName("widthDFType") + def width(using DFC): DFConstInt32 = + dfType.widthIntParam(using TC(dfType))(using dfc, new Width[DFTypeAny] {}).toDFConst + // for Bits/UInt/SInt, length == width, but for vectors, + // length is the number of elements, and width is the total width of the vector (length * element width) + extension [W <: IntP](dfType: DFTypeW[W]) + @targetName("lengthDFTypeW") + def length(using DFC): DFConstInt32 = dfType.width + extension [W <: IntP, T <: DFTypeW[W]](dfVal: DFValOf[T]) + @targetName("lengthDFValDFTypeW") + def length(using DFC): DFConstInt32 = dfVal.dfType.width + end DFType type DFTypeW[W <: IntP] = DFBits[W] | DFUInt[W] | DFSInt[W] diff --git a/core/src/main/scala/dfhdl/core/DFVector.scala b/core/src/main/scala/dfhdl/core/DFVector.scala index b31f50340..afd114182 100644 --- a/core/src/main/scala/dfhdl/core/DFVector.scala +++ b/core/src/main/scala/dfhdl/core/DFVector.scala @@ -334,6 +334,11 @@ object DFVector: DFVal.Func(vectorType, FuncOp.++, elems.toList) end DFVector + extension [T <: DFTypeAny, D1 <: IntP]( + dfType: DFVector[T, Tuple1[D1]] + ) + def length(using DFC): DFConstInt32 = dfType.lengthIntParam.toDFConst + extension [T <: DFTypeAny, D1 <: IntP, M <: ModifierAny]( lhs: DFVal[DFVector[T, Tuple1[D1]], M] ) diff --git a/core/src/main/scala/dfhdl/hdl.scala b/core/src/main/scala/dfhdl/hdl.scala index f32fddfc5..e4a3ceac0 100644 --- a/core/src/main/scala/dfhdl/hdl.scala +++ b/core/src/main/scala/dfhdl/hdl.scala @@ -23,6 +23,7 @@ protected object hdl: export compiler.ir.TextOut.Severity export internals.CommonOps.* export core.{dfType} + export core.DFType.Ops.* export core.DFPhysical.Val.Ops.* export core.LoopOps.* type Time = core.DFTime diff --git a/core/src/test/scala/CoreSpec/TypePrinterSpec.scala b/core/src/test/scala/CoreSpec/TypePrinterSpec.scala index f958ff995..5062a14f7 100644 --- a/core/src/test/scala/CoreSpec/TypePrinterSpec.scala +++ b/core/src/test/scala/CoreSpec/TypePrinterSpec.scala @@ -279,13 +279,15 @@ class TypePrinterSpec extends DFSpec: o.mem """ ) + // `Bit` supports neither the vector `length` (element count) nor the `Bits`/`UInt`/`SInt` + // `length` (bit count), so the tried extensions fail assertSinglePluginError( - "value length is not a member of UInt[8] <> OUT (extension method tried)" + "value length is not a member of Bit <> VAR (extension method tried)" )( """ class Foo extends EDDesign: - val o = UInt(8) <> OUT - o.length + val b = Bit <> VAR + b.length """ ) // the reduction applies to any receiver, not just DFHDL values: the suggested-import noise diff --git a/core/src/test/scala/CoreSpec/WidthLengthQueriesSpec.scala b/core/src/test/scala/CoreSpec/WidthLengthQueriesSpec.scala new file mode 100644 index 000000000..02aacc7c9 --- /dev/null +++ b/core/src/test/scala/CoreSpec/WidthLengthQueriesSpec.scala @@ -0,0 +1,49 @@ +package CoreSpec +import dfhdl.* +import dfhdl.compiler.printing.DefaultPrinter + +// `.width` applied on a DFType is the `$clog2` width-derivation idiom (issue #457): construct +// the type with `.until`/`.to` and recover the width the constructor computed. The recovered +// width of a parametric type stays symbolic (`clog2(N)` below). `.length` equals `.width` for +// the bit-accurate scalars (`Bits`/`UInt`/`SInt`) and counts ELEMENTS for vectors, on both +// DFTypes and values. Both queries return `Int <> CONST`. +class WidthLengthQueriesSpec extends NoDFCSpec: + // the freshly elaborated design, before any of the stages that rename and reorder members + private def codeString(dsn: core.Design): String = + val db = dsn.getDB + DefaultPrinter(using db.getSet).csDB + + test("type and value width/length queries") { + class Top extends EDDesign: + val N: Int <> CONST = 854 + val ADDR_WIDTH = UInt.until(N).width + val a = UInt(ADDR_WIDTH) <> OUT + val W8 = Bits(8).width + val LB = Bits(8).length + val LU = UInt(8).length + val LS = SInt(8).length + val TVW = (Bits(8) X 4).width + val TVL = (Bits(8) X 4).length + val o = UInt(8) <> OUT + val OL = o.length + o <> OL.bits.uint.resize(8) + a <> 0 + assertNoDiff( + codeString(Top()), + """|class Top extends EDDesign: + | val N: Int <> CONST = 854 + | val a = UInt(clog2(N)) <> OUT + | val W8: Int <> CONST = 8 + | val LB: Int <> CONST = 8 + | val LU: Int <> CONST = 8 + | val LS: Int <> CONST = 8 + | val TVW: Int <> CONST = 32 + | val TVL: Int <> CONST = 4 + | val o = UInt(8) <> OUT + | val OL: Int <> CONST = 8 + | o <> OL.bits.uint.resize(8) + | a <> d"1'0".resize(clog2(N)) + |end Top""".stripMargin + ) + } +end WidthLengthQueriesSpec diff --git a/docs/user-guide/type-system/index.md b/docs/user-guide/type-system/index.md index 2b5419a3b..62bffe19d 100755 --- a/docs/user-guide/type-system/index.md +++ b/docs/user-guide/type-system/index.md @@ -2891,6 +2891,12 @@ val addr = UInt.until(DEPTH) <> VAR // width = clog2(DEPTH) val mask = Bits.until(SIZE) <> VAR // width = clog2(SIZE) ``` See the [DFType Constructors][DFDecimal] and [Bits constructors][DFBits] sections for details on `.until` and `.to`. + +When the computed width itself is needed, for example to pass it on to a child design or to size a related field, recover it from the constructed type with `.width` instead of calling `clog2` yourself: +```scala +val ADDR_WIDTH = UInt.until(DEPTH).width // Int <> CONST = clog2(DEPTH) +``` +See [Width and Length Queries][width-length-ops] for details. /// /// admonition | Non-constant DFHDL `Int` values @@ -2903,6 +2909,44 @@ Non-constant DFHDL `Int` values (e.g., `Int <> VAR`) are possible and support th To extract a partial bit range from a DFHDL `Int` value, first convert it to `Bits` using `.bits`, then apply the slice: `myInt.bits(hi, lo)`. This is a `.bits` conversion followed by `(hi, lo)` slicing. The `.bits` conversion is a DFHDL extension method available on DFHDL `Int <> CONST` values, not on plain Scala `Int`. /// +### Width and Length Queries (`.width`, `.length`) {#width-length-ops} + +Applies to: `.width`: any DFType and any DFHDL value; `.length`: `Bits`/`UInt`/`SInt` DFTypes and values, and `Vector` DFTypes and values + +Both queries return a constant DFHDL `Int` value (`Int <> CONST`) rather than a plain Scala `Int`, so they compose with design parameters: querying a parametric type keeps the result symbolic, and the generated code carries the width expression (`clog2(DEPTH)`, `LANE * LANES`, and so on) instead of a folded number. + +/// html | div.operations +| Operation | Description | Returns | +| ---------- | ----------- | ------- | +| `x.width` | The total bit width of `x`, a DFType or a DFHDL value | `Int <> CONST` | +| `x.length` | For `Bits`/`UInt`/`SInt`: the number of bits, identical to `.width`. For `Vector`: the number of elements | `Int <> CONST` | +/// + +Applying `.width` directly on a DFType is the DFHDL counterpart of Verilog's `$clog2` width derivation: construct the type with [`UInt.until`/`UInt.to`][DFDecimal] (or their [`Bits` counterparts][DFBits]) and recover the width the constructor computed: + +```scala +class Foo(val DEPTH: Int <> CONST = 854) extends RTDesign: + // like Verilog's `$clog2(DEPTH)`, and stays parametric: elaborates as `clog2(DEPTH)` + val ADDR_WIDTH = UInt.until(DEPTH).width + val addr = UInt(ADDR_WIDTH) <> VAR +``` + +For vectors the two queries answer different questions: `.length` counts elements, while `.width` is the total bit width (the element count times the element width). + +```scala +val w8 = Bits(8).width // Int <> CONST = 8 +val vec = Bits(8) X 4 <> VAR +val elems = vec.length // Int <> CONST = 4 (elements) +val bits = vec.width // Int <> CONST = 32 (total bits: 4 * 8) +val o = UInt(8) <> OUT +val ol = o.length // Int <> CONST = 8, same as `o.width` +``` + +/// admonition | Declare with `.until`/`.to`, recover with `.width` + type: tip +Prefer declaring range-derived values directly with the `.until`/`.to` constructors and reach for `.width` only where the width itself is the value you need. The declaration then remains the single source of the width relationship, and every derived width follows it. +/// + ### History Operations {#history-ops} Applies to: `Bit` (`.rising`, `.falling`) @@ -3056,7 +3100,8 @@ vec(idx) := newValue // Write element at index | ------------ | ----------- | ------- | | `vec(idx)` | Access element at index | Element type | | `vec.elements` | Get all elements as Scala sequence | Seq[BaseType] | -| `vec.size` | Get vector dimension | Int | +| `vec.length` | Get the number of elements | `Int <> CONST` | +| `vec.width` | Get the total bit width (see [Width and Length Queries][width-length-ops]) | `Int <> CONST` | /// From 0d386c02c08a24b9d5d0ca1105fc62cca133e8d7 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Sat, 8 Aug 2026 18:21:31 +0300 Subject: [PATCH 17/25] core+docs: a `val` binding a width/length query keeps its name in the generated code Follow-up to #457. `val ADDR_WIDTH = UInt.until(N).width` elaborated without the binding's name: `toDFConst` mints a fresh NAMED const for a literal width, but a parametric width already exists as an anonymous member (`clog2(N)`, created inside the type constructor), and the handle was returned untouched, so the printers inlined the expression at every use site instead of declaring the constant. The new `toDFConstQuery` sibling rebinds a pre-existing constant reached under a named context through a named Ident, never a meta restamp (issue #449), and the three user-facing query sites use it: `DFType.Ops.width` (which the `length` delegates share), the vector type `length`, and the value-level `DFVal.width`. The generated code then reads val ADDR_WIDTH: Int <> CONST = clog2(N) val a = UInt(ADDR_WIDTH) <> OUT with the constant referenced by name everywhere, including width checks (`.resize(ADDR_WIDTH)`). `toDFConst` itself stays wrap-free on purpose: operations pass constants as function ARGUMENTS under the operation's own context (`repeat`'s count, for example), and a wrap there would steal the result's name for an argument. The plugin's naming scope completes the picture: only the spine apply of a `val` carries its name, so an inner query such as `value.width.toScalaInt` stays anonymous and creates no member, and the full suite shows no other printed-output change. Co-Authored-By: Claude Fable 5 --- core/src/main/scala/dfhdl/core/DFType.scala | 2 +- core/src/main/scala/dfhdl/core/DFVal.scala | 2 +- core/src/main/scala/dfhdl/core/DFVector.scala | 2 +- core/src/main/scala/dfhdl/core/IntParam.scala | 13 +++++++++++++ .../scala/CoreSpec/WidthLengthQueriesSpec.scala | 9 ++++++--- docs/user-guide/type-system/index.md | 3 ++- 6 files changed, 24 insertions(+), 7 deletions(-) diff --git a/core/src/main/scala/dfhdl/core/DFType.scala b/core/src/main/scala/dfhdl/core/DFType.scala index 264498834..68b84875c 100644 --- a/core/src/main/scala/dfhdl/core/DFType.scala +++ b/core/src/main/scala/dfhdl/core/DFType.scala @@ -288,7 +288,7 @@ object DFType: extension (dfType: DFTypeAny) @targetName("widthDFType") def width(using DFC): DFConstInt32 = - dfType.widthIntParam(using TC(dfType))(using dfc, new Width[DFTypeAny] {}).toDFConst + dfType.widthIntParam(using TC(dfType))(using dfc, new Width[DFTypeAny] {}).toDFConstQuery // for Bits/UInt/SInt, length == width, but for vectors, // length is the number of elements, and width is the total width of the vector (length * element width) extension [W <: IntP](dfType: DFTypeW[W]) diff --git a/core/src/main/scala/dfhdl/core/DFVal.scala b/core/src/main/scala/dfhdl/core/DFVal.scala index 6f6bba4f1..80efdab32 100644 --- a/core/src/main/scala/dfhdl/core/DFVal.scala +++ b/core/src/main/scala/dfhdl/core/DFVal.scala @@ -1707,7 +1707,7 @@ object DFVal extends DFValLP: DFVal.Alias.History(dfVal, step, HistoryOp.State, initOpt) } def reg(using DFC, RTDomainOnly, RegInitCheck[I]): DFValOf[T] = dfVal.reg(1) - def width(using DFC): DFConstInt32 = dfVal.widthIntParam.toDFConst + def width(using DFC): DFConstInt32 = dfVal.widthIntParam.toDFConstQuery end extension extension [T <: DFTypeAny, A, C, I, P](dfVal: DFVal[T, Modifier[A, C, I, P]]) diff --git a/core/src/main/scala/dfhdl/core/DFVector.scala b/core/src/main/scala/dfhdl/core/DFVector.scala index afd114182..720dcbf8f 100644 --- a/core/src/main/scala/dfhdl/core/DFVector.scala +++ b/core/src/main/scala/dfhdl/core/DFVector.scala @@ -337,7 +337,7 @@ object DFVector: extension [T <: DFTypeAny, D1 <: IntP]( dfType: DFVector[T, Tuple1[D1]] ) - def length(using DFC): DFConstInt32 = dfType.lengthIntParam.toDFConst + def length(using DFC): DFConstInt32 = dfType.lengthIntParam.toDFConstQuery extension [T <: DFTypeAny, D1 <: IntP, M <: ModifierAny]( lhs: DFVal[DFVector[T, Tuple1[D1]], M] diff --git a/core/src/main/scala/dfhdl/core/IntParam.scala b/core/src/main/scala/dfhdl/core/IntParam.scala index 588e729d9..14b9e62f9 100644 --- a/core/src/main/scala/dfhdl/core/IntParam.scala +++ b/core/src/main/scala/dfhdl/core/IntParam.scala @@ -215,6 +215,19 @@ object IntParam extends IntParamLP: lhs match case int: Int => DFConstInt32(int, named = true) case const: DFConstInt32 => const + // The user-facing sibling of `toDFConst` for the width/length QUERIES, whose result a `val` + // binds directly (`val ADDR_WIDTH = UInt.until(N).width`). A literal is minted named, as in + // `toDFConst`, but a pre-existing constant (a parametric width) reached under a named context + // is rebound through a named Ident, never a meta restamp (issue #449), so the binding's name + // survives into the generated code instead of the expression inlining at every use site. + // `toDFConst` itself must stay wrap-free: operations pass constants as function ARGUMENTS + // under the operation's own context, and a wrap there would steal the result's name. + def toDFConstQuery: DFConstInt32 = + lhs match + case int: Int => DFConstInt32(int, named = true) + case const: DFConstInt32 => + if (dfc.isAnonymous) const + else DFVal.Alias.AsIs.ident(const) def toScalaIntOpt: Option[Int] = lhs match case int: Int => Some(int) diff --git a/core/src/test/scala/CoreSpec/WidthLengthQueriesSpec.scala b/core/src/test/scala/CoreSpec/WidthLengthQueriesSpec.scala index 02aacc7c9..25590670a 100644 --- a/core/src/test/scala/CoreSpec/WidthLengthQueriesSpec.scala +++ b/core/src/test/scala/CoreSpec/WidthLengthQueriesSpec.scala @@ -6,7 +6,9 @@ import dfhdl.compiler.printing.DefaultPrinter // the type with `.until`/`.to` and recover the width the constructor computed. The recovered // width of a parametric type stays symbolic (`clog2(N)` below). `.length` equals `.width` for // the bit-accurate scalars (`Bits`/`UInt`/`SInt`) and counts ELEMENTS for vectors, on both -// DFTypes and values. Both queries return `Int <> CONST`. +// DFTypes and values. Both queries return `Int <> CONST`, and a `val` binding a query keeps its +// name in the generated code: a pre-existing (parametric) width constant is rebound through a +// named Ident (`toDFConstQuery`), never restamped (issue #449). class WidthLengthQueriesSpec extends NoDFCSpec: // the freshly elaborated design, before any of the stages that rename and reorder members private def codeString(dsn: core.Design): String = @@ -32,7 +34,8 @@ class WidthLengthQueriesSpec extends NoDFCSpec: codeString(Top()), """|class Top extends EDDesign: | val N: Int <> CONST = 854 - | val a = UInt(clog2(N)) <> OUT + | val ADDR_WIDTH: Int <> CONST = clog2(N) + | val a = UInt(ADDR_WIDTH) <> OUT | val W8: Int <> CONST = 8 | val LB: Int <> CONST = 8 | val LU: Int <> CONST = 8 @@ -42,7 +45,7 @@ class WidthLengthQueriesSpec extends NoDFCSpec: | val o = UInt(8) <> OUT | val OL: Int <> CONST = 8 | o <> OL.bits.uint.resize(8) - | a <> d"1'0".resize(clog2(N)) + | a <> d"1'0".resize(ADDR_WIDTH) |end Top""".stripMargin ) } diff --git a/docs/user-guide/type-system/index.md b/docs/user-guide/type-system/index.md index 62bffe19d..f4015d62f 100755 --- a/docs/user-guide/type-system/index.md +++ b/docs/user-guide/type-system/index.md @@ -2926,7 +2926,8 @@ Applying `.width` directly on a DFType is the DFHDL counterpart of Verilog's `$c ```scala class Foo(val DEPTH: Int <> CONST = 854) extends RTDesign: - // like Verilog's `$clog2(DEPTH)`, and stays parametric: elaborates as `clog2(DEPTH)` + // like Verilog's `$clog2(DEPTH)`, and stays parametric: the generated code keeps the + // named constant `ADDR_WIDTH = clog2(DEPTH)` val ADDR_WIDTH = UInt.until(DEPTH).width val addr = UInt(ADDR_WIDTH) <> VAR ``` From a8066cca38f987c7cf3c5f02f25947f6024befaf Mon Sep 17 00:00:00 2001 From: Oron Port Date: Sat, 8 Aug 2026 18:58:58 +0300 Subject: [PATCH 18/25] docs: inter-dependent design parameters Resolves DFiantHDL/dfhdl_by_agents#115 and DFiantHDL/dfhdl_by_agents#88 A derived parameter is not declared as a parameter in DFHDL: a parameter default cannot reference a sibling parameter (now stated at the Parameter Block Syntax `_default_` bullet), and no separate parameter is needed, since the derived value is computed in the design body as a named constant that serves everywhere a parameter would, port widths included. The new subsection demonstrates the pattern with a generic register parameterized solely by its initialization value (`Bits[Int] <> CONST`), deriving the register length with the `.length` query and sizing the ports with it. The generated Verilog and VHDL are included inline, both captured from real runs and lint-verified (verilator, ghdl): the derived constant appears by name (a Verilog localparam, a VHDL generic), the parameter width is fixed by the applied argument while its value stays overridable, and keeping two parameters consistent by hand, the mismatch trap of dfhdl_by_agents#88, is ruled out by construction. Co-Authored-By: Claude Fable 5 --- docs/user-guide/design-hierarchy/index.md | 94 ++++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/docs/user-guide/design-hierarchy/index.md b/docs/user-guide/design-hierarchy/index.md index 487a7bacb..492715fea 100644 --- a/docs/user-guide/design-hierarchy/index.md +++ b/docs/user-guide/design-hierarchy/index.md @@ -226,7 +226,7 @@ The DFHDL design parameter block follows standard Scala syntax, accepting a comm - Used in the generated backend code - Available through the CLI for top designs -* __`_default_`__ - Optional default value. +* __`_default_`__ - Optional default value. A default cannot reference another parameter of the same design; a parameter that would be derived from its siblings belongs in the design body instead. See [Inter-Dependent Design Parameters][inter-dependent-params]. * __`_access_`__ - Optional [Scala access modifier](https://docs.scala-lang.org/scala3/book/domain-modeling-oop.html#access-modifiers){target="_blank"}. Usually `#!scala val` to make the parameter public. See [Design Parameter Access Rules][design-parameter-access-rules] for details. @@ -401,6 +401,98 @@ class Foo( Overusing default parameter values is considered bad design practice. In general, default values should be used sparingly and only to define "sensible defaults" for parameters that are rarely changed. A good rule of thumb is to *avoid* default values that affect a design's interface (e.g., the width of a port). /// +#### Inter-Dependent Design Parameters {#inter-dependent-params} + +HDL designs often declare one parameter whose value is derived from another, like a width parameter alongside an initialization parameter of that width, or a `$clog2`-computed width in Verilog. In DFHDL such a DERIVED parameter is not declared as a parameter at all: a parameter default cannot reference a sibling parameter (a Scala restriction on parameter lists), and no separate parameter is needed, since the derived value is simply computed in the design body as a named constant. The body constant serves everywhere a parameter would, port widths included, and appears by name in the generated code. + +The generic register below is parameterized solely by its initialization value `INIT`, an unbounded-width `Bits[Int] <> CONST` parameter. The register length `LEN` is derived from it with the [`.length` query][width-length-ops] and sets the port widths: + +```scala +import dfhdl.* + +/** A generic register */ +class InitReg( + val INIT: Bits[Int] <> CONST = h"00" +) extends RTDesign: + /** the register length, derived from the initialization parameter */ + val LEN = INIT.length + /** data input */ + val din = Bits(LEN) <> IN + /** data output */ + val dout = Bits(LEN) <> OUT + dout := din.reg(1, init = INIT) +``` + +/// tab | Generated Verilog +```verilog +/* A generic register */ +`default_nettype none +`timescale 1ns/1ps + +module InitReg#(parameter logic [7:0] INIT = 8'h00)( + input wire logic clk, + input wire logic rst, + /* data input */ + input wire logic [LEN - 1:0] din, + /* data output */ + output logic [LEN - 1:0] dout +); + `include "dfhdl_defs.svh" + /* the register length, derived from the initialization parameter */ + localparam int LEN = 8; + always_ff @(posedge clk) + begin + if (rst == 1'b1) dout <= INIT; + else dout <= din; + end +endmodule +``` +/// + +/// tab | Generated VHDL +```vhdl +-- A generic register +library ieee; +use ieee.std_logic_1164.all; +use ieee.numeric_std.all; +use work.dfhdl_pkg.all; + +entity InitReg is +generic ( + INIT : std_logic_vector(7 downto 0) := x"00"; + LEN : integer := 8 +); +port ( + clk : in std_logic; + rst : in std_logic; + -- data input + din : in std_logic_vector(LEN - 1 downto 0); + -- data output + dout : out std_logic_vector(LEN - 1 downto 0) +); +end InitReg; + +architecture InitReg_arch of InitReg is +begin + process (clk) + begin + if rising_edge(clk) then + if rst = '1' then dout <= INIT; + else dout <= din; + end if; + end if; + end process; +end InitReg_arch; +``` +/// + +Points worth noting in this pattern: + +- Declaring `LEN` as a separate design parameter next to `INIT` is not possible in DFHDL, and it is also not needed: the width of a `Bits[Int] <> CONST` parameter travels with the applied argument itself, so `LEN` always agrees with `INIT` by construction. Two separate parameters would have to be kept consistent manually at every instantiation, a mismatch the derived form rules out entirely. +- The width of `INIT` is set by the APPLIED argument (here the default `h"00"`, so 8). In the generated code that width is fixed in the module/entity declaration, while the VALUE of `INIT` remains overridable at that width. Applying an argument of a different width elaborates a design with the corresponding widths. +- `val LEN = INIT.length` is a named constant, so the generated code declares it by name (a Verilog `localparam`, a VHDL `generic` with a consistent default) and references it wherever it is used, rather than inlining its expression. +- The same recipe serves any derived parameter, for example a `clog2`-computed address width: declare the primary parameters, and compute the derived constants in the body. See also the [width derivation idiom][width-length-ops] (`UInt.until(DEPTH).width`). + #### Design Parameter Access Rules Without any [Scala access modifier](https://docs.scala-lang.org/scala3/book/domain-modeling-oop.html#access-modifiers){target="_blank"}, a Scala class parameter access is declared as `#!scala private val`. This default access leads to an error if that parameter affects the type of non-private class member (e.g., a `width` parameter affecting the bits width of a port). To resolve this error, the parameter can be declared as public `#!scala val`, as `#!scala protected val`, or even `#!scala private[scope] val` with a scope [access qualifier](https://www.scala-lang.org/files/archive/spec/3.4/05-classes-and-objects.html#private){target="_blank"}. From 520fd8db705a58d691f2acc950e50c7200051b76 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Sat, 8 Aug 2026 22:43:42 +0300 Subject: [PATCH 19/25] ir+core+stages: value `.width`/`.length` become width/length query FUNCs printed natively per backend A value-receiver width/length query now elaborates to a `DFVal.Func` with the new `Op.width`/`Op.length` instead of materializing a constant, so the generated code keeps the value-to-width relation: SystemVerilog spells `$bits(x)`/`$size(x)`, VHDL spells `x'length`/`bitWidth(x)` (constant arguments only: a design-level constant becomes a generic whose default cannot name a port), and the pre-SV Verilog dialects inline the width parameter expression (shared `AbstractValPrinter.csInlinedWidth`). Type-receiver queries still materialize width constants. - `Func` constData reads the argument's TYPE, so the query is constant over a non-constant argument; `calcFuncData` gets the matching type-driven case. - `IntExprCalc` linearizes the queries through the argument type's width parameters, with product-base equivalence so `vec.width` matches `W * N`; `linearOfTypeWidth` moved into `Calc` (mode-consistent; its `widthIntOpt` shortcut folded top-design parameters through their defaults). - `DropStructsVecs` folds length-over-vector into the element-count parameter value before flattening destroys it; width needs no fold. - DFacsimile folds the queries to per-instance resolved constants (`paramDependent` walks the argument's type refs for them). - Docs: the InitReg inter-dependent-parameters outputs now show `$bits(INIT)`/`INIT'length` (verilator/ghdl lint-verified); the type-system query section documents the native spellings. Co-Authored-By: Claude Fable 5 --- .claude/commands/new-stage.md | 28 +++++ .../scala/dfhdl/compiler/ir/DFMember.scala | 25 +++- .../scala/dfhdl/compiler/ir/DataOps.scala | 9 ++ .../scala/dfhdl/compiler/ir/IntExprCalc.scala | 119 ++++++++++++++---- .../compiler/printing/DFValPrinter.scala | 24 ++++ .../compiler/stages/DropStructsVecs.scala | 28 +++++ .../stages/verilog/VerilogValPrinter.scala | 14 +++ .../compiler/stages/vhdl/VHDLValPrinter.scala | 38 ++++-- .../src/main/scala/dfhdl/sim/DFacsimile.scala | 23 +++- .../StagesSpec/DropStructsVecsSpec.scala | 42 +++++++ .../StagesSpec/PrintCodeStringSpec.scala | 80 ++++++++++++ .../scala/StagesSpec/PrintVHDLCodeSpec.scala | 60 +++++++++ .../StagesSpec/PrintVerilogCodeSpec.scala | 91 ++++++++++++++ .../scala/dfhdl/sim/WidthQuerySimSpec.scala | 34 +++++ core/src/main/scala/dfhdl/core/DFType.scala | 3 - core/src/main/scala/dfhdl/core/DFVal.scala | 15 ++- core/src/main/scala/dfhdl/core/DFVector.scala | 5 +- .../CoreSpec/WidthLengthQueriesSpec.scala | 52 -------- docs/user-guide/design-hierarchy/index.md | 6 +- docs/user-guide/type-system/index.md | 2 +- 20 files changed, 604 insertions(+), 94 deletions(-) create mode 100644 compiler/stages/src/test/scala/dfhdl/sim/WidthQuerySimSpec.scala delete mode 100644 core/src/test/scala/CoreSpec/WidthLengthQueriesSpec.scala diff --git a/.claude/commands/new-stage.md b/.claude/commands/new-stage.md index c3b8bde62..66e39314e 100644 --- a/.claude/commands/new-stage.md +++ b/.claude/commands/new-stage.md @@ -1602,6 +1602,34 @@ Mirror `plantClonedMembers`'s per-member mechanics when a custom per-ref remap i `dfc.mutableDB.newRefFor(cloned.ownerRef, dfc.owner.asIR)` → zip `m.getRefs` with `cloned.getRefs` and `newRefFor` each cloned ref to the (remapped) original target. +### Materializing a type's width parameter as a standalone member + +To replace a member with the VALUE behind an `IntParamRef` (e.g. folding a `width`/`length` +query into the parameter it queries — `DropStructsVecs.lengthFoldPatch` is the model), inside a +`MetaDesign(member, ReplaceWithLast(ChangeRefAndRemove))`: + +```scala +val lengthParam = + vecType.cellDimParamRefs.head.get.asInstanceOf[IntParam[Int]].cloneAnonValueAndDepsHere +lengthParam.toScalaIntOpt match + case Some(int) => dfhdl.core.DFConstInt32(int, named = true)(using dfc.setMeta(func.meta)) + case None => + dfhdl.core.DFVal.Alias.AsIs.ident(lengthParam.toDFConst(using dfc.anonymize))(using + dfc.setMeta(func.meta)) +``` + +Three load-bearing details: `cloneAnonValueAndDepsHere` first, because an anonymous width cone +is already read by the TYPE that carries it and an anonymous value may be read exactly once +(mistake 22); a literal must MINT a member (`DFConstInt32(int, named = true)`) and a named +target must be WRAPPED in an ident, because `ReplaceWithLast` needs a member to be created in +the MetaDesign (bare `toDFConst` on a pre-existing constant creates none); and the replacement +carries the original's meta via `dfc.setMeta(member.meta)` so a named binding keeps its name. + +Relatedly, a new `Func.Op` whose result is constant over a NON-constant argument (the +`width`/`length` type queries) must also be taught to `IntExprCalc` (linearization through the +argument TYPE's width params, product-base equivalence so `vec.width` matches `W * N`), or +every symbolic width-equivalence check against such an expression fails at elaboration. + ### Compile-time constant evaluation of values `dfVal.getConstDataThroughParams[Any]` returns `Some(data)` when the (possibly substituted) diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFMember.scala b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFMember.scala index 4500239b6..dc3ea53bd 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFMember.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DFMember.scala @@ -800,6 +800,24 @@ object DFVal: protected def protIsFullyAnonymous(using MemberGetSet): Boolean = args.forall(_.get.isFullyAnonymous) protected def protGetConstData(using MemberGetSet, ConstData.CachePolicy): ConstData[Any] = + op match + // width/length read the argument's TYPE, never its data, so the query is constant + // even when the argument value itself is not (a port, a variable) + case Func.Op.width | Func.Op.length => + val argType = this.args.head.get.dfType + val widthOpt = (op, argType) match + case (Func.Op.length, vec: DFVector) => vec.lengthIntOpt + case _ => argType.widthIntOpt + widthOpt match + case Some(i) => ConstData.KnownConst(Some(BigInt(i))) + // a parameter-dependent width: still a constant, resolved per instantiation + case None => ConstData.UnknownConst(this) + case _ => protGetConstDataFromArgs + end protGetConstData + private def protGetConstDataFromArgs(using + MemberGetSet, + ConstData.CachePolicy + ): ConstData[Any] = val args = this.args.map(_.get) val argConstData = args.map(_.getConstData[Any]) if (argConstData.exists(_ == ConstData.NotConst)) ConstData.NotConst @@ -817,7 +835,7 @@ object DFVal: val argData = argConstData.collect { case ConstData.KnownConst(d) => d } val argTypes = args.map(_.dfType) ConstData.KnownConst(calcFuncData(dfType, op, argTypes, argData)) - end protGetConstData + end protGetConstDataFromArgs protected def `prot_=~`(that: DFMember)(using MemberGetSet): Boolean = that match case that: Func => // `Op.Def` needs no specialization here: its `staticRef` is a STABLE identity key @@ -846,6 +864,11 @@ object DFVal: case unary_-, unary_~, unary_! case rising, falling case clog2, max, min, abs, sel + // type queries over the single argument: `width` is the total bit width of the + // argument's type, `length` its element count (a vector) or bit count (bits/uint/sint). + // Both read the argument's TYPE, never its data, so they are constant even over a + // non-constant argument (see `protGetConstData`). + case width, length // special-case of initFile construct for vectors of bits case InitFile(format: InitFileFormat, path: String) // A method (def-design) application: a call of a static function or an ED diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DataOps.scala b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DataOps.scala index 2eed4ebe9..4b69ddeff 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/ir/DataOps.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/ir/DataOps.scala @@ -128,6 +128,15 @@ def calcFuncData[OT <: DFType]( println(x) ??? ret.asInstanceOf[outType.Data] + else if (op == FuncOp.width || op == FuncOp.length) + // type queries: computed from the argument's TYPE; the argument's data is never consulted. + // `Func.protGetConstData` resolves these directly (policy-aware); this covers any other + // data-level evaluator that dispatches through `calcFuncData`. + val argType = argTypes.head + val ret: Option[BigInt] = (op, argType) match + case (FuncOp.length, vec: DFVector) => Some(BigInt(vec.lengthUNSAFE)) + case _ => Some(BigInt(argType.widthUNSAFE)) + ret.asInstanceOf[outType.Data] else outType match // bits operations are handled specially, because bubble is bit-accurate diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/ir/IntExprCalc.scala b/compiler/ir/src/main/scala/dfhdl/compiler/ir/IntExprCalc.scala index a096771ad..5bcb73147 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/ir/IntExprCalc.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/ir/IntExprCalc.scala @@ -106,9 +106,7 @@ object IntExprCalc: def isConst(l: Linear): Boolean = l.terms.isEmpty def linearOfVal(v: DFVal)(using MemberGetSet): Linear = calc.linear(v) def linearOfParamRef(ref: IntParamRef)(using MemberGetSet): Linear = - ref.getRef match - case Some(typeRef) => linearOfVal(typeRef.get) - case None => const(ref.getIntUNSAFE) + calc.linearOfParamRef(ref) def add(a: Linear, b: Linear)(using MemberGetSet): Linear = calc.add(a, b) def sub(a: Linear, b: Linear)(using MemberGetSet): Linear = calc.add(a, negate(b)) def negate(l: Linear): Linear = Linear(l.terms.map((c, b) => (-c, b)), -l.offset) @@ -125,19 +123,7 @@ object IntExprCalc: /** Total bit width of a type as a linear form, when expressible. */ def linearOfTypeWidth(t: DFType)(using MemberGetSet): Option[Linear] = - t.widthIntOpt match - case Some(w) => Some(const(w)) - case None => - t match - case DFBits(widthParamRef) => Some(linearOfParamRef(widthParamRef)) - case dec: DFDecimal => - Some(addConst(linearOfParamRef(dec.magnitudeWidthParamRef), dec.fractionWidth)) - case vec: DFVector => - vec.cellDimParamRefs.foldLeft(linearOfTypeWidth(vec.cellType)) { (accOpt, dim) => - accOpt.flatMap(mulOpt(_, linearOfParamRef(dim))) - } - case opaque: DFOpaque => linearOfTypeWidth(opaque.actualType) - case _ => None + calc.linearOfTypeWidth(t) /** Proves `e >= 0` for every valid parameter assignment. Each fact in `facts` is a linear form * known to be `>= 1` on the valid domain (slice widths: a slice of zero or negative width is @@ -179,19 +165,29 @@ object IntExprCalc: // linearized) and products get dedicated factor-multiset handling. private val commutativeOps = Set(FuncOp.max, FuncOp.min, FuncOp.&, FuncOp.|, FuncOp.^) + // The non-constant factor multiset of a product-like base: a `*` chain, or a + // width/length query whose queried width is a pure product of parameters. The constant + // factor is already carried by the term coefficient (see `linear`), so bases match by + // their non-constant factors only. + private def productFactorsOpt(f: DFVal.Func): Option[List[DFVal]] = f.op match + case FuncOp.`*` => Some(flattenProduct(f)._2) + case FuncOp.width | FuncOp.length => widthQueryFactors(f).filter(_._2.nonEmpty).map(_._2) + case _ => None + // Equivalence of opaque bases: same op/type Funcs with equivalent args // (each arg compared through its full linear form, so `clog2(2 * W)` // matches `clog2(W + W)`), or `=~` leaves after stripping. Commutative // ops compare their args as multisets, so `v1 * v2` matches `v2 * v1`. def baseEq(a: DFVal, b: DFVal): Boolean = (strip(a), strip(b)) match + // Product-like bases match by their non-constant factor multisets, in any order + // and across the two shapes, so `vec.width` matches `W * N`. + case (af: DFVal.Func, bf: DFVal.Func) + if af.dfType =~ bf.dfType && + productFactorsOpt(af).nonEmpty && productFactorsOpt(bf).nonEmpty => + multisetEquiv(productFactorsOpt(af).get, productFactorsOpt(bf).get) case (af: DFVal.Func, bf: DFVal.Func) if af.op == bf.op && af.dfType =~ bf.dfType => - if (af.op == FuncOp.`*`) - // Product bases: the constant factor is already carried by the - // term coefficient (see `linear`), so only the non-constant - // factor multisets must match, in any order. - multisetEquiv(flattenProduct(af)._2, flattenProduct(bf)._2) - else if (commutativeOps.contains(af.op)) + if (commutativeOps.contains(af.op)) af.args.length == bf.args.length && multisetEquiv(af.args.map(_.get), bf.args.map(_.get)) else @@ -211,7 +207,9 @@ object IntExprCalc: } // Splits a product into its overall constant factor and the list of - // non-constant factors, flattening nested products. A non-product part + // non-constant factors, flattening nested products. A width/length query over a + // pure-product width expands into the width parameters' factors, so `vec.width * 2` + // and `W * N * 2` normalize identically. A non-product part // whose linear form is a constant folds into the constant factor, and one // that is a single scaled term contributes its base with the scale folded // in, so `(W + W) * v` and `2 * W * v` normalize identically. @@ -221,12 +219,45 @@ object IntExprCalc: val (argC, argFs) = flattenProduct(arg) (c * argC, fs ++ argFs) } + case sv @ DFVal.Func(op = FuncOp.width | FuncOp.length) if widthQueryFactors(sv).nonEmpty => + widthQueryFactors(sv).get case sv => linear(sv) match case Linear(Nil, k) => (k, Nil) case Linear(List((k, b)), 0) => (k, List(b)) case _ => (1, List(sv)) + // The multiplicative decomposition (constant factor, non-constant factor values) of a + // width/length type query whose queried width is a pure product of parameters, e.g. a + // vector of parametric cells: `vec.width` then normalizes exactly like `W * N`. `None` + // for a width with additive structure (fixed-point, structs) or no decomposition at all. + private def widthQueryFactors(v: DFVal): Option[(Int, List[DFVal])] = + def paramRefFactors(ref: IntParamRef): (Int, List[DFVal]) = + ref.getRef match + case Some(typeRef) => flattenProduct(typeRef.get) + case None => (ref.getIntUNSAFE, Nil) + def typeFactors(t: DFType): Option[(Int, List[DFVal])] = t match + case _ if t.getRefs.isEmpty => t.widthIntOpt.map((_, Nil)) + case DFBits(widthParamRef) => Some(paramRefFactors(widthParamRef)) + case DFXInt(_, widthParamRef, _) => Some(paramRefFactors(widthParamRef)) + case vec: DFVector => + vec.cellDimParamRefs.foldLeft(typeFactors(vec.cellType)) { (accOpt, dim) => + accOpt.map { (c, fs) => + val (dimC, dimFs) = paramRefFactors(dim) + (c * dimC, fs ++ dimFs) + } + } + case opaque: DFOpaque => typeFactors(opaque.actualType) + case _ => None + v match + case DFVal.Func(op = op @ (FuncOp.width | FuncOp.length), args = List(argRef)) => + val argType = argRef.get.dfType + (op, argType) match + case (FuncOp.length, vec: DFVector) => Some(paramRefFactors(vec.cellDimParamRefs.head)) + case _ => typeFactors(argType) + case _ => None + end widthQueryFactors + // Merge coefficients of equivalent bases and drop cancelled-out terms. private def canonical(terms: List[(Int, DFVal)]): List[(Int, DFVal)] = val merged = mutable.ListBuffer.empty[(Int, DFVal)] @@ -250,6 +281,30 @@ object IntExprCalc: def sub(l: Linear, r: Linear): Linear = add(l, negate(r)) + def linearOfParamRef(ref: IntParamRef): Linear = + ref.getRef match + case Some(typeRef) => linear(typeRef.get) + case None => Linear(Nil, ref.getIntUNSAFE) + + /** Total bit width of a type as a linear form (in this calc's own parameter-resolution mode), + * when expressible. A type carrying width-parameter refs decomposes through its structure so + * the refs resolve in this calc's mode; the `widthIntOpt` shortcut applies only to ref-free + * types, since its internal resolution (the default `Always` policy) folds a TOP design's + * parameters through their default values, which no calc mode does. + */ + def linearOfTypeWidth(t: DFType): Option[Linear] = + t match + case _ if t.getRefs.isEmpty => t.widthIntOpt.map(Linear(Nil, _)) + case DFBits(widthParamRef) => Some(linearOfParamRef(widthParamRef)) + case dec: DFDecimal => + Some(DataCalc.addConst(linearOfParamRef(dec.magnitudeWidthParamRef), dec.fractionWidth)) + case vec: DFVector => + vec.cellDimParamRefs.foldLeft(linearOfTypeWidth(vec.cellType)) { (accOpt, dim) => + accOpt.flatMap(DataCalc.mulOpt(_, linearOfParamRef(dim))) + } + case opaque: DFOpaque => linearOfTypeWidth(opaque.actualType) + case _ => t.widthIntOpt.map(Linear(Nil, _)) + def equivalent(a: DFVal, b: DFVal): Boolean = constDiff(a, b).contains(0) @@ -330,6 +385,24 @@ object IntExprCalc: val offsets = linears.collect { case Linear(Nil, k) => k } Linear(Nil, if (op == FuncOp.max) offsets.max else offsets.min) else Linear(List((1, sv)), 0) + // a width/length type query decomposes through the argument type's width parameters, + // so `Bits(W * 3).width` linearizes as `3 * W` and a literal width as its constant; a + // pure-product width over several parameters (a vector of parametric cells) keeps an + // opaque base whose product factors match the equivalent `*` expression (see `baseEq`) + case sv @ DFVal.Func(op = op @ (FuncOp.width | FuncOp.length), args = List(argRef)) => + val argType = argRef.get.dfType + val additiveOpt = (op, argType) match + case (FuncOp.length, vec: DFVector) => Some(linearOfParamRef(vec.cellDimParamRefs.head)) + case _ => linearOfTypeWidth(argType) + additiveOpt.orElse { + widthQueryFactors(sv).map { (c, fs) => + fs match + case Nil => Linear(Nil, c) + case f :: Nil => scale(linear(f), c) + case _ if c == 0 => Linear(Nil, 0) + case _ => Linear(List((c, sv)), 0) + } + }.getOrElse(Linear(List((1, sv)), 0)) case sv => Linear(List((1, sv)), 0) end linear end Calc diff --git a/compiler/ir/src/main/scala/dfhdl/compiler/printing/DFValPrinter.scala b/compiler/ir/src/main/scala/dfhdl/compiler/printing/DFValPrinter.scala index 10ef5c2d5..8a73f2a41 100644 --- a/compiler/ir/src/main/scala/dfhdl/compiler/printing/DFValPrinter.scala +++ b/compiler/ir/src/main/scala/dfhdl/compiler/printing/DFValPrinter.scala @@ -95,6 +95,27 @@ trait AbstractValPrinter extends AbstractPrinter: ref.get match case DFVal.Const(dfType = _: DFDecimal, data = Some(i)) => i.toString case _ => ref.refCodeString + + /** The total-width expression of a type, spelled the way its own declaration spells it (a literal + * stays a literal, a parametric width keeps its parameter expression, rendered through the + * concrete printer). Used by the backends where a `width`/`length` query cannot be spelled + * natively: the pre-SystemVerilog dialects, and a VHDL query over a non-constant argument (which + * may print into a generic default that cannot name it). + */ + final def csInlinedWidth(dfType: DFType): String = dfType match + case DFBool | DFBit => "1" + case dt: DFBits => dt.widthParamRef.refCodeString + case dt: DFDecimal => + if (dt.fractionWidth == 0) dt.magnitudeWidthParamRef.refCodeString + else s"${dt.magnitudeWidthParamRef.refCodeString.applyBrackets()} + ${dt.fractionWidth}" + case dt: DFEnum => dt.widthParam.toString + case dt: DFVector => + s"${dt.cellDimParamRefs.head.refCodeString.applyBrackets()} * ${csInlinedWidth(dt.cellType).applyBrackets()}" + case dt: DFOpaque => csInlinedWidth(dt.actualType) + case dt => + dt.widthIntOpt.map(_.toString).getOrElse( + throw new IllegalArgumentException(s"Unable to inline the width of type: $dt") + ) def csConditionalExprRel(csExp: String, ch: DFConditional.Header): String def csDFMemberName(named: DFMember.Named): String = named.getName @@ -286,7 +307,10 @@ protected trait DFValPrinter extends AbstractValPrinter: case Func.Op.abs => if (typeCS) s"Abs[$csArg]" else s"abs($csArg)" + // the postfix fallback also serves the width/length queries (`x.width`, `x.length`), + // which have no type-level twin (the receiver is a term), so typeCS prints the same case _ => s"${csArg.applyBrackets()}.${opStr}" + end match // multiarg func case args => val csArgs = args.map(_.refCodeString) diff --git a/compiler/stages/src/main/scala/dfhdl/compiler/stages/DropStructsVecs.scala b/compiler/stages/src/main/scala/dfhdl/compiler/stages/DropStructsVecs.scala index 6d89c33b6..0b7f87c2e 100644 --- a/compiler/stages/src/main/scala/dfhdl/compiler/stages/DropStructsVecs.scala +++ b/compiler/stages/src/main/scala/dfhdl/compiler/stages/DropStructsVecs.scala @@ -147,6 +147,32 @@ case object DropStructsVecs extends GlobalStage: dsn.patch end stage1Patch + // A `length` query over a vector is folded into the vector's element-count parameter + // value: the drop retargets the query's argument to the flattened Bits replacement, + // whose own width/length is the vector's TOTAL width, so the element count must be + // materialized while the vector type still exists. (A `width` query needs no fold: + // flattening preserves the total width, and the pre-SV dialects inline it at print.) + def lengthFoldPatch(func: DFVal.Func, vecType: DFVector)(using + MemberGetSet + ): (DFMember, Patch) = + val dsn = new MetaDesign( + func, + Patch.Add.Config.ReplaceWithLast(Patch.Replace.Config.ChangeRefAndRemove) + ): + // a fresh clone for an anonymous element-count cone: the original stays referenced + // by the (block-ram) vector type, and an anonymous value may be read exactly once + val lengthParam = vecType.cellDimParamRefs.head.get.cloneAnonValueAndDepsHere + lengthParam.toScalaIntOpt match + case Some(int) => + dfhdl.core.DFConstInt32(int, named = true)(using dfc.setMeta(func.meta)) + case None => + // parametric element count: rebind the referenced value under the query's meta + dfhdl.core.DFVal.Alias.AsIs.ident(lengthParam.toDFConst(using dfc.anonymize))(using + dfc.setMeta(func.meta) + ) + dsn.patch + end lengthFoldPatch + val stage1Subs: ListMap[StaticRef, DB] = ListMap.from( designDB.subDBs.iterator.map { (key, sub) => val patchList = sub.atGetSet { @@ -156,6 +182,8 @@ case object DropStructsVecs extends GlobalStage: // every sub-DB that holds it (see `globalStage1Patch`) if (dfVal.isGlobal) globalStage1Patch.getOrElseUpdate(dfVal, stage1Patch(dfVal)) else stage1Patch(dfVal) + case func @ DFVal.Func(op = FuncOp.length, args = List(DFRef(DFVector.Val(vecType)))) => + lengthFoldPatch(func, vecType) } } key -> sub.patch(patchList) diff --git a/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogValPrinter.scala b/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogValPrinter.scala index fdbd021b8..7507e3274 100644 --- a/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogValPrinter.scala +++ b/compiler/stages/src/main/scala/dfhdl/compiler/stages/verilog/VerilogValPrinter.scala @@ -232,6 +232,20 @@ protected trait VerilogValPrinter extends AbstractValPrinter: case VerilogDialect.v95 | VerilogDialect.v2001 => "" case _ => "$" s"${internalLog}clog2($argStr)" + case Func.Op.width | Func.Op.length => + val supportsQuerySyntax = printer.dialect match + case VerilogDialect.v95 | VerilogDialect.v2001 => false + case _ => true + (dfVal.op, arg.get.dfType) match + // a vector's element count (`DropStructsVecs` folds these for the pre-SV + // dialects, so only a SystemVerilog `$size` spelling is ever needed) + case (Func.Op.length, _: DFVector) => + if (supportsQuerySyntax) s"$$size($argStrB)" else printer.unsupported + case (_, argType) => + if (supportsQuerySyntax) s"$$bits($argStrB)" + // pre-SystemVerilog dialects have no width query; inline the width + // parameter expression, which is what the type declaration itself prints + else csInlinedWidth(argType) case _ => printer.unsupported end match // multiarg func diff --git a/compiler/stages/src/main/scala/dfhdl/compiler/stages/vhdl/VHDLValPrinter.scala b/compiler/stages/src/main/scala/dfhdl/compiler/stages/vhdl/VHDLValPrinter.scala index cedb7d299..a525c8434 100644 --- a/compiler/stages/src/main/scala/dfhdl/compiler/stages/vhdl/VHDLValPrinter.scala +++ b/compiler/stages/src/main/scala/dfhdl/compiler/stages/vhdl/VHDLValPrinter.scala @@ -118,13 +118,36 @@ protected trait VHDLValPrinter extends AbstractValPrinter: dfVal.dfType match case dfType: DFEnum => s"toggle($argStrB)" case _ => s"not $argStrB" - case Func.Op.unary_~ => s"not $argStrB" - case Func.Op.& => s"and reduce $argStrB" - case Func.Op.| => s"or reduce $argStrB" - case Func.Op.^ => s"xor reduce $argStrB" - case Func.Op.abs => s"abs($argStr)" - case Func.Op.clog2 => s"clog2($argStr)" - case _ => printer.unsupported + case Func.Op.unary_~ => s"not $argStrB" + case Func.Op.& => s"and reduce $argStrB" + case Func.Op.| => s"or reduce $argStrB" + case Func.Op.^ => s"xor reduce $argStrB" + case Func.Op.abs => s"abs($argStr)" + case Func.Op.clog2 => s"clog2($argStr)" + case Func.Op.width | Func.Op.length => + // Only a CONSTANT argument (a generic or a constant) may be named from every + // context this query can print into -- in particular a design-level constant + // becomes a GENERIC whose default cannot reference a port. A non-constant + // argument (a port, a variable) inlines the width parameter expression + // instead, which is what the argument's own subtype indication prints. + if (arg.get.isConst) + (dfVal.op, arg.get.dfType) match + // every `length` receiver and every flat-array rendering (std_logic_vector, + // unsigned, signed, fixed-point) spells the query with 'length (a + // fixed-point array spans its fraction bits, so its 'length is the total + // width too) + case (Func.Op.length, _) => s"$argStrB'length" + case (_, dt: DFDecimal) if !dt.isDFInt32 => s"$argStrB'length" + case (_, _: DFBits) => s"$argStrB'length" + // every other rendering (integer, std_logic, boolean, enum, record, vector + // array, opaque) is covered by the `bitWidth` overload family the printer + // already emits (dfhdl_pkg + the per-named-type support functions) + case _ => s"bitWidth($argStr)" + else + (dfVal.op, arg.get.dfType) match + case (Func.Op.length, vec: DFVector) => vec.cellDimParamRefs.head.refCodeString + case (_, argType) => csInlinedWidth(argType) + case _ => printer.unsupported end match // multiarg func case args => @@ -167,6 +190,7 @@ protected trait VHDLValPrinter extends AbstractValPrinter: .mkString(s" ${commonOpStr} ") end match end csDFValFuncExpr + def csFixedCond(condRef: DFRef.TwoWay[DFVal, ?]): String = val requiresBoolConv = if (printer.inVHDL93) diff --git a/compiler/stages/src/main/scala/dfhdl/sim/DFacsimile.scala b/compiler/stages/src/main/scala/dfhdl/sim/DFacsimile.scala index 8593a1b97..89c844b78 100644 --- a/compiler/stages/src/main/scala/dfhdl/sim/DFacsimile.scala +++ b/compiler/stages/src/main/scala/dfhdl/sim/DFacsimile.scala @@ -23,7 +23,8 @@ enum SimTier derives CanEqual: * at the alias position (wires sampled inside a conditional branch are rejected) * - funcs: n-ary `+`/`-`/`&`/`|`/`^` (width-extending variants included), `*`/`/`/`%` (up to * 64-bit results), `++`, comparisons, `unary_-`/`unary_~`/`unary_!`, `<<`/`>>` by constant or - * dynamic amount, `ror`/`rol`, `reverse`/`repeat`, `max`/`min`/`abs`, `sel` + * dynamic amount, `ror`/`rol`, `reverse`/`repeat`, `max`/`min`/`abs`, `sel`, and the + * `width`/`length` type queries (folded to the per-instance resolved width/element count) * - `AsIs` casts (sign-extending for signed sources), bit-select/range on Bits (constant or * dynamic index), field select on structs, vector indexing (constant or dynamic index; * constant-vector indexing becomes per-lane ROMs) @@ -919,7 +920,11 @@ private final class Builder(rawDB: DB): val b = v match case _: DFVal.DesignParam => true case _: DFVal.Dcl => false - case _ => + // a width/length query reads its argument's TYPE: dependence comes through the + // type's width params, while the value-ref walk would stop at the argument Dcl + case f: DFVal.Func if f.op == DFVal.Func.Op.width || f.op == DFVal.Func.Op.length => + f.args.head.get.dfType.getRefs.exists(r => paramDependent(r.get)) + case _ => v.getRefs.exists { r => r.get match case dv: DFVal => paramDependent(dv) @@ -1064,6 +1069,13 @@ private final class Builder(rawDB: DB): wide.mux(wide.ltNode(a, wide.zero(resW), signed = true), wide.neg(a), a) else a case FO.sel => wide.mux(rd(args.head).lanes(0), rd(args(1)), rd(args(2))) + // type queries fold to a constant of the argument's per-instance width/length; + // the argument itself is never read + case FO.width | FO.length => + val value = (f.op, args.head.dfType) match + case (FO.length, vt: DFVector) => vecLengthOf(vt, f) + case _ => widthOf(args.head) + wide.const(resW, BigInt(value).toBitVector(resW)) // edge detection over a 1-cycle sampling register; the init biases match the RT lowering // (no spurious edge at time zero: rising samples 1, falling samples 0) case FO.rising => @@ -2982,5 +2994,12 @@ private final class Builder(rawDB: DB): case o: DFOpaque => o.actualType.widthIntOpt.orElse(widthThroughParams(o.actualType)) case _ => None end widthThroughParams + + /** A vector's element count, resolved like [[widthOfType]] (through design params). */ + private def vecLengthOf(t: DFVector, where: Any): Int = + t.lengthIntOpt.orElse { + given ConstData.CachePolicy = ConstData.CachePolicy.NoCache + t.cellDimParamRefs.head.getIntConstData.toOption + }.getOrElse(unsupported("unresolvable (param-dependent) vector length", where)) end Scope end Builder diff --git a/compiler/stages/src/test/scala/StagesSpec/DropStructsVecsSpec.scala b/compiler/stages/src/test/scala/StagesSpec/DropStructsVecsSpec.scala index 84d53b01e..6b473b1d5 100644 --- a/compiler/stages/src/test/scala/StagesSpec/DropStructsVecsSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/DropStructsVecsSpec.scala @@ -307,4 +307,46 @@ class DropStructsVecsSpec extends StageSpec(stageCreatesUnrefAnons = true): |""".stripMargin ) } + + test("Vector length query folds before the drop") { + given options.CompilerOptions.Backend = _.verilog.v2001 + // the drop flattens `vec` to Bits, whose own length is the TOTAL width, so a `length` + // query over it is folded into the element-count parameter value while the vector type + // still exists: a parametric count rebinds the parameter (`LEN = N`), a literal count + // materializes the literal (`FOUR = 4`). A `width` query needs no fold (flattening + // preserves the total width), so it stays a query over the flattened value. + class Top(val W: Int <> CONST = 4, val N: Int <> CONST = 3) extends RTDesign: + val vec = Bits(W) X N <> IN + val lit = Bits(8) X 4 <> IN + val LEN = vec.length + val FOUR = lit.length + val WID = vec.width + val cnt = UInt(LEN) <> OUT + val idx = UInt(FOUR) <> OUT + val flat = Bits(WID) <> OUT + cnt := 0 + idx := 0 + flat := vec.bits + val top = (new Top).dropStructsVecs + assertCodeString( + top, + """|class Top( + | val W: Int <> CONST = 4, + | val N: Int <> CONST = 3 + |) extends RTDesign: + | val vec = Bits(W * N) <> IN + | val lit = Bits(32) <> IN + | val LEN: Int <> CONST = N + | val FOUR: Int <> CONST = 4 + | val WID: Int <> CONST = vec.width + | val cnt = UInt(LEN) <> OUT + | val idx = UInt(FOUR) <> OUT + | val flat = Bits(WID) <> OUT + | cnt := d"1'0".resize(LEN) + | idx := d"1'0".resize(FOUR) + | flat := vec.resize(W * N) + |end Top + |""".stripMargin + ) + } end DropStructsVecsSpec diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala index 5f6796ea1..48c246289 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintCodeStringSpec.scala @@ -3071,4 +3071,84 @@ class PrintCodeStringSpec extends StageSpec(stageCreatesUnrefAnons = true): |""".stripMargin ) } + // `.width` applied on a DFType is the `$clog2` width-derivation idiom (issue #457): + // construct the type with `.until`/`.to` and recover the width the constructor computed, + // materialized as a width constant (symbolic for a parametric type, `clog2(N)` below). + // `.length` equals `.width` for the bit-accurate scalars and counts ELEMENTS for vectors. + test("Type-receiver width/length queries materialize width constants") { + class QueryTypes extends EDDesign: + val N: Int <> CONST = 854 + val ADDR_WIDTH = UInt.until(N).width + val a = UInt(ADDR_WIDTH) <> OUT + val W8 = Bits(8).width + val LB = Bits(8).length + val LU = UInt(8).length + val LS = SInt(8).length + val TVW = (Bits(8) X 4).width + val TVL = (Bits(8) X 4).length + val o = UInt(8) <> OUT + val OL = o.length + o <> OL.bits.uint.resize(8) + a <> 0 + end QueryTypes + assertCodeString( + QueryTypes(), + """|class QueryTypes extends EDDesign: + | val N: Int <> CONST = 854 + | val ADDR_WIDTH: Int <> CONST = clog2(N) + | val a = UInt(ADDR_WIDTH) <> OUT + | val W8: Int <> CONST = 8 + | val LB: Int <> CONST = 8 + | val LU: Int <> CONST = 8 + | val LS: Int <> CONST = 8 + | val TVW: Int <> CONST = 32 + | val TVL: Int <> CONST = 4 + | val o = UInt(8) <> OUT + | val OL: Int <> CONST = o.length + | o <> OL.bits.uint.resize(8) + | a <> d"1'0".resize(ADDR_WIDTH) + |end QueryTypes + |""".stripMargin + ) + } + // a VALUE-receiver query constructs a `width`/`length` FUNC over the value, so the + // backends spell the query natively (`$bits(x)`/`$size(x)` in SystemVerilog, + // `x'length`/`bitWidth(x)` in VHDL) and the generated code keeps the value-to-width + // relation instead of a baked number + test("Value-receiver width/length queries construct query FUNCs") { + class QueryVals( + val W: Int <> CONST = 4, + val N: Int <> CONST = 3, + val INIT: Bits[Int] <> CONST = h"00" + ) extends EDDesign: + val IL = INIT.length + val vec = Bits(W) X N <> IN + val LEN = vec.length + val WID = vec.width + val din = Bits(W) <> IN + val DW = din.width + val flat = Bits(WID) <> OUT + // `vec.width` and the argument's own `W * N` width compare equal symbolically + // (the query linearizes through the vector type's width parameters) + flat <> vec.bits + end QueryVals + assertCodeString( + QueryVals(), + """|class QueryVals( + | val W: Int <> CONST = 4, + | val N: Int <> CONST = 3, + | val INIT: Bits[8] <> CONST = h"00" + |) extends EDDesign: + | val IL: Int <> CONST = INIT.length + | val vec = Bits(W) X N <> IN + | val LEN: Int <> CONST = vec.length + | val WID: Int <> CONST = vec.width + | val din = Bits(W) <> IN + | val DW: Int <> CONST = din.width + | val flat = Bits(WID) <> OUT + | flat <> vec.bits + |end QueryVals + |""".stripMargin + ) + } end PrintCodeStringSpec diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala index 4e56ceb02..3b0c1007e 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala @@ -3538,4 +3538,64 @@ class PrintVHDLCodeSpec extends StageSpec: |""".stripMargin ) } + + // width/length queries over a CONSTANT argument print natively (`'length` for arrays, + // `bitWidth(...)` otherwise), so a design-level constant binding one becomes a generic + // whose default keeps the value-to-width relation. Over a NON-constant argument (a + // port), the width parameter expression is inlined instead: the query may print into a + // generic default, where naming a port is illegal. + test("width/length query emission") { + class WidthQuery( + val W: Int <> CONST = 4, + val N: Int <> CONST = 3, + val INIT: Bits[Int] <> CONST = h"00" + ) extends EDDesign: + val LI = INIT.length + val vec = Bits(W) X N <> IN + val LEN = vec.length + val WID = vec.width + val din = Bits(LI) <> IN + val dout = Bits(LI) <> OUT + val flat = Bits(WID) <> OUT + val cnt = UInt(LEN) <> OUT + dout <> din + flat <> vec.bits + cnt <> 0 + end WidthQuery + val top = WidthQuery().getCompiledCodeString + assertNoDiff( + top, + """|library ieee; + |use ieee.std_logic_1164.all; + |use ieee.numeric_std.all; + |use work.dfhdl_pkg.all; + |use work.WidthQuery_pkg.all; + | + |entity WidthQuery is + |generic ( + | W : integer := 4; + | N : integer := 3; + | INIT : std_logic_vector(7 downto 0) := x"00"; + | LI : integer := INIT'length; + | LEN : integer := N; + | WID : integer := N * W + |); + |port ( + | vec : in t_arrX1_std_logic_vector(0 to N - 1)(W - 1 downto 0); + | din : in std_logic_vector(LI - 1 downto 0); + | dout : out std_logic_vector(LI - 1 downto 0); + | flat : out std_logic_vector(WID - 1 downto 0); + | cnt : out unsigned(LEN - 1 downto 0) + |); + |end WidthQuery; + | + |architecture WidthQuery_arch of WidthQuery is + |begin + | dout <= din; + | flat <= to_slv(vec); + | cnt <= resize(1d"0", LEN); + |end WidthQuery_arch; + |""".stripMargin + ) + } end PrintVHDLCodeSpec diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala index 4317f809b..bede97f59 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala @@ -3351,4 +3351,95 @@ class PrintVerilogCodeSpec extends StageSpec: |""".stripMargin ) } + + // width/length queries print natively in SystemVerilog: `$bits` for the total width, + // `$size` for a vector's element count; a named query binding becomes a localparam + // over the query, keeping the value-to-width relation in the generated code + test("width/length query emission") { + class WidthQuery( + val W: Int <> CONST = 4, + val N: Int <> CONST = 3, + val INIT: Bits[Int] <> CONST = h"00" + ) extends EDDesign: + val LI = INIT.length + val vec = Bits(W) X N <> IN + val LEN = vec.length + val WID = vec.width + val din = Bits(LI) <> IN + val dout = Bits(LI) <> OUT + val flat = Bits(WID) <> OUT + val cnt = UInt(LEN) <> OUT + dout <> din + flat <> vec.bits + cnt <> 0 + end WidthQuery + val top = WidthQuery().getCompiledCodeString + assertNoDiff( + top, + """|`default_nettype none + |`timescale 1ns/1ps + | + |module WidthQuery#( + | parameter int W = 4, + | parameter int N = 3, + | parameter logic [7:0] INIT = 8'h00 + |)( + | input wire logic [W - 1:0] vec [0:N - 1], + | input wire logic [LI - 1:0] din, + | output logic [LI - 1:0] dout, + | output logic [WID - 1:0] flat, + | output logic [LEN - 1:0] cnt + |); + | `include "dfhdl_defs.svh" + | localparam int LI = $bits(INIT); + | localparam int LEN = $size(vec); + | localparam int WID = $bits(vec); + | assign dout = din; + | assign flat = {vec}; + | assign cnt = LEN'(1'd0); + |endmodule + |""".stripMargin + ) + } + + // the pre-SystemVerilog dialects have no width query syntax; the width parameter + // expression is inlined instead, and a vector's length query is folded by + // `DropStructsVecs` into the element-count parameter before the vector is flattened + test("width/length query emission under v2001") { + given options.CompilerOptions.Backend = _.verilog.v2001 + class WidthQueryOld( + val W: Int <> CONST = 4, + val N: Int <> CONST = 3 + ) extends EDDesign: + val vec = Bits(W) X N <> IN + val LEN = vec.length + val WID = vec.width + val flat = Bits(WID) <> OUT + val cnt = UInt(LEN) <> OUT + flat <> vec.bits + cnt <> 0 + end WidthQueryOld + val top = WidthQueryOld().getCompiledCodeString + assertNoDiff( + top, + """|`default_nettype none + |`timescale 1ns/1ps + | + |module WidthQueryOld#( + | parameter integer W = 4, + | parameter integer N = 3 + |)( + | input wire [(W * N) - 1:0] vec, + | output wire [WID - 1:0] flat, + | output wire [LEN - 1:0] cnt + |); + | `include "dfhdl_defs.vh" + | parameter integer LEN = N; + | parameter integer WID = W * N; + | assign flat = `EXTEND_U(vec, W * N, W * N); + | assign cnt = `EXTEND_U(1'd0, 1, LEN); + |endmodule + |""".stripMargin + ) + } end PrintVerilogCodeSpec diff --git a/compiler/stages/src/test/scala/dfhdl/sim/WidthQuerySimSpec.scala b/compiler/stages/src/test/scala/dfhdl/sim/WidthQuerySimSpec.scala new file mode 100644 index 000000000..9aa069bee --- /dev/null +++ b/compiler/stages/src/test/scala/dfhdl/sim/WidthQuerySimSpec.scala @@ -0,0 +1,34 @@ +package dfhdl.sim +import dfhdl.* + +class WidthQuerySub(val W: Int <> CONST = 8) extends DFDesign: + val din = Bits(W) <> IN + val w = Int <> OUT + w := din.width + +class WidthQueryDut( + val W: Int <> CONST = 8, + val N: Int <> CONST = 5 +) extends DFDesign: + val vec = Bits(W) X N <> IN + val len = Int <> OUT + val wid = Int <> OUT + val subw = Int <> OUT + val sub = WidthQuerySub(12) + len := vec.length + wid := vec.width + sub.din <> all(0) + subw := sub.w + +/** `width`/`length` query FUNCs fold to constants of the argument's resolved type width on both + * kernel tiers, including a query over a sub-design's parametric port (resolved to the + * instance-applied width, not the declaration default). + */ +class WidthQuerySimSpec extends SimSpec: + bothTiers("width/length queries fold to resolved constants"): tier => + (new WidthQueryDut).simulation { dut => + assertEquals(dut.len.peek, 5) + assertEquals(dut.wid.peek, 40) + assertEquals(dut.subw.peek, 12) + }.withTier(tier).run() +end WidthQuerySimSpec diff --git a/core/src/main/scala/dfhdl/core/DFType.scala b/core/src/main/scala/dfhdl/core/DFType.scala index 68b84875c..0b06007bd 100644 --- a/core/src/main/scala/dfhdl/core/DFType.scala +++ b/core/src/main/scala/dfhdl/core/DFType.scala @@ -294,9 +294,6 @@ object DFType: extension [W <: IntP](dfType: DFTypeW[W]) @targetName("lengthDFTypeW") def length(using DFC): DFConstInt32 = dfType.width - extension [W <: IntP, T <: DFTypeW[W]](dfVal: DFValOf[T]) - @targetName("lengthDFValDFTypeW") - def length(using DFC): DFConstInt32 = dfVal.dfType.width end DFType diff --git a/core/src/main/scala/dfhdl/core/DFVal.scala b/core/src/main/scala/dfhdl/core/DFVal.scala index 80efdab32..603023cc3 100644 --- a/core/src/main/scala/dfhdl/core/DFVal.scala +++ b/core/src/main/scala/dfhdl/core/DFVal.scala @@ -1707,7 +1707,20 @@ object DFVal extends DFValLP: DFVal.Alias.History(dfVal, step, HistoryOp.State, initOpt) } def reg(using DFC, RTDomainOnly, RegInitCheck[I]): DFValOf[T] = dfVal.reg(1) - def width(using DFC): DFConstInt32 = dfVal.widthIntParam.toDFConstQuery + // a `width` FUNC rather than a materialized constant: the backends spell the query + // natively (`$bits(x)` in SystemVerilog, `bitWidth(x)`/`x'length` in VHDL), so the + // generated code keeps the value-to-width relation instead of a baked number + def width(using DFC): DFConstInt32 = trydf { + DFVal.Func(DFInt32, FuncOp.width, List(dfVal.asIR)).asConstOf[DFInt32] + } + end extension + + extension [W <: IntP, T <: DFTypeW[W]](dfVal: DFValOf[T]) + @targetName("lengthDFValDFTypeW") + // a `length` FUNC rather than a materialized constant (see `width` above); + def length(using DFC): DFConstInt32 = trydf { + DFVal.Func(DFInt32, FuncOp.length, List(dfVal.asIR)).asConstOf[DFInt32] + } end extension extension [T <: DFTypeAny, A, C, I, P](dfVal: DFVal[T, Modifier[A, C, I, P]]) diff --git a/core/src/main/scala/dfhdl/core/DFVector.scala b/core/src/main/scala/dfhdl/core/DFVector.scala index 720dcbf8f..4a88e43cb 100644 --- a/core/src/main/scala/dfhdl/core/DFVector.scala +++ b/core/src/main/scala/dfhdl/core/DFVector.scala @@ -354,7 +354,10 @@ object DFVector: val idxVal = DFConstInt32(i) DFVal.Alias.ApplyIdx(elementType, lhs, idxVal)(using dfc.anonymize) ) - def length(using DFC): DFConstInt32 = lhs.dfType.lengthIntParam.toDFConst + // a `length` FUNC rather than a materialized constant (see `DFVal.Ops.width`) + def length(using DFC): DFConstInt32 = trydf { + DFVal.Func(DFInt32, FuncOp.length, List(lhs.asIR)).asConstOf[DFInt32] + } end extension extension (str: String) def toByteVector(using dfc: DFC): DFConstOf[DFVector[DFBits[8], Tuple1[Int]]] = diff --git a/core/src/test/scala/CoreSpec/WidthLengthQueriesSpec.scala b/core/src/test/scala/CoreSpec/WidthLengthQueriesSpec.scala deleted file mode 100644 index 25590670a..000000000 --- a/core/src/test/scala/CoreSpec/WidthLengthQueriesSpec.scala +++ /dev/null @@ -1,52 +0,0 @@ -package CoreSpec -import dfhdl.* -import dfhdl.compiler.printing.DefaultPrinter - -// `.width` applied on a DFType is the `$clog2` width-derivation idiom (issue #457): construct -// the type with `.until`/`.to` and recover the width the constructor computed. The recovered -// width of a parametric type stays symbolic (`clog2(N)` below). `.length` equals `.width` for -// the bit-accurate scalars (`Bits`/`UInt`/`SInt`) and counts ELEMENTS for vectors, on both -// DFTypes and values. Both queries return `Int <> CONST`, and a `val` binding a query keeps its -// name in the generated code: a pre-existing (parametric) width constant is rebound through a -// named Ident (`toDFConstQuery`), never restamped (issue #449). -class WidthLengthQueriesSpec extends NoDFCSpec: - // the freshly elaborated design, before any of the stages that rename and reorder members - private def codeString(dsn: core.Design): String = - val db = dsn.getDB - DefaultPrinter(using db.getSet).csDB - - test("type and value width/length queries") { - class Top extends EDDesign: - val N: Int <> CONST = 854 - val ADDR_WIDTH = UInt.until(N).width - val a = UInt(ADDR_WIDTH) <> OUT - val W8 = Bits(8).width - val LB = Bits(8).length - val LU = UInt(8).length - val LS = SInt(8).length - val TVW = (Bits(8) X 4).width - val TVL = (Bits(8) X 4).length - val o = UInt(8) <> OUT - val OL = o.length - o <> OL.bits.uint.resize(8) - a <> 0 - assertNoDiff( - codeString(Top()), - """|class Top extends EDDesign: - | val N: Int <> CONST = 854 - | val ADDR_WIDTH: Int <> CONST = clog2(N) - | val a = UInt(ADDR_WIDTH) <> OUT - | val W8: Int <> CONST = 8 - | val LB: Int <> CONST = 8 - | val LU: Int <> CONST = 8 - | val LS: Int <> CONST = 8 - | val TVW: Int <> CONST = 32 - | val TVL: Int <> CONST = 4 - | val o = UInt(8) <> OUT - | val OL: Int <> CONST = 8 - | o <> OL.bits.uint.resize(8) - | a <> d"1'0".resize(ADDR_WIDTH) - |end Top""".stripMargin - ) - } -end WidthLengthQueriesSpec diff --git a/docs/user-guide/design-hierarchy/index.md b/docs/user-guide/design-hierarchy/index.md index 492715fea..72275ce5c 100644 --- a/docs/user-guide/design-hierarchy/index.md +++ b/docs/user-guide/design-hierarchy/index.md @@ -439,7 +439,7 @@ module InitReg#(parameter logic [7:0] INIT = 8'h00)( ); `include "dfhdl_defs.svh" /* the register length, derived from the initialization parameter */ - localparam int LEN = 8; + localparam int LEN = $bits(INIT); always_ff @(posedge clk) begin if (rst == 1'b1) dout <= INIT; @@ -460,7 +460,7 @@ use work.dfhdl_pkg.all; entity InitReg is generic ( INIT : std_logic_vector(7 downto 0) := x"00"; - LEN : integer := 8 + LEN : integer := INIT'length ); port ( clk : in std_logic; @@ -490,7 +490,7 @@ Points worth noting in this pattern: - Declaring `LEN` as a separate design parameter next to `INIT` is not possible in DFHDL, and it is also not needed: the width of a `Bits[Int] <> CONST` parameter travels with the applied argument itself, so `LEN` always agrees with `INIT` by construction. Two separate parameters would have to be kept consistent manually at every instantiation, a mismatch the derived form rules out entirely. - The width of `INIT` is set by the APPLIED argument (here the default `h"00"`, so 8). In the generated code that width is fixed in the module/entity declaration, while the VALUE of `INIT` remains overridable at that width. Applying an argument of a different width elaborates a design with the corresponding widths. -- `val LEN = INIT.length` is a named constant, so the generated code declares it by name (a Verilog `localparam`, a VHDL `generic` with a consistent default) and references it wherever it is used, rather than inlining its expression. +- `val LEN = INIT.length` is a named constant, so the generated code declares it by name (a Verilog `localparam`, a VHDL `generic`) and references it wherever it is used. The query itself is spelled natively, `$bits(INIT)` in SystemVerilog and `INIT'length` in VHDL, so the generated code keeps the `INIT`-to-`LEN` relation instead of a baked number (dialects without a width query, such as Verilog-2001, inline the width value instead). - The same recipe serves any derived parameter, for example a `clog2`-computed address width: declare the primary parameters, and compute the derived constants in the body. See also the [width derivation idiom][width-length-ops] (`UInt.until(DEPTH).width`). #### Design Parameter Access Rules diff --git a/docs/user-guide/type-system/index.md b/docs/user-guide/type-system/index.md index f4015d62f..4e8be8ac1 100755 --- a/docs/user-guide/type-system/index.md +++ b/docs/user-guide/type-system/index.md @@ -2913,7 +2913,7 @@ To extract a partial bit range from a DFHDL `Int` value, first convert it to `Bi Applies to: `.width`: any DFType and any DFHDL value; `.length`: `Bits`/`UInt`/`SInt` DFTypes and values, and `Vector` DFTypes and values -Both queries return a constant DFHDL `Int` value (`Int <> CONST`) rather than a plain Scala `Int`, so they compose with design parameters: querying a parametric type keeps the result symbolic, and the generated code carries the width expression (`clog2(DEPTH)`, `LANE * LANES`, and so on) instead of a folded number. +Both queries return a constant DFHDL `Int` value (`Int <> CONST`) rather than a plain Scala `Int`, so they compose with design parameters: querying a parametric type keeps the result symbolic, and the generated code carries the width expression (`clog2(DEPTH)`, `LANE * LANES`, and so on) instead of a folded number. A query over a DFHDL VALUE is spelled natively in the generated code where the target language has a width query: `$bits(x)`/`$size(x)` in SystemVerilog and `x'length`/`bitWidth(x)` in VHDL (dialects without one, such as Verilog-2001, inline the width expression instead). See [Inter-Dependent Design Parameters][inter-dependent-params] for the canonical use. /// html | div.operations | Operation | Description | Returns | From 9afce5bf2a21a834f46a9964adc4604aaa68d77a Mon Sep 17 00:00:00 2001 From: Oron Port Date: Sun, 9 Aug 2026 02:51:56 +0300 Subject: [PATCH 20/25] plugin+core: dedicated error for a single-line `process`/`initial` block body `process(all): y := x` parses as a type ascription and used to fail with obscure typer errors. Two interception points now report a dedicated error spelling out the fix from the statement's own source: the PreTyper parse-tree rewrite (well-formed trees; the statement is neutralized so no follow-on errors surface) and a CustomReporter override for bodies that fail the type parse (a parser error skips every plugin phase while the typer still runs, so the recovered tree's ascription errors are rewritten at reporting and collapsed onto one). The override is gated on a parser error having been reported, so ordinary compilations never pay for it. The nested snippet pipeline now types parser-recovered trees like the real pipeline, making the override testable (`assertPluginErrors`); RTProcessSpec is renamed ProcessSpec and hosts the new tests. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- ...{RTProcessSpec.scala => ProcessSpec.scala} | 74 ++++++- core/src/test/scala/NoDFCSpec.scala | 14 ++ devdocs/plugin-error-testing.md | 27 ++- .../main/scala/plugin/PluginTestPhase.scala | 141 +++++++------ .../src/main/scala/plugin/PreTyperPhase.scala | 193 ++++++++++++++++-- 6 files changed, 366 insertions(+), 85 deletions(-) rename core/src/test/scala/CoreSpec/{RTProcessSpec.scala => ProcessSpec.scala} (69%) diff --git a/CLAUDE.md b/CLAUDE.md index 505231279..c14f529ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ internals → plugin → compiler_ir → core → compiler_stages → lib → pl Located in `plugin/src/main/scala/plugin/`, in the order `Plugin.initialize` lists them: -1. `PreTyperPhase` — untyped parse-tree rewrites (`<>` precedence, auto-`@top`) +1. `PreTyperPhase` — untyped parse-tree rewrites (`<>` precedence, auto-`@top`, the single-line `process`/`initial` block error) 2. `TopAnnotPhase` — top-level annotation processing 3. `PureCheckPhase` — purity analysis for elaboration caching 4. `CodeDigestPhase` — code digests, the elaboration cache keys diff --git a/core/src/test/scala/CoreSpec/RTProcessSpec.scala b/core/src/test/scala/CoreSpec/ProcessSpec.scala similarity index 69% rename from core/src/test/scala/CoreSpec/RTProcessSpec.scala rename to core/src/test/scala/CoreSpec/ProcessSpec.scala index 2e1a7873a..89a2489fe 100644 --- a/core/src/test/scala/CoreSpec/RTProcessSpec.scala +++ b/core/src/test/scala/CoreSpec/ProcessSpec.scala @@ -2,7 +2,7 @@ package CoreSpec import dfhdl.* import munit.* -class RTProcessSpec extends NoDFCSpec: +class ProcessSpec extends NoDFCSpec: test("valid RT process steps report no plugin errors"): assertPluginError("No error found")( """ @@ -224,4 +224,74 @@ class RTProcessSpec extends NoDFCSpec: 1.cy.wait """ ) -end RTProcessSpec + + test("single-line sensitivity-list process body"): + assertSinglePluginError( + "The body of a `process` block cannot be placed on the same line after the `:`.\n" + + "Move it to its own indented line:\n process(all):\n y := x" + )( + """ + class Foo extends EDDesign: + val x = Bits(8) <> IN + val y = Bits(8) <> OUT + process(all): y := x + """ + ) + + test("single-line forever process body"): + assertSinglePluginError( + "The body of a `process` block cannot be placed on the same line after the `:`.\n" + + "Move it to its own indented line:\n process:\n y :== x" + )( + """ + class Foo extends EDDesign: + val x = Bits(8) <> IN + val y = Bits(8) <> OUT + process: y :== x + """ + ) + + test("single-line initial block body"): + assertSinglePluginError( + "The body of an `initial` block cannot be placed on the same line after the `:`.\n" + + "Move it to its own indented line:\n initial:\n y := x" + )( + """ + class Foo extends EDDesign: + val y = Bits(8) <> OUT + initial: y := x + """ + ) + + // The two tests below have bodies that fail the TYPE parse (a call's parentheses are not type + // syntax), so the parser reports first and every plugin phase is skipped for the compilation; + // the dedicated error then comes from the reporter-side override, which also collapses the + // ascription's obscure typer errors into it (hence exactly TWO errors asserted). + test("single-line process body failing the type parse (call on the assignment target)"): + assertPluginErrors( + "end of statement expected but '(' found\n\n" + + "The body of a `process` block cannot be placed on the same line after the `:`.\n" + + "Move it to its own indented line:\n process(all):\n y(0) := x" + )( + """ + class Foo extends EDDesign: + val x = Bits(8) <> IN + val y = Bits(8) <> OUT + process(all): y(0) := x + """ + ) + + test("single-line initial body failing the type parse (call on the assignment source)"): + assertPluginErrors( + "end of statement expected but '(' found\n\n" + + "The body of an `initial` block cannot be placed on the same line after the `:`.\n" + + "Move it to its own indented line:\n initial:\n y := x.resize(4)" + )( + """ + class Foo extends EDDesign: + val x = Bits(8) <> IN + val y = Bits(8) <> OUT + initial: y := x.resize(4) + """ + ) +end ProcessSpec diff --git a/core/src/test/scala/NoDFCSpec.scala b/core/src/test/scala/NoDFCSpec.scala index 6211922cf..4064d89d6 100644 --- a/core/src/test/scala/NoDFCSpec.scala +++ b/core/src/test/scala/NoDFCSpec.scala @@ -50,6 +50,20 @@ abstract class NoDFCSpec extends FunSuite, NoTopAnnotIsRequired: ) end assertPluginError + // Like `assertPluginError`, but asserts on ALL errors the snippet produces, chronologically, + // joined with a blank line: for mistakes whose parser error is followed by a rewritten + // diagnostic (e.g. the single-line process/initial override), this pins both texts and the + // collapse of everything else. + transparent inline def assertPluginErrors(expectedErrs: String)( + inline code: String + ): Unit = + val errs = internals.PluginErrCheck.pluginCheckErrors(code) + assertNoDiff( + if (errs.isEmpty) noErrMsg else errs.reverse.mkString("\n\n"), + expectedErrs + ) + end assertPluginErrors + // Like `assertPluginError`, but asserts the snippet produces EXACTLY one error with the given // user-facing text: on top of the message itself, this pins the diagnostic dedup (an // inline-expansion error re-raised at several positions must render once). diff --git a/devdocs/plugin-error-testing.md b/devdocs/plugin-error-testing.md index 481c7b90b..312fe5fdc 100644 --- a/devdocs/plugin-error-testing.md +++ b/devdocs/plugin-error-testing.md @@ -16,7 +16,7 @@ dedicated plugin phase intercepts, nested-compiles, and replaces with the litera |---|---|---| | `PluginErrCheck.pluginCheckErrors(code)` | `internals/src/test/.../PluginErrCheck.scala` | marker; throwing body, never published | | `PluginTestPhase` (pipeline name `PluginErrCheck`) | `plugin/.../PluginTestPhase.scala` | intercepts marker calls, runs the nested compile | -| `assertPluginError(expectedErr)(code)` | `NoDFCSpec` | munit-facing helper | +| `assertPluginError(expectedErr)(code)` | `NoDFCSpec` | munit-facing helper (variants: `assertSinglePluginError`, `assertPluginErrors`) | | `pluginErrorTestSettings` + `internals % "test->test;compile->compile"` | build.sbt (core only) | gating and marker visibility | | `CoreSpec` | core tests | @@ -48,10 +48,16 @@ reported error, the same convention as `assertCompileError`. snippets isolated: block-local classes are owned by a throwaway symbol and never enter a package scope, so snippets cannot collide with each other or leak into the real compilation. -2. `PreTyperPhase.rewriteParsed` applies the two `<>` precedence fixers to the parse tree, - giving snippets the same parse-level fidelity as regular units (the auto-`@top` rewrite - never applies inside a block and is skipped). Notably, `typeCheckErrors` skips untyped - rewrites entirely; owning the pipeline is what makes this fidelity possible. +2. `PreTyperPhase.rewriteParsed` applies the parse-tree rewrites (the two `<>` precedence + fixers and the single-line `process`/`initial` error) to the parse tree, giving snippets + the same parse-level fidelity as regular units (the auto-`@top` rewrite never applies + inside a block and is skipped). Notably, `typeCheckErrors` skips untyped rewrites + entirely; owning the pipeline is what makes this fidelity possible. When the PARSE itself + errored, `rewriteParsed` is skipped and the parser's recovered tree is typed raw instead, + mirroring the real pipeline: a parser error makes the compiler skip every plugin phase for + the run while still running the typer, and that recovery mode is exactly where the + reporter-side single-line override (`DiagnosticRewriter.singleLineOverride`) exists, so + snippets exercise it too. 3. A fresh nested context is created: a fresh typer state (whose buffering reporter is what isolates the snippet's diagnostics from the real run), a nested `Typer`, a dummy owner (as in the intrinsic: the real owner may be inspected by a transform phase, causing cyclic @@ -79,6 +85,9 @@ reported error, the same convention as `assertCompileError`. replacement literal. A snippet that fails the typer therefore reports the typer error and the plugin phases never run: typer errors mask plugin errors (e.g. a `Unit <> EDRET` body using `:=` hits the `Scope.Procedural` typer error before the plugin's procedural error). + A snippet that fails the PARSER reports the parser error plus whatever the typer says + about the recovered tree (after the same diagnostic rewriting; `assertPluginErrors` + asserts the full chronological list for these). ## Writing tests @@ -148,8 +157,10 @@ mapping. Metals/BSP export `Test / scalacOptions`, so the gating applies in the `PreTyperPhase.initContext` installs for the real run, so the collection step applies the SAME rewriting through the shared `DiagnosticRewriter`: position normalization, dedup (an inline-expansion error re-raised at several positions must render once; asserted with - `assertSinglePluginError`), the DFHDL-mismatch postscript drop, and the guide rails (which - name the enclosing call from the snippet's parse tree). The rewriter's `unitSource` must be + `assertSinglePluginError`), the DFHDL-mismatch postscript drop, the guide rails (which + name the enclosing call from the snippet's parse tree), and the single-line + `process`/`initial` override (which replaces every ascription error of one mistake with + the dedicated message and collapses them onto one). The rewriter's `unitSource` must be the snippet's virtual source: a nested diagnostic's position chain extends past it into the real unit (the marker call site), so the outermost frame does not identify the unit. The rendering itself matters too: `message` renders under `Message.inMessageContext`, which pins @@ -185,7 +196,7 @@ sbtn.bat 'set core/Test/scalacOptions += "-P:dfhdl.plugin:disableCustomPrinter"; Every plugin `report.error` site is covered by an `assertPluginError` test in the core spec that matches its subject (EDMethodSpec, StaticFunctionSpec, DFMatchSpec, DFTypeSpec, -DFDecimalSpec, DFBoolOrBitSpec, RTProcessSpec), EXCEPT the following, which are deliberately +DFDecimalSpec, DFBoolOrBitSpec, ProcessSpec), EXCEPT the following, which are deliberately untested: - **TopAnnotPhase errors and the missing-`@top` instantiation error**: exercising them needs diff --git a/plugin/src/main/scala/plugin/PluginTestPhase.scala b/plugin/src/main/scala/plugin/PluginTestPhase.scala index f995608ea..d3fe000d5 100644 --- a/plugin/src/main/scala/plugin/PluginTestPhase.scala +++ b/plugin/src/main/scala/plugin/PluginTestPhase.scala @@ -160,67 +160,74 @@ class PluginTestPhase(setting: Setting) extends CommonPhase: inContext(newContext) { def noErrors = ctx.reporter.allErrors.isEmpty - // the snippet's parse tree, kept for the diagnostic rewriting below (the guide rails - // name the enclosing call from it); empty when parsing itself failed - var snippetUntpd: untpd.Tree = untpd.EmptyTree + // The snippet's parse tree, kept for the diagnostic rewriting below (the guide rails + // and the single-line process/initial override read it). On a parse error the real + // pipeline skips every plugin phase but STILL runs the typer on the parser's recovered + // tree; both are mirrored here (raw tree, no `rewriteParsed`), so a snippet surfaces + // exactly the diagnostics a user reads, including the reporter-side override that only + // exists for that recovery mode. val parsed = new Parser(source2).block() + // The parse-phase diagnostics, snapshotted: their presence is the single-line + // override's gate, and the override must never apply to one of THEM (in the real + // pipeline it cannot: the unit's parse tree is still unassigned while they report). + val parseDiags = ctx.reporter.allErrors + val parsedClean = parseDiags.isEmpty + val snippetUntpd: untpd.Tree = + if (parsedClean) preTyperRewriter.rewriteParsed(parsed) else parsed + val tpdTree = ctx.typer.typed(snippetUntpd) if (noErrors) - val untpdTree = preTyperRewriter.rewriteParsed(parsed) - snippetUntpd = untpdTree - val tpdTree = ctx.typer.typed(untpdTree) - if (noErrors) - // Every run below is constructed INSIDE this nested context on purpose: the - // closures capture the given Context, and capturing the enclosing real one - // would leak the snippet's diagnostics into the real compilation. - // - // The standard runs are those the real pipeline interleaves with the plugin - // phases. Pickler, SetRootTree (present only under -Yretain-trees), and the - // InlineVals/ElimRepeated/RefChecks group the upstream intrinsic reconstructs - // are all irrelevant to plugin diagnostics and skipped. - // - // The inlining phase runs through the plugin's own Zinc-free tree map instead of - // the real `Inlining` phase: the real phase records every inline call as an - // incremental-compilation dependency and flushes it to Zinc keyed by the unit's - // source, and the snippet's virtual source has no Zinc virtual file, producing a - // "Missing Zinc virtual file" warning per recorded dependency. - val standardRuns: List[(Int, Tree => Tree)] = - List(ctx.base.postTyperPhase).collect { + // Every run below is constructed INSIDE this nested context on purpose: the + // closures capture the given Context, and capturing the enclosing real one + // would leak the snippet's diagnostics into the real compilation. + // + // The standard runs are those the real pipeline interleaves with the plugin + // phases. Pickler, SetRootTree (present only under -Yretain-trees), and the + // InlineVals/ElimRepeated/RefChecks group the upstream intrinsic reconstructs + // are all irrelevant to plugin diagnostics and skipped. + // + // The inlining phase runs through the plugin's own Zinc-free tree map instead of + // the real `Inlining` phase: the real phase records every inline call as an + // incremental-compilation dependency and flushes it to Zinc keyed by the unit's + // source, and the snippet's virtual source has no Zinc virtual file, producing a + // "Missing Zinc virtual file" warning per recorded dependency. + val standardRuns: List[(Int, Tree => Tree)] = + List(ctx.base.postTyperPhase).collect { + case p if p.exists => + ( + p.id, + (t: Tree) => + atPhase(p)(p.runOn(compilationUnits(snippetUntpd, t)).head.tpdTree) + ) + } ++ + List(ctx.base.inliningPhase).collect { case p if p.exists => - ( - p.id, - (t: Tree) => atPhase(p)(p.runOn(compilationUnits(untpdTree, t)).head.tpdTree) - ) - } ++ - List(ctx.base.inliningPhase).collect { - case p if p.exists => - (p.id, (t: Tree) => inlineCalls(t)) - } - // Each fresh plugin phase is pinned to its installed counterpart's phase id, so - // denotation lookups match the real pipeline, and the whole nested pipeline is - // ordered by those ids, i.e. by the real schedule's order. - val installed = installedPhaseMap - val pluginRuns: List[(Int, Tree => Tree)] = - freshPluginPhases.flatMap { fresh => - installed.get(fresh.phaseName).map { real => - val mp = MegaPhaseWithCustomPhaseId(Array(fresh), real.id, real.id) - val run: Tree => Tree = fresh match - // PureCheck does its whole-run analysis (and its static-impurity error - // reporting) in `runOn`, which `transformUnit` never reaches - case pureCheck: PureCheckPhase => - (t: Tree) => - atPhase(mp.end + 1) { - val res = mp.transformUnit(t) - pureCheck.analyzeNested(compilationUnits(untpdTree, res)) - res - } - case _ => (t: Tree) => atPhase(mp.end + 1)(mp.transformUnit(t)) - (real.id, run) - } + (p.id, (t: Tree) => inlineCalls(t)) } - var transformTree = tpdTree - for ((_, run) <- (standardRuns ++ pluginRuns).sortBy(_._1)) - if (noErrors) transformTree = run(transformTree) - end if + // Each fresh plugin phase is pinned to its installed counterpart's phase id, so + // denotation lookups match the real pipeline, and the whole nested pipeline is + // ordered by those ids, i.e. by the real schedule's order. + val installed = installedPhaseMap + val pluginRuns: List[(Int, Tree => Tree)] = + freshPluginPhases.flatMap { fresh => + installed.get(fresh.phaseName).map { real => + val mp = MegaPhaseWithCustomPhaseId(Array(fresh), real.id, real.id) + val run: Tree => Tree = fresh match + // PureCheck does its whole-run analysis (and its static-impurity error + // reporting) in `runOn`, which `transformUnit` never reaches + case pureCheck: PureCheckPhase => + (t: Tree) => + atPhase(mp.end + 1) { + val res = mp.transformUnit(t) + pureCheck.analyzeNested(compilationUnits(snippetUntpd, res)) + res + } + case _ => (t: Tree) => atPhase(mp.end + 1)(mp.transformUnit(t)) + (real.id, run) + } + } + var transformTree = tpdTree + for ((_, run) <- (standardRuns ++ pluginRuns).sortBy(_._1)) + if (noErrors) transformTree = run(transformTree) end if // Every diagnostic goes through the SAME rewriting the real run's CustomReporter // applies (position normalization, dedup, postscript drop, guide rails), then renders @@ -231,12 +238,22 @@ class PluginTestPhase(setting: Setting) extends CommonPhase: // installed that printer. The colour escapes `Diagnostic.message` would have dropped // are stripped the same way. val seen = collection.mutable.HashSet.empty[(String, Int, Int, Int, String)] + def overrideEligible(dia: reporting.Diagnostic): Boolean = + !parsedClean && parseDiags.forall(_ ne dia) ctx.reporter.allErrors.collect { - case dia if seen.add(diagRewriter.dedupKey(dia, source2)) => - val userPos = diagRewriter.normalizedPos(dia.pos, source2) - diagRewriter - .updatedMsg(dia.msg, userPos, snippetUntpd) - .toString.replaceAll("\\e\\[[;\\d]*m", "") + case dia + if seen.add( + diagRewriter.dedupKey(dia, source2, snippetUntpd, overrideEligible(dia)) + ) => + val overridden = + diagRewriter.singleLineOverride(dia, source2, snippetUntpd, overrideEligible(dia)) + overridden match + case Some((text, _)) => text + case None => + val userPos = diagRewriter.normalizedPos(dia.pos, source2) + diagRewriter + .updatedMsg(dia.msg, userPos, snippetUntpd) + .toString.replaceAll("\\e\\[[;\\d]*m", "") } } } diff --git a/plugin/src/main/scala/plugin/PreTyperPhase.scala b/plugin/src/main/scala/plugin/PreTyperPhase.scala index 92e2fe0d8..52a44f7d2 100644 --- a/plugin/src/main/scala/plugin/PreTyperPhase.scala +++ b/plugin/src/main/scala/plugin/PreTyperPhase.scala @@ -22,6 +22,70 @@ import collection.mutable import annotation.tailrec import reporting.* +/** The single-line `process`/`initial` block mistake, `process(all): y := x`: the parser reads the + * line as a TYPE ASCRIPTION (`process(all)` ascribed to the "type" `y := x`), which the typer then + * rejects with baffling errors (`Not found: type :=`, `Expected a type, but found a term`). The + * recognition and the dedicated message live here, shared by the two interception points: the + * [[PreTyperPhase]] parse-tree rewrite (well-formed parse trees) and the + * [[DiagnosticRewriter.singleLineOverride]] reporting hook (trees a PARSE error kept the plugin + * phases from ever seeing). Sharing the builder keeps the two paths from drifting: whichever + * fires, the user reads the same text. + */ +private object SingleLineProcessBlock: + import untpd.* + private val bodyOps = Set(":=", ":==", "<>") + + @tailrec private def blockName(tree: Tree): Option[String] = + tree match + case Ident(name) if name.toString == "process" || name.toString == "initial" => + Some(name.toString) + case Select(Ident(qual), name) + if qual.toString == "process" && name.toString == "forever" => + Some("process") + case Apply(fun, _) => blockName(fun) + case _ => None + + /** The mistake's ascription shape, as (block name, term, ascribed "type"). With `bodyOpOnly` the + * ascribed tree must be a `:=`/`:==`/`<>` infix op, the strict gate the parse-tree rewrite uses + * on well-formed trees (where an ordinary ascription must keep its meaning). The reporting hook + * drops the gate entirely: parser recovery leaves unpredictable shapes there, a bare body prefix + * (`y(0) := x` keeps just `y`) or an infix chain under a body identifier taken as the operator + * (`y := !x` parses as `(y := !) x ...`), so the `process`/`initial` term is the discriminator. + */ + def matchTyped(tree: Tree, bodyOpOnly: Boolean): Option[(String, Tree, Tree)] = + tree match + case Typed(expr, tpt) => + val bodyLike = tpt match + case InfixOp(_, Ident(op), _) => bodyOps.contains(op.toString) + case _ => false + if (bodyLike || !bodyOpOnly) blockName(expr).map((_, expr, tpt)) else None + case _ => None + + /** The dedicated error text, spelling out the fix with the statement's own source: the block head + * from the term's span, and the body from the ascribed tree's start to the END OF ITS LINE (the + * ascribed tree itself may hold only a prefix of the body after parser recovery). + */ + def message(name: String, expr: Tree, tpt: Tree)(using Context): String = + val article = if (name == "initial") "an" else "a" + val source = expr.source + val head = + if (expr.span.exists && source.exists) + String(source.content().slice(expr.span.start, expr.span.end)) + else name + val body = + if (tpt.span.exists && source.exists) + val content = source.content() + var end = tpt.span.start + while (end < content.length && content(end) != '\n' && content(end) != '\r') end += 1 + String(content.slice(tpt.span.start, end)).trim + else "y := x" + s"""|The body of $article `$name` block cannot be placed on the same line after the `:`. + |Move it to its own indented line: + | $head: + | $body""".stripMargin + end message +end SingleLineProcessBlock + /** The single home of DFHDL's user-facing diagnostic rewriting, applied by [[CustomReporter]] on * the real compilation and by [[PluginTestPhase]] on nested snippet compilations, so specs assert * on exactly what a user reads. @@ -52,15 +116,78 @@ final class DiagnosticRewriter(symbols: DFHDLSymbols.Cache): end normalizedPos /** The identity of a diagnostic AS RENDERED: the same inline-expansion error re-raised at several - * positions collapses onto one normalized position, so it must render once. + * positions collapses onto one normalized position, so it must render once. A diagnostic the + * single-line override replaces keys on the OVERRIDE's position and text instead: every + * ascription error of one mistake renders as the same dedicated message, so all of them must + * collapse onto one. */ - def dedupKey(dia: Diagnostic, unitSource: util.SourceFile)(using + def dedupKey( + dia: Diagnostic, + unitSource: util.SourceFile, + untpdRoot: untpd.Tree, + parseErrored: Boolean + )(using Context ): (String, Int, Int, Int, String) = - val diaPos = normalizedPos(dia.pos, unitSource) - val (spanStart, spanEnd) = - if (diaPos.span.exists) (diaPos.span.start, diaPos.span.end) else (-1, -1) - (diaPos.source.file.path, spanStart, spanEnd, dia.level, dia.msg.toString) + singleLineOverride(dia, unitSource, untpdRoot, parseErrored) match + case Some((text, pos)) => + (pos.source.file.path, pos.span.start, pos.span.end, dia.level, text) + case None => + val diaPos = normalizedPos(dia.pos, unitSource) + val (spanStart, spanEnd) = + if (diaPos.span.exists) (diaPos.span.start, diaPos.span.end) else (-1, -1) + (diaPos.source.file.path, spanStart, spanEnd, dia.level, dia.msg.toString) + + /** The dedicated single-line `process`/`initial` error standing in for `dia`, or None. When the + * block body fails the TYPE parse (`process(all): y(0) := x`), the parser reports its own error + * and the compiler then skips every plugin phase for the run while STILL running the typer, so + * the PreTyper parse-tree rewrite never sees the mistake and the ascription's obscure typer + * errors surface after the parser's. They are caught here instead, at reporting: an ERROR whose + * position falls inside the ascribed "type" of a surviving mistake shape in the unit's parse + * tree is replaced with the dedicated message, anchored at the ascribed tree so every such error + * collapses onto ONE rendered diagnostic (see [[dedupKey]]). `parseErrored` (the compilation has + * reported a PARSER error, and `dia` is not itself one; see [[CustomReporter]]) gates the whole + * override: the recovery mode is the only one it exists for, so an ordinary failing compilation + * never pays the tree traversal, on an error-free parse the PreTyper rewrite has already + * neutralized every matching shape, and an error landing inside a surviving ascription of + * ordinary code keeps its own diagnostic. + */ + def singleLineOverride( + dia: Diagnostic, + unitSource: util.SourceFile, + untpdRoot: untpd.Tree, + parseErrored: Boolean + )(using Context): Option[(String, util.SourcePosition)] = + if (!parseErrored || dia.level < interfaces.Diagnostic.ERROR || untpdRoot.isEmpty) None + else + try + val diaPos = normalizedPos(dia.pos, unitSource) + if (!(diaPos.source eq unitSource) || !diaPos.span.exists) None + else + var found: Option[(String, untpd.Tree, untpd.Tree)] = None + val traverser = new untpd.UntypedTreeTraverser: + def traverse(tree: untpd.Tree)(using Context): Unit = + if (found.isEmpty) + SingleLineProcessBlock.matchTyped(tree, bodyOpOnly = false) match + case res @ Some((_, _, tpt)) + if tpt.span.exists && tpt.span.contains(diaPos.span) => + found = res + case _ => traverseChildren(tree) + traverser.traverse(untpdRoot) + found.map { (name, expr, tpt) => + // the position covers the body from the ascribed tree's start to its LINE end: + // parser recovery may run the tree past the line (absorbing the next statement) + // or stop it short of the body's end, and the mistake is its line either way + val content = unitSource.content() + var end = tpt.span.start + while (end < content.length && content(end) != '\n' && content(end) != '\r') + end += 1 + val pos = unitSource.atSpan(util.Spans.Span(tpt.span.start, end)) + (SingleLineProcessBlock.message(name, expr, tpt), pos) + } + end if + catch case scala.util.control.NonFatal(_) => None + end singleLineOverride /** The message to report in place of `base`. Every message is re-rendered through the DFHDL type * printer. A type mismatch whose REQUIRED side is a DFHDL value is additionally re-issued with @@ -292,18 +419,33 @@ class CustomReporter( if ((unit ne null) && (pos.source eq unit.source)) unit.untpdTree else untpd.EmptyTree catch case scala.util.control.NonFatal(_) => untpd.EmptyTree + // Whether the run has reported a PARSER error: the gate of the single-line process/initial + // override, which exists only for that recovery mode (a parser error makes the compiler skip + // every plugin phase while still running the typer), so an ordinary failing compilation never + // pays the override's tree traversal. The flag is raised in `isHidden` (called first for + // every diagnostic). A parse error itself can never be overridden even though the flag is + // already up while it reports: at that moment the unit's parse tree is not yet assigned, so + // `untpdRootFor` answers empty. + private var parseErrorSeen = false // the dedup lives in `isHidden` rather than `doReport` so a swallowed duplicate is also // never counted, keeping the "N errors found" summary consistent with what is rendered // (the same reason the compiler's own dedup, `UniqueMessagePositions`, works at this hook) override def isHidden(dia: Diagnostic)(using Context): Boolean = + if (dia.level >= interfaces.Diagnostic.ERROR && ctx.phase.phaseName == "parser") + parseErrorSeen = true super.isHidden(dia) || - dia.level >= interfaces.Diagnostic.WARNING && - !reported.add(rewriter.dedupKey(dia, ctx.source)) + dia.level >= interfaces.Diagnostic.WARNING && + !reported.add(rewriter.dedupKey(dia, ctx.source, untpdRootFor(dia.pos), parseErrorSeen)) override def doReport(dia: Diagnostic)(using ctx: Context): Unit = val userPos = rewriter.normalizedPos(dia.pos, ctx.source) - val diaPos = userPos.copy(outer = null) // disable inline stack error printing - val newMsg = rewriter.updatedMsg(dia.msg, userPos, untpdRootFor(userPos)) - orig.doReport(Diagnostic(newMsg, diaPos, dia.level)) + val untpdRoot = untpdRootFor(userPos) + rewriter.singleLineOverride(dia, ctx.source, untpdRoot, parseErrorSeen) match + case Some((text, pos)) => + orig.doReport(Diagnostic(NoExplanation(text), pos, dia.level)) + case None => + val diaPos = userPos.copy(outer = null) // disable inline stack error printing + val newMsg = rewriter.updatedMsg(dia.msg, userPos, untpdRoot) + orig.doReport(Diagnostic(newMsg, diaPos, dia.level)) end doReport end CustomReporter @@ -314,6 +456,9 @@ end CustomReporter * - change infix operator precedence of terms: `a := b match {...}` to be `a := (b match {...})` * and `a <> b match {...}` to be `a <> (b match {...})` * - change process{} to process.forever{} + * - report a dedicated error when a `process`/`initial` block body is placed on the same line + * after the `:` (e.g., `process(all): y := x`), which the parser otherwise reads as a type + * ascription that later fails with obscure typer errors * - auto-add `@top` annotation to concrete classes that look like DFHDL designs (extend * EDDesign/RTDesign/DFDesign, have `type <> CONST` parameters, or use `<>` in their body), * provided `import dfhdl.*` is in lexical scope and no `@top` annotation is already present. @@ -641,11 +786,35 @@ class PreTyperPhase(setting: Setting) extends CommonPhase: t end match end transform + + /** The [[SingleLineProcessBlock]] mistake caught on a well-formed parse tree: a dedicated error + * is reported and the statement is replaced with `scala.Predef.???` (valid in any position, no + * purity warning) so none of the ascription's typer errors surface. Bodies that fail the TYPE + * parse never reach this rewrite (a parser error skips every plugin phase for the run); those + * are caught at reporting instead, by [[DiagnosticRewriter.singleLineOverride]]. + */ + private val `singleLineProcessErr` = new UntypedTreeMap: + override def transform(tree: Tree)(using Context): Tree = + SingleLineProcessBlock.matchTyped(tree, bodyOpOnly = true) match + case Some((name, expr, tpt)) => + report.error(SingleLineProcessBlock.message(name, expr, tpt), tpt.srcPos) + Select( + Select( + Select(Ident(nme.ROOTPKG), "scala".toTermName), + "Predef".toTermName + ), + "???".toTermName + ).withSpan(tree.span) + case None => super.transform(tree) + end transform + // Applies this phase's parse-tree rewrites to a standalone parsed tree, so nested snippet // compilations (PluginTestPhase) get the same parse-level fidelity as regular units. The // auto-@top rewrite is deliberately skipped: it never applies inside block snippets. def rewriteParsed(tree: Tree)(using Context): Tree = - `fixXand<>Precedence`.transform(`fix<>andOpPrecedence`.transform(tree)) + `fixXand<>Precedence`.transform( + `fix<>andOpPrecedence`.transform(`singleLineProcessErr`.transform(tree)) + ) // The symbols the DFHDL type printer matches against, cached per run. The cache belongs to // this phase instance rather than to a global, so compilers running concurrently in one JVM From 53ac2b5ddf65783742fe7c64f467b652a21bf75b Mon Sep 17 00:00:00 2001 From: Oron Port Date: Sun, 9 Aug 2026 03:38:51 +0300 Subject: [PATCH 21/25] core+docs: target-context widening crosses `.sel` like Verilog's `?:` (#464) A narrow anonymous arithmetic chain inside a `.sel` assigned to a wider target evaluated modularly at the operand width, with only the selection result extended afterward, silently diverging from the Verilog `?:` it translates (whose branch operands are context-determined). An anonymous DFXInt-typed sel converted to a wider type now re-types to the target, with each branch re-entering the widening and the condition untouched, so `dx := c.sel(xb - xa, xa - xb)` emits `c ? (xb - xa) : (xa - xb)` in the wider assignment context, faithful to the source intent. The promotion machinery moves to its own CarryPromote file: widenedOpt (the widening decision and fresh-func construction) and the Verilog-semantics warning helpers; the general toDFXIntOf conversion stays in DFDecimal and falls back to the leaf conversion when no widening applies. All six probe shapes (carry-fit, beyond-carry, unsigned, mixed-sign, nested sel, sel-inside-arith) are SAT-proven equivalent to a hand-written `?:` golden via a yosys miter. Co-Authored-By: Claude Fable 5 --- .../scala/StagesSpec/PrintVHDLCodeSpec.scala | 20 +- .../StagesSpec/PrintVerilogCodeSpec.scala | 20 +- .../main/scala/dfhdl/core/CarryPromote.scala | 254 ++++++++++++++++++ core/src/main/scala/dfhdl/core/DFBits.scala | 4 +- .../src/main/scala/dfhdl/core/DFDecimal.scala | 227 +--------------- .../test/scala/CoreSpec/DFDecimalSpec.scala | 36 +++ docs/transitioning/from-verilog/index.md | 2 +- docs/user-guide/type-system/index.md | 7 + lib/src/test/scala/ContextWidenSpec.scala | 28 ++ 9 files changed, 365 insertions(+), 233 deletions(-) create mode 100644 core/src/main/scala/dfhdl/core/CarryPromote.scala diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala index 3b0c1007e..2950db9fe 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala @@ -3493,12 +3493,15 @@ class PrintVHDLCodeSpec extends StageSpec: val uacc = UInt(W + 2) <> OUT val prod = SInt(2 * W) <> OUT val uprod = UInt(2 * W) <> OUT - sum <> a + b - usub <> ua - ub - acc <> a + b - uacc <> ua + ub - prod <> a * b - uprod <> ua * ub + val c = Bit <> IN + val viaSel = SInt(W + 1) <> OUT + sum <> a + b + usub <> ua - ub + acc <> a + b + uacc <> ua + ub + prod <> a * b + uprod <> ua * ub + viaSel <> c.sel(b - a, a - b) end ParamWiden val top = ParamWiden().getCompiledCodeString assertNoDiff( @@ -3522,7 +3525,9 @@ class PrintVHDLCodeSpec extends StageSpec: | acc : out signed((W + 2) - 1 downto 0); | uacc : out unsigned((W + 2) - 1 downto 0); | prod : out signed((2 * W) - 1 downto 0); - | uprod : out unsigned((2 * W) - 1 downto 0) + | uprod : out unsigned((2 * W) - 1 downto 0); + | c : in std_logic; + | viaSel : out signed((W + 1) - 1 downto 0) |); |end ParamWiden; | @@ -3534,6 +3539,7 @@ class PrintVHDLCodeSpec extends StageSpec: | uacc <= eby(ua, 2) + eby(ub, 2); | prod <= a * b; | uprod <= ua * ub; + | viaSel <= bool_sel(to_bool(c), csub(b, a), csub(a, b)); |end ParamWiden_arch; |""".stripMargin ) diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala index bede97f59..7d93cf795 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala @@ -3315,12 +3315,15 @@ class PrintVerilogCodeSpec extends StageSpec: val uacc = UInt(W + 2) <> OUT val prod = SInt(2 * W) <> OUT val uprod = UInt(2 * W) <> OUT - sum <> a + b - usub <> ua - ub - acc <> a + b - uacc <> ua + ub - prod <> a * b - uprod <> ua * ub + val c = Bit <> IN + val viaSel = SInt(W + 1) <> OUT + sum <> a + b + usub <> ua - ub + acc <> a + b + uacc <> ua + ub + prod <> a * b + uprod <> ua * ub + viaSel <> c.sel(b - a, a - b) end ParamWiden val top = ParamWiden().getCompiledCodeString assertNoDiff( @@ -3338,7 +3341,9 @@ class PrintVerilogCodeSpec extends StageSpec: | output logic signed [(W + 2) - 1:0] acc, | output logic [(W + 2) - 1:0] uacc, | output logic signed [(2 * W) - 1:0] prod, - | output logic [(2 * W) - 1:0] uprod + | output logic [(2 * W) - 1:0] uprod, + | input wire logic c, + | output logic signed [(W + 1) - 1:0] viaSel |); | `include "dfhdl_defs.svh" | assign sum = a + b; @@ -3347,6 +3352,7 @@ class PrintVerilogCodeSpec extends StageSpec: | assign uacc = `EBY_U(ua, 2) + `EBY_U(ub, 2); | assign prod = a * b; | assign uprod = ua * ub; + | assign viaSel = c ? (b - a) : (a - b); |endmodule |""".stripMargin ) diff --git a/core/src/main/scala/dfhdl/core/CarryPromote.scala b/core/src/main/scala/dfhdl/core/CarryPromote.scala new file mode 100644 index 000000000..59eb4ac8b --- /dev/null +++ b/core/src/main/scala/dfhdl/core/CarryPromote.scala @@ -0,0 +1,254 @@ +package dfhdl.core +import dfhdl.compiler.ir +import dfhdl.internals.* +import ir.DFVal.Func.{Op => FuncOp} +import ir.DFDecimal.NativeType +import NativeType.* +import DFDecimal.Extensions.* + +/** Target-context widening ("carry promotion") of anonymous integer expressions, and the related + * Verilog-semantics warning machinery. + * + * [[widenedOpt]] holds the deep re-evaluation rule used by the `toDFXIntOf` conversion: an + * anonymous non-carry `+`/`-`/`*` cone (or a `sel`, matching Verilog's `?:`) converted to a wider + * type is re-evaluated at the target's width and sign. The warning helpers detect the + * narrow-chain/implicit-`Int` patterns whose Verilog evaluation would diverge from DFHDL's + * bit-accurate one; they are invoked from the `/`, `%`, comparison, and shift operation builders + * in `DFDecimal` and `DFBits`. + */ +private[core] object CarryPromote: + /** Deep target-context widening, matching Verilog's assignment-context width propagation: an + * anonymous non-carry `+`/`-`/`*` cone converted to a WIDER type is re-evaluated at the target's + * width and sign. Every func in the cone is retyped to the target and every leaf is converted to + * it (recursively, via `toDFXIntOf` on each argument), so all intermediates evaluate at the + * target width. Truncation to the target width commutes with `+`/`-`/`*`, so this is the unique + * evaluation that agrees with Verilog for every input; in particular a sign conversion is + * applied to the OPERANDS, never to a narrower result (zero-extending a wrapped subtraction + * result flips its sign). + * + * A `sel` is context-transparent the same way: it corresponds to Verilog's `?:`, whose branch + * operands are context-determined, so the selection re-types to the target and each branch + * re-enters the widening, while the condition passes through untouched. + * + * The candidate is taken BEFORE any sign conversion: an upstream anonymous sign-conversion alias + * (the commutative-arith sign alignment creates one) is unwrapped, or it would hide the func and + * pin the chain at its narrow width. A carry func (result wider than its operands) keeps its + * documented exact semantics and converts as a leaf; so do all other ops (shifts, bitwise, + * comparisons), whose evaluation this rule does not context-widen. + * + * Returns `None` when no widening applies, leaving the plain leaf conversion to the caller + * (`toDFXIntOf` in `DFDecimal`). + */ + private[core] def widenedOpt[RS <: Boolean, RW <: IntP, RN <: NativeType]( + lhsIR: ir.DFVal, + dfType: DFXInt[RS, RW, RN] + )(using dfc: DFC): Option[DFValOf[DFSInt[Int]]] = + import dfc.getSet + val candidateIR = signConversionRelVal(lhsIR).getOrElse(lhsIR) + + // symbolic elimination keeps this consistent with the width-fit acceptance + // rule of the TC conversion: `16 > WIDTH max 16` decides as `16 > 16` (no + // widening), so the anonymous form resolves exactly like a named + // intermediate value; if still undecidable, optimistically assume the + // target is wider. + def contextWidenCheck(funcWidth: IntParam[Int]): Boolean = + dfType.asFE[DFSInt[Int]] + .compareWidths(DFXInt(true, funcWidth, BitAccurate), elimSymbolicMaxMin = true)( + _ > _ + ) + .getOrElse(true) + + // The widened Func is BUILT FRESH rather than revised in place (an anonymous + // member is never revised; issue #449); the original cone becomes debris for + // the end-of-design sweep. The spelling of the result (a carry op or explicit + // operand widenings) is purely a PRINTING decision, reconstructed from this + // shape by the CarryFunc/Eby extractors. The widened evaluation type is the + // target itself as a bit-accurate type; an Int target widens the cone at its + // native 32-bit width (Verilog's `integer` context) and converts by the caller. + def newDT = dfType.asIR.asInstanceOf[ir.DFDecimal].copy( + magnitudeWidthParamRef = dfType.widthIntParam.ref, + nativeType = BitAccurate + ) + // an argument re-enters the full conversion, so nested cones widen and leaves + // get their sign conversion / resize at the target type + def widenedArg(argRef: ir.DFVal.Ref): DFValAny = + DFXInt.Val.Ops.toDFXIntOf( + argRef.get.asValOf[DFXInt[Boolean, Int, NativeType]] + )(DFXInt(dfType.signed, dfType.widthIntParam, BitAccurate))(using + dfc.anonymize + ) + // no MutableDB revision under meta-programming (matching `setMember`'s behavior + // there): the retyped value is returned unregistered and the argument + // conversions are skipped, since no member is registered + def rebuilt(func: ir.DFVal.Func, newArgs: => List[ir.DFVal]): DFValOf[DFSInt[Int]] = + if (dfc.inMetaProgramming) func.updateDFType(newDT).asValOf[DFSInt[Int]] + else + ir.DFVal.Func( + newDT, + func.op, + newArgs.map(_.refTW[ir.DFVal](knownReachable = true)), + dfc.ownerOrEmptyRef, + func.meta, + func.tags + ).addMember.asValOf[DFSInt[Int]] + + candidateIR match + case func @ ir.DFVal.Func( + dfType = ir.DFUInt(_) | ir.DFSInt(_), + op = FuncOp.+ | FuncOp.- | FuncOp.* + ) + if func.isAnonymous && { + // non-carry (modular) func: its type equals its aligned operands' + func.dfType =~ func.args.head.get.dfType && + contextWidenCheck(func.asValOf[DFSInt[Int]].widthIntParam) + } => + Some(rebuilt(func, func.args.map(widenedArg(_).asIR))) + case func @ ir.DFVal.Func( + dfType = ir.DFUInt(_) | ir.DFSInt(_), + op = FuncOp.sel + ) + // a sel's type structurally equals both branches' types (the frontend + // converts one branch to the other's type), so no operand-shape gate + if func.isAnonymous && + contextWidenCheck(func.asValOf[DFSInt[Int]].widthIntParam) => + Some(rebuilt(func, func.args.head.get :: func.args.tail.map(widenedArg(_).asIR))) + case _ => None + end match + end widenedOpt + + private[core] val verilogSemanticsWarnMsg = + """|Implicit Scala/DFHDL Int conversion may produce different results than Verilog. + |In Verilog, integer literals are 32-bit, which can widen intermediate arithmetic. + |In DFHDL, Int literals are converted to minimum bit-accurate width. + |Use carry operations (+^, -^, *^) or explicit bit-accurate literals (d"W'V").""".stripMargin + + // Check if a value is tagged with ImplicitlyFromIntTag. An implicit `Int` operand + // adapted to a parametric width keeps its tagged const under a resize alias (the + // fold into a single const happens only for literal widths), so the check follows + // alias chains down to the underlying value. + private[core] def hasImplicitlyFromIntTag(dfVal: ir.DFVal)(using ir.MemberGetSet): Boolean = + dfVal.tags.hasTagOf[ir.ImplicitlyFromIntTag] || + (dfVal match + case alias: ir.DFVal.Alias => hasImplicitlyFromIntTag(alias.relValRef.get) + case _ => false) + + // A width reference resolved through design parameters: this runs during + // elaboration, where a parameter's applied (or default) value is known, so a + // parametric width like `CORDW + 1` resolves to its actual value. + private def resolvedWidthOf(ref: ir.IntParamRef)(using + getSet: ir.MemberGetSet + ): Option[Int] = + ref.getIntConstData(using + getSet, + ir.ConstData.CachePolicy.GoThroughDesignParams + ) match + case ir.ConstData.KnownConst(w) => Some(w) + case _ => None + + // A value's width classified as narrow (< 32 bits). A width that cannot be + // resolved counts as narrow: a false-positive warning costs one carry op, while a + // false negative is silently wrong hardware. + private def resolvedWidthIsNarrow(dfVal: ir.DFVal)(using ir.MemberGetSet): Boolean = + dfVal.dfType match + case dec: ir.DFDecimal => + resolvedWidthOf(dec.magnitudeWidthParamRef) match + case Some(m) => m + dec.fractionWidth < 32 + case None => true + case _ => + dfVal.dfType.widthIntOpt.map(_ < 32).getOrElse(true) + + // An anonymous sign-conversion alias: an unsigned value reinterpreted as signed + // with exactly one extra bit (`.signed`). The Verilog backend emits it as + // `$signed({1'b0, ...})`, whose concatenation operand is self-determined, so a + // narrow chain stays narrow through it and the promotion/warning machinery must + // look through it. An equal-width alias is a reinterpret cast and never matches. + private def signConversionRelVal(dfVal: ir.DFVal)(using + ir.MemberGetSet + ): Option[ir.DFVal] = + dfVal match + case alias: ir.DFVal.Alias.AsIs if alias.isAnonymous => + alias.dfType match + case ir.DFSInt(aliasWidthRef) => + val relVal = alias.relValRef.get + relVal.dfType match + case ir.DFUInt(relWidthRef) => + (resolvedWidthOf(aliasWidthRef), resolvedWidthOf(relWidthRef)) match + case (Some(aw), Some(rw)) if aw == rw + 1 => Some(relVal) + case _ => None + case _ => None + case _ => None + case _ => None + + // Check if an anonymous sub-tree contains non-carry +/-/* with width < 32. + private[core] def containsNarrowNonCarryArith( + dfVal: ir.DFVal + )(using ir.MemberGetSet): Boolean = + dfVal match + case func: ir.DFVal.Func if func.isAnonymous => + func.op match + case FuncOp.+ | FuncOp.- | FuncOp.* => + // carry-ness is a SHAPE property now (operand-widened funcs, see CarryFunc) + val isNonCarry = + dfhdl.compiler.analysis.CarryFunc.unapply(func).isEmpty + val isNarrowNonCarry = isNonCarry && resolvedWidthIsNarrow(func) + isNarrowNonCarry || + func.args.exists(ref => containsNarrowNonCarryArith(ref.get)) + case _ => + func.args.exists(ref => containsNarrowNonCarryArith(ref.get)) + case _ => + signConversionRelVal(dfVal) match + case Some(relVal) => containsNarrowNonCarryArith(relVal) + case None => + dfVal match + case alias: ir.DFVal.Alias.AsIs if alias.isAnonymous => + dfhdl.compiler.analysis.Eby.unapply(alias) match + case Some(relVal, _) => containsNarrowNonCarryArith(relVal) + case None => false + case _ => false + + // Check if an anonymous sub-tree contains narrow non-carry arith that + // also has an ImplicitlyFromIntTag operand (Verilog "Forcing Larger + // Evaluation" pattern). + private[core] def containsNarrowNonCarryArithWithTaggedOperand( + dfVal: ir.DFVal + )(using ir.MemberGetSet): Boolean = + dfVal match + case func: ir.DFVal.Func if func.isAnonymous => + func.op match + case FuncOp.+ | FuncOp.- | FuncOp.* => + val isNonCarry = + dfhdl.compiler.analysis.CarryFunc.unapply(func).isEmpty + val isNarrowNonCarry = isNonCarry && resolvedWidthIsNarrow(func) + (isNarrowNonCarry && func.args.exists(ref => hasImplicitlyFromIntTag(ref.get))) || + func.args.exists(ref => + containsNarrowNonCarryArithWithTaggedOperand(ref.get) + ) + case _ => + func.args.exists(ref => + containsNarrowNonCarryArithWithTaggedOperand(ref.get) + ) + case _ => + signConversionRelVal(dfVal) match + case Some(relVal) => containsNarrowNonCarryArithWithTaggedOperand(relVal) + case None => + dfVal match + case alias: ir.DFVal.Alias.AsIs if alias.isAnonymous => + dfhdl.compiler.analysis.Eby.unapply(alias) match + case Some(relVal, _) => + containsNarrowNonCarryArithWithTaggedOperand(relVal) + case None => false + case _ => false + + // Unified Verilog-semantics warning trigger shared by `/`, `%` (arithOp) + // and comparison operations (DFXIntCompare). Warns when a narrow non-carry + // chain mixes with a tagged-from-Int operand on either side - directly OR + // nested inside the chain. + private[core] def shouldWarnVerilogSemantics( + lhs: ir.DFVal, + rhs: ir.DFVal + )(using ir.MemberGetSet): Boolean = + (hasImplicitlyFromIntTag(rhs) && containsNarrowNonCarryArith(lhs)) || + (hasImplicitlyFromIntTag(lhs) && containsNarrowNonCarryArith(rhs)) || + containsNarrowNonCarryArithWithTaggedOperand(lhs) || + containsNarrowNonCarryArithWithTaggedOperand(rhs) +end CarryPromote diff --git a/core/src/main/scala/dfhdl/core/DFBits.scala b/core/src/main/scala/dfhdl/core/DFBits.scala index 44ab0b0e8..baa05ab3d 100644 --- a/core/src/main/scala/dfhdl/core/DFBits.scala +++ b/core/src/main/scala/dfhdl/core/DFBits.scala @@ -718,14 +718,14 @@ object DFBits: import dfc.getSet // Check B: shift amount is self-determined in Verilog, // so only warn if the LHS chain itself contains a tagged operand - if DFXInt.Val.Ops.containsNarrowNonCarryArithWithTaggedOperand( + if CarryPromote.containsNarrowNonCarryArithWithTaggedOperand( lhs.asIR ) then dfc.logEvent( DFWarning( op.value.toString, - DFXInt.Val.Ops.verilogSemanticsWarnMsg + CarryPromote.verilogSemanticsWarnMsg ) ) val shiftVal = ub(lhs.widthIntParam.asInstanceOf[IntParam[LW]], rhs) diff --git a/core/src/main/scala/dfhdl/core/DFDecimal.scala b/core/src/main/scala/dfhdl/core/DFDecimal.scala index 2309290d1..6301d1600 100644 --- a/core/src/main/scala/dfhdl/core/DFDecimal.scala +++ b/core/src/main/scala/dfhdl/core/DFDecimal.scala @@ -1112,7 +1112,7 @@ object DFXInt: import dfc.getSet if ( !dfType.asIR.isDFInt32 && !rhs.dfType.asIR.isDFInt32 && - !DFXInt.Val.Ops.hasImplicitlyFromIntTag(rhs.asIR) + !CarryPromote.hasImplicitlyFromIntTag(rhs.asIR) ) // integer operands (fraction 0): the magnitude ref is the total-width // ref and may be parametric @@ -1206,10 +1206,10 @@ object DFXInt: val op = opv.value op match case FuncOp.=== | FuncOp.=!= | FuncOp.< | FuncOp.> | FuncOp.<= | FuncOp.>= => - if DFXInt.Val.Ops.shouldWarnVerilogSemantics(dfVal.asIR, dfValArg.asIR) + if CarryPromote.shouldWarnVerilogSemantics(dfVal.asIR, dfValArg.asIR) then dfc.logEvent( - DFWarning(op.toString, DFXInt.Val.Ops.verilogSemanticsWarnMsg) + DFWarning(op.toString, CarryPromote.verilogSemanticsWarnMsg) ) case _ => func(dfVal, dfValArg) @@ -1338,82 +1338,13 @@ object DFXInt: val dfValIR = if (dfType.asIR.isDFInt32 && lhs.dfType.asIR.isDFInt32) lhs.asIR else - // Deep target-context widening, matching Verilog's assignment-context width - // propagation: an anonymous non-carry +/-/* cone converted to a WIDER type - // is re-evaluated at the target's width and sign. Every func in the cone is - // retyped to the target and every leaf is converted to it (recursively, via - // toDFXIntOf on each argument), so all intermediates evaluate at the target - // width. Truncation to the target width commutes with +/-/*, so this is the - // unique evaluation that agrees with Verilog for every input; in particular - // a sign conversion is applied to the OPERANDS, never to a narrower result - // (zero-extending a wrapped subtraction result flips its sign). - // The candidate is taken BEFORE any sign conversion: an upstream anonymous - // sign-conversion alias (the commutative-arith sign alignment creates one) - // is unwrapped, or it would hide the func and pin the chain at its narrow - // width. A carry func (result wider than its operands) keeps its documented - // exact semantics and converts as a leaf; so do all non-arithmetic ops - // (shifts, selections), whose evaluation this rule does not context-widen. val signFixNeeded = !lhs.dfType.asIR.isDFInt32 && dfType.signed && !lhs.dfType.signed - val candidateIR = signConversionRelVal(lhs.asIR).getOrElse(lhs.asIR) - - // symbolic elimination keeps this consistent with the width-fit acceptance - // rule of the TC conversion: `16 > WIDTH max 16` decides as `16 > 16` (no - // widening), so the anonymous form resolves exactly like a named - // intermediate value; if still undecidable, optimistically assume the - // target is wider. - def contextWidenCheck(funcWidth: IntParam[Int]): Boolean = - dfType.asFE[DFSInt[Int]] - .compareWidths(DFXInt(true, funcWidth, BitAccurate), elimSymbolicMaxMin = true)( - _ > _ - ) - .getOrElse(true) - - val lhsConverted: DFValOf[DFSInt[Int]] = candidateIR match - case func @ ir.DFVal.Func( - dfType = ir.DFUInt(_) | ir.DFSInt(_), - op = FuncOp.+ | FuncOp.- | FuncOp.* - ) - if func.isAnonymous && { - // non-carry (modular) func: its type equals its aligned operands' - func.dfType =~ func.args.head.get.dfType && - contextWidenCheck(func.asValOf[DFSInt[Int]].widthIntParam) - } => - // The widened Func is BUILT FRESH rather than revised in place (an - // anonymous member is never revised; issue #449); the original cone - // becomes debris for the end-of-design sweep. The spelling of the result - // (a carry op or explicit operand widenings) is purely a PRINTING - // decision, reconstructed from this shape by the CarryFunc/Eby - // extractors. The widened evaluation type is the target itself as a - // bit-accurate type; an Int target widens the cone at its native 32-bit - // width (Verilog's `integer` context) and converts below. - val newDT = dfType.asIR.asInstanceOf[ir.DFDecimal].copy( - magnitudeWidthParamRef = dfType.widthIntParam.ref, - nativeType = BitAccurate - ) - if (dfc.inMetaProgramming) - // no MutableDB revision under meta-programming (matching `setMember`'s - // behavior there): the retyped value is returned unregistered and the - // argument conversions are skipped, since no member is registered - func.updateDFType(newDT).asValOf[DFSInt[Int]] - else - val widenedArgs = func.args.map { argRef => - DFXInt.Val.Ops.toDFXIntOf( - argRef.get.asValOf[DFXInt[Boolean, Int, NativeType]] - )(DFXInt(dfType.signed, dfType.widthIntParam, BitAccurate))(using - dfc.anonymize - ) - } - ir.DFVal.Func( - newDT, - func.op, - widenedArgs.map(_.asIR.refTW[ir.DFVal](knownReachable = true)), - dfc.ownerOrEmptyRef, - func.meta, - func.tags - ).addMember.asValOf[DFSInt[Int]] - end if - case _ => + // deep target-context widening first (the carry-promotion machinery, see + // CarryPromote.widenedOpt); when it does not apply, the value converts as + // a leaf below + val lhsConverted: DFValOf[DFSInt[Int]] = + CarryPromote.widenedOpt(lhs.asIR, dfType).getOrElse { // Fold stacked widenings: an anonymous same-kind widening resize alias // is transparent to a further conversion (both are value-preserving // extensions), so when the width fix below would resize anyway, it @@ -1437,7 +1368,7 @@ object DFXInt: // no widening: apply the plain sign fix when the target requires it if (signFixNeeded) base.asValOf[DFUInt[Int]].signed.asValOf[DFSInt[Int]] else base - end lhsConverted + } val nativeTypeChanged = dfType.nativeType != lhsConverted.dfType.nativeType if (nativeTypeChanged) dfType.asIR.nativeType match case Int32 => @@ -1504,142 +1435,6 @@ object DFXInt: end eby end extension - private[core] val verilogSemanticsWarnMsg = - """|Implicit Scala/DFHDL Int conversion may produce different results than Verilog. - |In Verilog, integer literals are 32-bit, which can widen intermediate arithmetic. - |In DFHDL, Int literals are converted to minimum bit-accurate width. - |Use carry operations (+^, -^, *^) or explicit bit-accurate literals (d"W'V").""".stripMargin - - // Check if a value is tagged with ImplicitlyFromIntTag. An implicit `Int` operand - // adapted to a parametric width keeps its tagged const under a resize alias (the - // fold into a single const happens only for literal widths), so the check follows - // alias chains down to the underlying value. - private[core] def hasImplicitlyFromIntTag(dfVal: ir.DFVal)(using ir.MemberGetSet): Boolean = - dfVal.tags.hasTagOf[ir.ImplicitlyFromIntTag] || - (dfVal match - case alias: ir.DFVal.Alias => hasImplicitlyFromIntTag(alias.relValRef.get) - case _ => false) - - // A width reference resolved through design parameters: this runs during - // elaboration, where a parameter's applied (or default) value is known, so a - // parametric width like `CORDW + 1` resolves to its actual value. - private def resolvedWidthOf(ref: ir.IntParamRef)(using - getSet: ir.MemberGetSet - ): Option[Int] = - ref.getIntConstData(using - getSet, - ir.ConstData.CachePolicy.GoThroughDesignParams - ) match - case ir.ConstData.KnownConst(w) => Some(w) - case _ => None - - // A value's width classified as narrow (< 32 bits). A width that cannot be - // resolved counts as narrow: a false-positive warning costs one carry op, while a - // false negative is silently wrong hardware. - private def resolvedWidthIsNarrow(dfVal: ir.DFVal)(using ir.MemberGetSet): Boolean = - dfVal.dfType match - case dec: ir.DFDecimal => - resolvedWidthOf(dec.magnitudeWidthParamRef) match - case Some(m) => m + dec.fractionWidth < 32 - case None => true - case _ => - dfVal.dfType.widthIntOpt.map(_ < 32).getOrElse(true) - - // An anonymous sign-conversion alias: an unsigned value reinterpreted as signed - // with exactly one extra bit (`.signed`). The Verilog backend emits it as - // `$signed({1'b0, ...})`, whose concatenation operand is self-determined, so a - // narrow chain stays narrow through it and the promotion/warning machinery must - // look through it. An equal-width alias is a reinterpret cast and never matches. - private def signConversionRelVal(dfVal: ir.DFVal)(using - ir.MemberGetSet - ): Option[ir.DFVal] = - dfVal match - case alias: ir.DFVal.Alias.AsIs if alias.isAnonymous => - alias.dfType match - case ir.DFSInt(aliasWidthRef) => - val relVal = alias.relValRef.get - relVal.dfType match - case ir.DFUInt(relWidthRef) => - (resolvedWidthOf(aliasWidthRef), resolvedWidthOf(relWidthRef)) match - case (Some(aw), Some(rw)) if aw == rw + 1 => Some(relVal) - case _ => None - case _ => None - case _ => None - case _ => None - - // Check if an anonymous sub-tree contains non-carry +/-/* with width < 32. - private[core] def containsNarrowNonCarryArith( - dfVal: ir.DFVal - )(using ir.MemberGetSet): Boolean = - dfVal match - case func: ir.DFVal.Func if func.isAnonymous => - func.op match - case FuncOp.+ | FuncOp.- | FuncOp.* => - // carry-ness is a SHAPE property now (operand-widened funcs, see CarryFunc) - val isNonCarry = - dfhdl.compiler.analysis.CarryFunc.unapply(func).isEmpty - val isNarrowNonCarry = isNonCarry && resolvedWidthIsNarrow(func) - isNarrowNonCarry || - func.args.exists(ref => containsNarrowNonCarryArith(ref.get)) - case _ => - func.args.exists(ref => containsNarrowNonCarryArith(ref.get)) - case _ => - signConversionRelVal(dfVal) match - case Some(relVal) => containsNarrowNonCarryArith(relVal) - case None => - dfVal match - case alias: ir.DFVal.Alias.AsIs if alias.isAnonymous => - dfhdl.compiler.analysis.Eby.unapply(alias) match - case Some(relVal, _) => containsNarrowNonCarryArith(relVal) - case None => false - case _ => false - - // Check if an anonymous sub-tree contains narrow non-carry arith that - // also has an ImplicitlyFromIntTag operand (Verilog "Forcing Larger - // Evaluation" pattern). - private[core] def containsNarrowNonCarryArithWithTaggedOperand( - dfVal: ir.DFVal - )(using ir.MemberGetSet): Boolean = - dfVal match - case func: ir.DFVal.Func if func.isAnonymous => - func.op match - case FuncOp.+ | FuncOp.- | FuncOp.* => - val isNonCarry = - dfhdl.compiler.analysis.CarryFunc.unapply(func).isEmpty - val isNarrowNonCarry = isNonCarry && resolvedWidthIsNarrow(func) - (isNarrowNonCarry && func.args.exists(ref => hasImplicitlyFromIntTag(ref.get))) || - func.args.exists(ref => - containsNarrowNonCarryArithWithTaggedOperand(ref.get) - ) - case _ => - func.args.exists(ref => - containsNarrowNonCarryArithWithTaggedOperand(ref.get) - ) - case _ => - signConversionRelVal(dfVal) match - case Some(relVal) => containsNarrowNonCarryArithWithTaggedOperand(relVal) - case None => - dfVal match - case alias: ir.DFVal.Alias.AsIs if alias.isAnonymous => - dfhdl.compiler.analysis.Eby.unapply(alias) match - case Some(relVal, _) => - containsNarrowNonCarryArithWithTaggedOperand(relVal) - case None => false - case _ => false - - // Unified Verilog-semantics warning trigger shared by `/`, `%` (arithOp) - // and comparison operations (DFXIntCompare). Warns when a narrow non-carry - // chain mixes with a tagged-from-Int operand on either side - directly OR - // nested inside the chain. - private[core] def shouldWarnVerilogSemantics( - lhs: ir.DFVal, - rhs: ir.DFVal - )(using ir.MemberGetSet): Boolean = - (hasImplicitlyFromIntTag(rhs) && containsNarrowNonCarryArith(lhs)) || - (hasImplicitlyFromIntTag(lhs) && containsNarrowNonCarryArith(rhs)) || - containsNarrowNonCarryArithWithTaggedOperand(lhs) || - containsNarrowNonCarryArithWithTaggedOperand(rhs) - // Check that a wildcard `Int` value fits in the bit-accurate value's type. // Produces an elaboration error if it doesn't. private def checkWildcardFit( @@ -1693,10 +1488,10 @@ object DFXInt: // so any narrow non-carry chain mixed with an implicit Int diverges. val shouldWarn = op match case FuncOp./ | FuncOp.% => - shouldWarnVerilogSemantics(lhs.asIR, rhsFix.asIR) + CarryPromote.shouldWarnVerilogSemantics(lhs.asIR, rhsFix.asIR) case _ => false if shouldWarn then - dfc.logEvent(DFWarning(op.toString, verilogSemanticsWarnMsg)) + dfc.logEvent(DFWarning(op.toString, CarryPromote.verilogSemanticsWarnMsg)) DFVal.Func(dfType, op, List(lhs, rhsFix)) end arithOp diff --git a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala index b06da875a..b32accb33 100644 --- a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala +++ b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala @@ -997,6 +997,42 @@ class DFDecimalSpec extends DFSpec: s9 := 3 * u2 + s8 } } + test("Arithmetic target-context widening through sel") { + val u8 = UInt(8) <> VAR + val u8b = UInt(8) <> VAR + val u9 = UInt(9) <> VAR + val u10 = UInt(10) <> VAR + val s9 = SInt(9) <> VAR + val c = Bit <> VAR + assertCodeString { + """|u9 := c.sel(u8 +^ u8, u8 -^ u8) + |u10 := c.sel(u8.eby(2) + u8.eby(2), u8.eby(2)) + |u8b := c.sel(u8 + u8, u8) + |s9 := c.sel(u8.signed - u8.signed, u8.signed + u8.signed) + |u9 := c.sel(c.sel(u8 +^ u8, u8.eby(1)), u8.eby(1)) + |u10 := c.sel(u8.eby(2) + u8.eby(2), u8.eby(2)) + u8.eby(2) + |val q = c.sel(u8 + u8, u8) + |u9 := q.eby(1) + |""".stripMargin + } { + // sel corresponds to Verilog's ?:, whose branch operands are context-determined: + // the widening crosses the selection into each branch (issue #464) + u9 := c.sel(u8 + u8, u8 - u8) + // beyond the carry width, with a plain leaf branch (widened as a leaf) + u10 := c.sel(u8 + u8, u8) + // target = sel width: untouched + u8b := c.sel(u8 + u8, u8) + // signed target: the sign conversion applies at the operands inside the branches + s9 := c.sel(u8 - u8, u8 + u8) + // nested sel: the context propagates through both levels + u9 := c.sel(c.sel(u8 + u8, u8), u8) + // sel nested inside a widened arithmetic cone + u10 := c.sel(u8 + u8, u8) + u8 + // named sel: a user-pinned boundary, extended as a value + val q = c.sel(u8 + u8, u8) + u9 := q + } + } test("Int32 arithmetic") { val param: Int <> CONST = 2 val t1 = 1 + param diff --git a/docs/transitioning/from-verilog/index.md b/docs/transitioning/from-verilog/index.md index c844a0c81..3c45dbf82 100644 --- a/docs/transitioning/from-verilog/index.md +++ b/docs/transitioning/from-verilog/index.md @@ -1297,7 +1297,7 @@ else out := b
-The `.sel` method compiles directly to Verilog's ternary operator. For complex nested conditions, prefer `if`/`else` or `match` over chaining `.sel` calls. See [Selection (.sel)][sel-ops] for details. +The `.sel` method compiles directly to Verilog's ternary operator, and it is width-faithful to it: Verilog's context-dependent width propagation crosses `?:`, and DFHDL's target-context widening likewise crosses `.sel`, so `dx := cond.sel(xb - xa, xa - xb)` into a wider `dx` re-evaluates both branches at the target width exactly as the original `?:` line does. For complex nested conditions, prefer `if`/`else` or `match` over chaining `.sel` calls; note that an inline `if`/`else` expression is NOT context-widened (its result converts as a plain value), so a widening translation of `?:` should use `.sel` or explicit carry ops. See [Selection (.sel)][sel-ops] for details. When using inline `if`/`else` as the RHS of `:=` or `:==`, **parentheses are required**. Without them, Scala 3 parses the `if` as a statement, not an expression: diff --git a/docs/user-guide/type-system/index.md b/docs/user-guide/type-system/index.md index 4e8be8ac1..f2c8a4384 100755 --- a/docs/user-guide/type-system/index.md +++ b/docs/user-guide/type-system/index.md @@ -2478,6 +2478,8 @@ Standard arithmetic operations wrap on overflow. For example, `d"8'255" + d"8'1" However, an **anonymous** arithmetic expression (`+`, `-`, `*`) that is assigned or connected to a variable **wider** than the operation's result is re-evaluated at the target's width and sign, exactly like Verilog's assignment-context width propagation: every operand, recursively through the anonymous expression, is widened to the target type, and the operations stay modular at that width. The carry operators are themselves shorthand for exactly this operand-widened evaluation (`x +^ y` is `x.eby(1) + y.eby(1)` with the operands first aligned to a common width), so when a widening lands exactly on a carry shape it prints back as the carry operator. +The widening context also crosses an anonymous `.sel`, matching Verilog's `?:` whose branch operands are context-determined: each selection branch re-evaluates at the target, while the selection condition is unaffected. The context stops at exactly three kinds of boundaries: a **named value** (a `val`-bound expression evaluates at its own declared width and only its result extends), a **carry operation** (its widened result is already exact), and any **other operation** (shifts, bitwise logic, comparisons, `if`/`match` expressions), whose result converts as a plain value. + ```scala val u8 = UInt(8) <> VAR val u9 = UInt(9) <> VAR @@ -2492,6 +2494,10 @@ u12 := u8 * u8 // beyond the carry fit: u8.eby(4) * u8.eby(4) u10 := u8 + u8 // target beyond the carry width: u8.eby(2) + u8.eby(2) s9 := u8 - u8 // unsigned to signed: operands convert, u8.signed - u8.signed +// The context crosses .sel branches (Verilog's ?:), condition untouched: +val c = Bit <> VAR +u9 := c.sel(u8 + u8, u8 - u8) // elaborates to c.sel(u8 +^ u8, u8 -^ u8) + // Implicit Int operands and whole chains evaluate at the target width: u10 := u8 + u8 + 1 // elaborates to u10 := u8.eby(2) + u8.eby(2) + d"10'1" @@ -2504,6 +2510,7 @@ u9 := sum // extended by 1: sum.eby(1) // SInt(W + 1) target: sum := a +^ b // SInt(W + 2) target: acc := a.eby(2) + b.eby(2) // SInt(2 * W) target: prod := a *^ b +// SInt(W + 1) target: dx := c.sel(b -^ a, a -^ b) ``` A parametric width relation is accepted when it holds for **every valid parameter assignment**, using the fact that widths are positive: `SInt(2 * W)` accepts a `W`-wide operation because `2 * W >= W` for any valid `W`. A relation that a valid assignment can violate is definitively rejected (`SInt(W)` never fits a `2 * W`-wide value), and an undecidable one (e.g. a literal target such as `SInt(16)` against a free `W`, which may exceed 16) is conservatively rejected as well; both still require an explicit carry op or `.resize` to state the intent. diff --git a/lib/src/test/scala/ContextWidenSpec.scala b/lib/src/test/scala/ContextWidenSpec.scala index 995e99b07..68db34b3b 100644 --- a/lib/src/test/scala/ContextWidenSpec.scala +++ b/lib/src/test/scala/ContextWidenSpec.scala @@ -96,6 +96,34 @@ class ContextWidenSpec extends DesignSpec: ) } + test("parametric sel target-context widening") { + @top(false) class SelWiden(val W: Int <> CONST = 4) extends EDDesign: + val a, b = SInt(W) <> IN + val c = Bit <> IN + val viaSel = SInt(W + 1) <> OUT + val wide = SInt(W + 2) <> OUT + // sel is Verilog's ?:, whose branch operands are context-determined: the + // widening crosses the selection and each branch re-evaluates at the target + // width (issue #464: previously the branches stayed modular at W and only + // the selection result was extended) + viaSel <> c.sel(b - a, a - b) + wide <> c.sel(b - a, a - b) + end SelWiden + + SelWiden().assertCodeString( + """|class SelWiden(val W: Int <> CONST = 4) extends EDDesign: + | val a = SInt(W) <> IN + | val b = SInt(W) <> IN + | val c = Bit <> IN + | val viaSel = SInt(W + 1) <> OUT + | val wide = SInt(W + 2) <> OUT + | viaSel <> c.sel(b -^ a, a -^ b) + | wide <> c.sel(b.eby(2) - a.eby(2), a.eby(2) - b.eby(2)) + |end SelWiden + |""".stripMargin + ) + } + test("explicit eby") { @top(false) class Eby(val W: Int <> CONST = 8) extends EDDesign: val a = SInt(W) <> IN From 4c33ad5ceacad1d0476505baab319ab61d9208db Mon Sep 17 00:00:00 2001 From: Oron Port Date: Sun, 9 Aug 2026 13:27:41 +0300 Subject: [PATCH 22/25] core+docs: target-context widening crosses `if`/`match` expressions (#464) A conditional expression in a type-free position (a connection RHS or an operand of a wider operation) was typed by its branches and converted as a leaf, evaluating the branches modularly at the narrow width and only extending the selected result, silently diverging from the per-branch assignments it lowers to. An anonymous DFXInt-typed conditional header converted to a wider type now re-evaluates each branch at the target: a fresh terminal ident over the widened re-evaluation is built inside each branch block (branch-local named values stay in scope), the superseded terminal is dropped explicitly (idents are consumed positionally, so the sweep alone would keep the narrow cone alive), and the header is revised in place to the target type, the same revision its construction applies. The type-driven positions were already faithful (each branch converts under the expected type at construction) and are regression-pinned. Mid-construction the designDB flat snapshot is unavailable (open-owner state), so the conditional structure is recovered from the raw creation-ordered member list via ref walks; and to keep that list properly nested, MutableDB gains a re-entrant `insertingAfter` insertion mode that places additions right after an anchor inside a built block. All four probe shapes (connection, arith-operand, match, nested if) are SAT-proven equivalent to a hand-written `?:` golden via a yosys miter. Co-Authored-By: Claude Fable 5 --- .../scala/StagesSpec/PrintVHDLCodeSpec.scala | 11 ++- .../StagesSpec/PrintVerilogCodeSpec.scala | 10 ++- .../main/scala/dfhdl/core/CarryPromote.scala | 78 +++++++++++++++++-- .../src/main/scala/dfhdl/core/MutableDB.scala | 33 +++++++- .../test/scala/CoreSpec/DFDecimalSpec.scala | 34 ++++++++ docs/transitioning/from-verilog/index.md | 2 +- docs/user-guide/type-system/index.md | 4 +- lib/src/test/scala/ContextWidenSpec.scala | 36 +++++++++ 8 files changed, 196 insertions(+), 12 deletions(-) diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala index 2950db9fe..cea495b10 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala @@ -3495,6 +3495,7 @@ class PrintVHDLCodeSpec extends StageSpec: val uprod = UInt(2 * W) <> OUT val c = Bit <> IN val viaSel = SInt(W + 1) <> OUT + val viaIf = SInt(W + 1) <> OUT sum <> a + b usub <> ua - ub acc <> a + b @@ -3502,6 +3503,7 @@ class PrintVHDLCodeSpec extends StageSpec: prod <> a * b uprod <> ua * ub viaSel <> c.sel(b - a, a - b) + viaIf <> (if (c) b - a else a - b) end ParamWiden val top = ParamWiden().getCompiledCodeString assertNoDiff( @@ -3527,7 +3529,8 @@ class PrintVHDLCodeSpec extends StageSpec: | prod : out signed((2 * W) - 1 downto 0); | uprod : out unsigned((2 * W) - 1 downto 0); | c : in std_logic; - | viaSel : out signed((W + 1) - 1 downto 0) + | viaSel : out signed((W + 1) - 1 downto 0); + | viaIf : out signed((W + 1) - 1 downto 0) |); |end ParamWiden; | @@ -3540,6 +3543,12 @@ class PrintVHDLCodeSpec extends StageSpec: | prod <= a * b; | uprod <= ua * ub; | viaSel <= bool_sel(to_bool(c), csub(b, a), csub(a, b)); + | process (all) + | begin + | if c then viaIf <= csub(b, a); + | else viaIf <= csub(a, b); + | end if; + | end process; |end ParamWiden_arch; |""".stripMargin ) diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala index 7d93cf795..72d11174c 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala @@ -3317,6 +3317,7 @@ class PrintVerilogCodeSpec extends StageSpec: val uprod = UInt(2 * W) <> OUT val c = Bit <> IN val viaSel = SInt(W + 1) <> OUT + val viaIf = SInt(W + 1) <> OUT sum <> a + b usub <> ua - ub acc <> a + b @@ -3324,6 +3325,7 @@ class PrintVerilogCodeSpec extends StageSpec: prod <> a * b uprod <> ua * ub viaSel <> c.sel(b - a, a - b) + viaIf <> (if (c) b - a else a - b) end ParamWiden val top = ParamWiden().getCompiledCodeString assertNoDiff( @@ -3343,7 +3345,8 @@ class PrintVerilogCodeSpec extends StageSpec: | output logic signed [(2 * W) - 1:0] prod, | output logic [(2 * W) - 1:0] uprod, | input wire logic c, - | output logic signed [(W + 1) - 1:0] viaSel + | output logic signed [(W + 1) - 1:0] viaSel, + | output logic signed [(W + 1) - 1:0] viaIf |); | `include "dfhdl_defs.svh" | assign sum = a + b; @@ -3353,6 +3356,11 @@ class PrintVerilogCodeSpec extends StageSpec: | assign prod = a * b; | assign uprod = ua * ub; | assign viaSel = c ? (b - a) : (a - b); + | always_comb + | begin + | if (c) viaIf = b - a; + | else viaIf = a - b; + | end |endmodule |""".stripMargin ) diff --git a/core/src/main/scala/dfhdl/core/CarryPromote.scala b/core/src/main/scala/dfhdl/core/CarryPromote.scala index 59eb4ac8b..3b27c6ddc 100644 --- a/core/src/main/scala/dfhdl/core/CarryPromote.scala +++ b/core/src/main/scala/dfhdl/core/CarryPromote.scala @@ -10,8 +10,9 @@ import DFDecimal.Extensions.* * Verilog-semantics warning machinery. * * [[widenedOpt]] holds the deep re-evaluation rule used by the `toDFXIntOf` conversion: an - * anonymous non-carry `+`/`-`/`*` cone (or a `sel`, matching Verilog's `?:`) converted to a wider - * type is re-evaluated at the target's width and sign. The warning helpers detect the + * anonymous non-carry `+`/`-`/`*` cone (or a `sel`, matching Verilog's `?:`, or an `if`/`match` + * expression, matching the per-branch assignments it lowers to) converted to a wider type is + * re-evaluated at the target's width and sign. The warning helpers detect the * narrow-chain/implicit-`Int` patterns whose Verilog evaluation would diverge from DFHDL's * bit-accurate one; they are invoked from the `/`, `%`, comparison, and shift operation builders * in `DFDecimal` and `DFBits`. @@ -28,7 +29,9 @@ private[core] object CarryPromote: * * A `sel` is context-transparent the same way: it corresponds to Verilog's `?:`, whose branch * operands are context-determined, so the selection re-types to the target and each branch - * re-enters the widening, while the condition passes through untouched. + * re-enters the widening, while the condition passes through untouched. An `if`/`match` + * EXPRESSION is likewise transparent, matching the per-branch assignments it lowers to; its + * blocks are revised in place (see the conditional-header case below for the mechanics). * * The candidate is taken BEFORE any sign conversion: an upstream anonymous sign-conversion alias * (the commutative-arith sign alignment creates one) is unwrapped, or it would hide the func and @@ -69,14 +72,15 @@ private[core] object CarryPromote: magnitudeWidthParamRef = dfType.widthIntParam.ref, nativeType = BitAccurate ) - // an argument re-enters the full conversion, so nested cones widen and leaves + // a nested value re-enters the full conversion, so nested cones widen and leaves // get their sign conversion / resize at the target type - def widenedArg(argRef: ir.DFVal.Ref): DFValAny = + def widened(v: ir.DFVal): DFValAny = DFXInt.Val.Ops.toDFXIntOf( - argRef.get.asValOf[DFXInt[Boolean, Int, NativeType]] + v.asValOf[DFXInt[Boolean, Int, NativeType]] )(DFXInt(dfType.signed, dfType.widthIntParam, BitAccurate))(using dfc.anonymize ) + def widenedArg(argRef: ir.DFVal.Ref): DFValAny = widened(argRef.get) // no MutableDB revision under meta-programming (matching `setMember`'s behavior // there): the retyped value is returned unregistered and the argument // conversions are skipped, since no member is registered @@ -112,6 +116,68 @@ private[core] object CarryPromote: if func.isAnonymous && contextWidenCheck(func.asValOf[DFSInt[Int]].widthIntParam) => Some(rebuilt(func, func.args.head.get :: func.args.tail.map(widenedArg(_).asIR))) + // A conditional EXPRESSION (if/match) re-evaluates each branch at the target, + // matching the Verilog its branches lower to (per-branch assignments to the + // wider target). The type-driven construction (fromBranchesExact1/fromCasesExact) + // already converts inside the branches; this covers the type-free positions + // (an operand of a wider operation, a connection RHS), where the header was + // typed by its branches. Each branch's terminal ident is superseded by a fresh + // ident over the branch value's widened re-evaluation, built INSIDE the branch + // block (so branch-local named values stay in scope); the old terminal and cone + // become debris for the end-of-design sweep, and the header is revised in place + // to the target type, the same revision its construction applies. + case header: ir.DFConditional.Header + if header.isAnonymous && + (header.dfType match + case ir.DFUInt(_) | ir.DFSInt(_) => true + case _ => false) && contextWidenCheck(header.asValOf[DFSInt[Int]].widthIntParam) => + if (dfc.inMetaProgramming) Some(header.updateDFType(newDT).asValOf[DFSInt[Int]]) + else + import dfhdl.compiler.analysis.{getHeaderCB, Ident} + // this runs MID-construction (the enclosing statement is still being built), so + // the conditional's structure is recovered from the raw creation-ordered member + // list of the current design context via plain ref walks; a designDB snapshot + // (`members`/`getCBList`) is not available in this state + val memberList = dfc.mutableDB.DesignContext.current.getImmutableMemberList + val blocks = memberList.collect { + case cb: ir.DFConditional.Block if cb.getHeaderCB == header => cb + } + val blockSet = blocks.toSet + // each block's terminal is its LAST directly-owned value (nested constructs in + // the branch body own their internals, so they never shadow the terminal ident) + val lastOwnedByBlock = + memberList.foldLeft(Map.empty[ir.DFConditional.Block, ir.DFVal]) { (acc, m) => + m match + case v: ir.DFVal => + v.ownerRef.get match + case cb: ir.DFConditional.Block if blockSet(cb) => acc.updated(cb, v) + case _ => acc + case _ => acc + } + val branchVals = blocks.flatMap { block => + lastOwnedByBlock.get(block).collect { + case ident @ Ident(underlying) => (block, ident, underlying) + } + } + // all-or-nothing: an unexpected branch shape (no terminal ident) leaves the + // whole conversion to the caller's leaf path + if (branchVals.sizeCompare(blocks) != 0) None + else + branchVals.foreach { (block, oldIdent, branchVal) => + // the widened members are INSERTED after the old terminal, inside the + // block's span, keeping the flat member list properly nested + dfc.mutableDB.insertingAfter(oldIdent) { + dfc.enterOwner(block.asFE) + DFVal.Alias.AsIs.ident(widened(branchVal))(using dfc.anonymize) + dfc.exitOwner() + } + // the superseded terminal is dropped explicitly: an ident is consumed + // positionally (never by reference), so the sweep alone would keep it + // and, through it, the superseded narrow cone + dfc.mutableDB.ignoreMember(oldIdent) + } + Some(header.replaceMemberWith(header.updateDFType(newDT)).asValOf[DFSInt[Int]]) + end if case _ => None end match end widenedOpt diff --git a/core/src/main/scala/dfhdl/core/MutableDB.scala b/core/src/main/scala/dfhdl/core/MutableDB.scala index 5854930cb..fa3a80d10 100644 --- a/core/src/main/scala/dfhdl/core/MutableDB.scala +++ b/core/src/main/scala/dfhdl/core/MutableDB.scala @@ -103,9 +103,28 @@ class DesignContext: def setOriginRefs(member: DFMember): Unit = member.getRefs.foreach { r => originRefTable += r -> member } + // ~~~ positional insertion (see MutableDB.insertingAfter) ~~~ + // While anchors are set, `addMember` INSERTS each new member right after the head + // anchor instead of appending, and the added member becomes the head anchor, so a + // sequence of additions lays out in creation order at the insertion point. This keeps + // the flat member list properly nested when constructing into an already-built block. + // A stack of anchors supports re-entry (a nested insertion at an earlier position + // shifts later indices, so every addition re-resolves its anchor through memberTable). + var insertAnchors = List.empty[DFMember] + def addMember[M <: DFMember](member: M): M = - memberTable += (member -> members.length) - members += MemberEntry(member, Set(), false) + insertAnchors match + case anchor :: rest => + val idx = memberTable(anchor) + 1 + members.insert(idx, MemberEntry(member, Set(), false)) + var i = idx + while (i < members.length) + memberTable.update(members(i).irValue, i) + i += 1 + insertAnchors = member :: rest + case Nil => + memberTable += (member -> members.length) + members += MemberEntry(member, Set(), false) setOriginRefs(member) member end addMember @@ -940,6 +959,16 @@ final class MutableDB(): injectGlobals(dfVal.globalCtx.asInstanceOf[DesignContext]) case _ => + // Runs `body` with member additions INSERTED right after `anchor` in the current + // design context instead of appended, so constructing into an already-built block + // (the widened conditional branches of `CarryPromote`) keeps the flat member list + // properly nested. Re-entrant: a nested insertion stacks its own anchor. + def insertingAfter[T](anchor: DFMember)(body: => T): T = + val ctx = DesignContext.current + ctx.insertAnchors = anchor :: ctx.insertAnchors + try body + finally ctx.insertAnchors = ctx.insertAnchors.drop(1) + def setMember[M <: DFMember](originalMember: M, newMemberFunc: M => M): M = if (inMetaProgramming) newMemberFunc(originalMember) else diff --git a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala index b32accb33..bec47f7ef 100644 --- a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala +++ b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala @@ -1033,6 +1033,40 @@ class DFDecimalSpec extends DFSpec: u9 := q } } + test("Arithmetic target-context widening through conditional expressions") { + val u8 = UInt(8) <> VAR + val u9 = UInt(9) <> VAR + val u10 = UInt(10) <> VAR + val c = Bit <> VAR + assertCodeString { + """|u9 := (( + | if (c) u8 +^ u8 + | else u8 -^ u8 + |): UInt[9] <> VAL) + |u10 := (( + | if (c) u8.eby(2) + u8.eby(2) + | else u8.eby(2) + |): UInt[10] <> VAL) + u8.eby(2) + |u10 := (( + | c match + | case 1 => u8.eby(2) + u8.eby(2) + | case _ => u8.eby(2) + | end match + |): UInt[10] <> VAL) + u8.eby(2) + |""".stripMargin + } { + // a type-driven position converts each branch at construction (the plugin's + // Exact1 route), landing on the same widened form + u9 := (if (c) u8 + u8 else u8 - u8) + // a type-free position (an operand of a wider operation): the conditional header + // was typed by its branches and re-evaluates at the target per branch (issue #464) + u10 := (if (c) u8 + u8 else u8) + u8 + u10 := (c match + case 1 => u8 + u8 + case _ => u8 + ) + u8 + } + } test("Int32 arithmetic") { val param: Int <> CONST = 2 val t1 = 1 + param diff --git a/docs/transitioning/from-verilog/index.md b/docs/transitioning/from-verilog/index.md index 3c45dbf82..cfd8805b6 100644 --- a/docs/transitioning/from-verilog/index.md +++ b/docs/transitioning/from-verilog/index.md @@ -1297,7 +1297,7 @@ else out := b -The `.sel` method compiles directly to Verilog's ternary operator, and it is width-faithful to it: Verilog's context-dependent width propagation crosses `?:`, and DFHDL's target-context widening likewise crosses `.sel`, so `dx := cond.sel(xb - xa, xa - xb)` into a wider `dx` re-evaluates both branches at the target width exactly as the original `?:` line does. For complex nested conditions, prefer `if`/`else` or `match` over chaining `.sel` calls; note that an inline `if`/`else` expression is NOT context-widened (its result converts as a plain value), so a widening translation of `?:` should use `.sel` or explicit carry ops. See [Selection (.sel)][sel-ops] for details. +The `.sel` method compiles directly to Verilog's ternary operator, and all three forms are width-faithful to it: Verilog's context-dependent width propagation crosses `?:`, and DFHDL's target-context widening likewise crosses `.sel` and `if`/`else` (and `match`) expression branches, so `dx := cond.sel(xb - xa, xa - xb)` into a wider `dx` re-evaluates both branches at the target width exactly as the original `?:` line does. For complex nested conditions, prefer `if`/`else` or `match` over chaining `.sel` calls. See [Selection (.sel)][sel-ops] for details. When using inline `if`/`else` as the RHS of `:=` or `:==`, **parentheses are required**. Without them, Scala 3 parses the `if` as a statement, not an expression: diff --git a/docs/user-guide/type-system/index.md b/docs/user-guide/type-system/index.md index f2c8a4384..0bc46ae07 100755 --- a/docs/user-guide/type-system/index.md +++ b/docs/user-guide/type-system/index.md @@ -2478,7 +2478,7 @@ Standard arithmetic operations wrap on overflow. For example, `d"8'255" + d"8'1" However, an **anonymous** arithmetic expression (`+`, `-`, `*`) that is assigned or connected to a variable **wider** than the operation's result is re-evaluated at the target's width and sign, exactly like Verilog's assignment-context width propagation: every operand, recursively through the anonymous expression, is widened to the target type, and the operations stay modular at that width. The carry operators are themselves shorthand for exactly this operand-widened evaluation (`x +^ y` is `x.eby(1) + y.eby(1)` with the operands first aligned to a common width), so when a widening lands exactly on a carry shape it prints back as the carry operator. -The widening context also crosses an anonymous `.sel`, matching Verilog's `?:` whose branch operands are context-determined: each selection branch re-evaluates at the target, while the selection condition is unaffected. The context stops at exactly three kinds of boundaries: a **named value** (a `val`-bound expression evaluates at its own declared width and only its result extends), a **carry operation** (its widened result is already exact), and any **other operation** (shifts, bitwise logic, comparisons, `if`/`match` expressions), whose result converts as a plain value. +The widening context also crosses an anonymous `.sel` (matching Verilog's `?:`, whose branch operands are context-determined) and anonymous `if`/`match` **expressions** (matching the per-branch assignments they lower to): each branch re-evaluates at the target, while the selection condition or match selector is unaffected. The context stops at exactly three kinds of boundaries: a **named value** (a `val`-bound expression evaluates at its own declared width and only its result extends), a **carry operation** (its widened result is already exact), and any **other operation** (shifts, bitwise logic, comparisons), whose result converts as a plain value. ```scala val u8 = UInt(8) <> VAR @@ -2497,6 +2497,8 @@ s9 := u8 - u8 // unsigned to signed: operands convert, u8.signed - u8.signed // The context crosses .sel branches (Verilog's ?:), condition untouched: val c = Bit <> VAR u9 := c.sel(u8 + u8, u8 - u8) // elaborates to c.sel(u8 +^ u8, u8 -^ u8) +// ... and if/match EXPRESSION branches the same way: +u9 := (if (c) u8 + u8 else u8 - u8) // each branch elaborates as a carry op // Implicit Int operands and whole chains evaluate at the target width: u10 := u8 + u8 + 1 // elaborates to u10 := u8.eby(2) + u8.eby(2) + d"10'1" diff --git a/lib/src/test/scala/ContextWidenSpec.scala b/lib/src/test/scala/ContextWidenSpec.scala index 68db34b3b..6581d4602 100644 --- a/lib/src/test/scala/ContextWidenSpec.scala +++ b/lib/src/test/scala/ContextWidenSpec.scala @@ -124,6 +124,42 @@ class ContextWidenSpec extends DesignSpec: ) } + test("parametric conditional-expression target-context widening") { + @top(false) class CondWiden(val W: Int <> CONST = 4) extends EDDesign: + val a, b = SInt(W) <> IN + val c = Bit <> IN + val viaConn = SInt(W + 1) <> OUT + val viaArith = SInt(W + 2) <> OUT + // an anonymous conditional EXPRESSION re-evaluates each branch at the target, + // matching the per-branch assignments it lowers to (issue #464); these are the + // type-free positions (connection RHS, operand of a wider operation), where the + // header was typed by its branches + viaConn <> (if (c) b - a else a - b) + viaArith <> (if (c) b - a else a - b) + a + end CondWiden + + CondWiden().assertCodeString( + """|class CondWiden(val W: Int <> CONST = 4) extends EDDesign: + | val a = SInt(W) <> IN + | val b = SInt(W) <> IN + | val c = Bit <> IN + | val viaConn = SInt(W + 1) <> OUT + | val viaArith = SInt(W + 2) <> OUT + | viaConn <> (( + | if (c) b -^ a + | else a -^ b + | ): SInt[W + 1] <> VAL) + | viaArith <> ( + | (( + | if (c) b.eby(2) - a.eby(2) + | else a.eby(2) - b.eby(2) + | ): SInt[W + 2] <> VAL) + a.eby(2) + | ) + |end CondWiden + |""".stripMargin + ) + } + test("explicit eby") { @top(false) class Eby(val W: Int <> CONST = 8) extends EDDesign: val a = SInt(W) <> IN From 88de1ce53d849a9eb392b02b74e36d9fd1cb05e0 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Sun, 9 Aug 2026 14:21:43 +0300 Subject: [PATCH 23/25] core+docs: widening crosses shift left operands and unary minus; warnings see through conditionals (#464) Two remaining divergences of the target-context widening model and one diagnostic gap: - A shift's LEFT operand is context-determined in Verilog (the amount is self-determined), so an anonymous shift converted to a wider SAME-SIGN type now re-types to the target and its left operand re-enters the widening: the carry bit survives a `>>` and a `<<` pushes into the extension range. A sign-CROSSING shift context stays a leaf: a shift evaluates at its operand's own signedness (an arithmetic-vs-logical `>>` difference), so the conversion cannot move to the operands and the explicit spelling states the intent there. - Unary minus is truncation-commutative and joins the widened arithmetic ops, so a negation cone re-evaluates at the target instead of wrapping narrow and extending. - The Verilog-semantics narrow-chain detectors descend anonymous conditional-expression branches (via the recovery shared with the widening), so a chain hidden one `if`/`match` away from a `/`, `%`, comparison, or shift is no longer silently missed. All six probe shapes are SAT-proven equivalent to a hand-written golden via a yosys miter. Also drops two redundant `asInstanceOf[ir.DFDecimal]` casts (`asIR` is precisely typed). Co-Authored-By: Claude Fable 5 --- .../scala/StagesSpec/PrintVHDLCodeSpec.scala | 10 +- .../StagesSpec/PrintVerilogCodeSpec.scala | 10 +- .../main/scala/dfhdl/core/CarryPromote.scala | 126 ++++++++++++------ .../test/scala/CoreSpec/DFDecimalSpec.scala | 46 ++++++- docs/user-guide/type-system/index.md | 8 +- lib/src/test/scala/ContextWidenSpec.scala | 27 ++++ 6 files changed, 180 insertions(+), 47 deletions(-) diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala index cea495b10..4e6e36471 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVHDLCodeSpec.scala @@ -3496,6 +3496,8 @@ class PrintVHDLCodeSpec extends StageSpec: val c = Bit <> IN val viaSel = SInt(W + 1) <> OUT val viaIf = SInt(W + 1) <> OUT + val shr = SInt(W + 2) <> OUT + val neg = SInt(W + 2) <> OUT sum <> a + b usub <> ua - ub acc <> a + b @@ -3504,6 +3506,8 @@ class PrintVHDLCodeSpec extends StageSpec: uprod <> ua * ub viaSel <> c.sel(b - a, a - b) viaIf <> (if (c) b - a else a - b) + shr <> (a + b) >> 1 + neg <> -(a + b) end ParamWiden val top = ParamWiden().getCompiledCodeString assertNoDiff( @@ -3530,7 +3534,9 @@ class PrintVHDLCodeSpec extends StageSpec: | uprod : out unsigned((2 * W) - 1 downto 0); | c : in std_logic; | viaSel : out signed((W + 1) - 1 downto 0); - | viaIf : out signed((W + 1) - 1 downto 0) + | viaIf : out signed((W + 1) - 1 downto 0); + | shr : out signed((W + 2) - 1 downto 0); + | neg : out signed((W + 2) - 1 downto 0) |); |end ParamWiden; | @@ -3549,6 +3555,8 @@ class PrintVHDLCodeSpec extends StageSpec: | else viaIf <= csub(a, b); | end if; | end process; + | shr <= signed_sra(eby(a, 2) + eby(b, 2), 1); + | neg <= -(eby(a, 2) + eby(b, 2)); |end ParamWiden_arch; |""".stripMargin ) diff --git a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala index 72d11174c..6c35d0013 100644 --- a/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala +++ b/compiler/stages/src/test/scala/StagesSpec/PrintVerilogCodeSpec.scala @@ -3318,6 +3318,8 @@ class PrintVerilogCodeSpec extends StageSpec: val c = Bit <> IN val viaSel = SInt(W + 1) <> OUT val viaIf = SInt(W + 1) <> OUT + val shr = SInt(W + 2) <> OUT + val neg = SInt(W + 2) <> OUT sum <> a + b usub <> ua - ub acc <> a + b @@ -3326,6 +3328,8 @@ class PrintVerilogCodeSpec extends StageSpec: uprod <> ua * ub viaSel <> c.sel(b - a, a - b) viaIf <> (if (c) b - a else a - b) + shr <> (a + b) >> 1 + neg <> -(a + b) end ParamWiden val top = ParamWiden().getCompiledCodeString assertNoDiff( @@ -3346,7 +3350,9 @@ class PrintVerilogCodeSpec extends StageSpec: | output logic [(2 * W) - 1:0] uprod, | input wire logic c, | output logic signed [(W + 1) - 1:0] viaSel, - | output logic signed [(W + 1) - 1:0] viaIf + | output logic signed [(W + 1) - 1:0] viaIf, + | output logic signed [(W + 2) - 1:0] shr, + | output logic signed [(W + 2) - 1:0] neg |); | `include "dfhdl_defs.svh" | assign sum = a + b; @@ -3361,6 +3367,8 @@ class PrintVerilogCodeSpec extends StageSpec: | if (c) viaIf = b - a; | else viaIf = a - b; | end + | assign shr = (`EBY_S(a, 2) + `EBY_S(b, 2)) >>> 1; + | assign neg = -(`EBY_S(a, 2) + `EBY_S(b, 2)); |endmodule |""".stripMargin ) diff --git a/core/src/main/scala/dfhdl/core/CarryPromote.scala b/core/src/main/scala/dfhdl/core/CarryPromote.scala index 3b27c6ddc..6a6a43395 100644 --- a/core/src/main/scala/dfhdl/core/CarryPromote.scala +++ b/core/src/main/scala/dfhdl/core/CarryPromote.scala @@ -31,13 +31,15 @@ private[core] object CarryPromote: * operands are context-determined, so the selection re-types to the target and each branch * re-enters the widening, while the condition passes through untouched. An `if`/`match` * EXPRESSION is likewise transparent, matching the per-branch assignments it lowers to; its - * blocks are revised in place (see the conditional-header case below for the mechanics). + * blocks are revised in place (see the conditional-header case below for the mechanics). A + * shift's LEFT operand is context-determined too (the amount is self-determined), gated on the + * target keeping the operand's signedness (see the shift case below). * * The candidate is taken BEFORE any sign conversion: an upstream anonymous sign-conversion alias * (the commutative-arith sign alignment creates one) is unwrapped, or it would hide the func and * pin the chain at its narrow width. A carry func (result wider than its operands) keeps its - * documented exact semantics and converts as a leaf; so do all other ops (shifts, bitwise, - * comparisons), whose evaluation this rule does not context-widen. + * documented exact semantics and converts as a leaf; so do all other ops (bitwise logic, + * comparisons, rotations), whose evaluation this rule does not context-widen. * * Returns `None` when no widening applies, leaving the plain leaf conversion to the caller * (`toDFXIntOf` in `DFDecimal`). @@ -68,7 +70,7 @@ private[core] object CarryPromote: // shape by the CarryFunc/Eby extractors. The widened evaluation type is the // target itself as a bit-accurate type; an Int target widens the cone at its // native 32-bit width (Verilog's `integer` context) and converts by the caller. - def newDT = dfType.asIR.asInstanceOf[ir.DFDecimal].copy( + def newDT = dfType.asIR.copy( magnitudeWidthParamRef = dfType.widthIntParam.ref, nativeType = BitAccurate ) @@ -99,7 +101,7 @@ private[core] object CarryPromote: candidateIR match case func @ ir.DFVal.Func( dfType = ir.DFUInt(_) | ir.DFSInt(_), - op = FuncOp.+ | FuncOp.- | FuncOp.* + op = FuncOp.+ | FuncOp.- | FuncOp.* | FuncOp.unary_- ) if func.isAnonymous && { // non-carry (modular) func: its type equals its aligned operands' @@ -107,6 +109,22 @@ private[core] object CarryPromote: contextWidenCheck(func.asValOf[DFSInt[Int]].widthIntParam) } => Some(rebuilt(func, func.args.map(widenedArg(_).asIR))) + // A shift's LEFT operand is context-determined in Verilog (the amount is + // self-determined), so an anonymous shift converted to a wider SAME-SIGN type + // re-types to the target and its left operand re-enters the widening: the high + // bits a narrow evaluation would lose (`>>` bringing down a carry bit, `<<` + // pushing into the extension range) are exactly what the context preserves. A + // sign-CROSSING shift context stays a leaf: a shift evaluates at its operand's + // own signedness (an arithmetic-vs-logical `>>` difference), so the sign + // conversion cannot move to the operands; the explicit spelling states the + // intent there. + case func @ ir.DFVal.Func( + dfType = ir.DFDecimal(funcSigned, _, 0, BitAccurate), + op = FuncOp.>> | FuncOp.<< + ) + if func.isAnonymous && funcSigned == dfType.asIR.signed && + contextWidenCheck(func.asValOf[DFSInt[Int]].widthIntParam) => + Some(rebuilt(func, widenedArg(func.args.head).asIR :: func.args.tail.map(_.get))) case func @ ir.DFVal.Func( dfType = ir.DFUInt(_) | ir.DFSInt(_), op = FuncOp.sel @@ -133,36 +151,9 @@ private[core] object CarryPromote: case _ => false) && contextWidenCheck(header.asValOf[DFSInt[Int]].widthIntParam) => if (dfc.inMetaProgramming) Some(header.updateDFType(newDT).asValOf[DFSInt[Int]]) else - import dfhdl.compiler.analysis.{getHeaderCB, Ident} - // this runs MID-construction (the enclosing statement is still being built), so - // the conditional's structure is recovered from the raw creation-ordered member - // list of the current design context via plain ref walks; a designDB snapshot - // (`members`/`getCBList`) is not available in this state - val memberList = dfc.mutableDB.DesignContext.current.getImmutableMemberList - val blocks = memberList.collect { - case cb: ir.DFConditional.Block if cb.getHeaderCB == header => cb - } - val blockSet = blocks.toSet - // each block's terminal is its LAST directly-owned value (nested constructs in - // the branch body own their internals, so they never shadow the terminal ident) - val lastOwnedByBlock = - memberList.foldLeft(Map.empty[ir.DFConditional.Block, ir.DFVal]) { (acc, m) => - m match - case v: ir.DFVal => - v.ownerRef.get match - case cb: ir.DFConditional.Block if blockSet(cb) => acc.updated(cb, v) - case _ => acc - case _ => acc - } - val branchVals = blocks.flatMap { block => - lastOwnedByBlock.get(block).collect { - case ident @ Ident(underlying) => (block, ident, underlying) - } - } // all-or-nothing: an unexpected branch shape (no terminal ident) leaves the // whole conversion to the caller's leaf path - if (branchVals.sizeCompare(blocks) != 0) None - else + condBranchTerminals(header).map { branchVals => branchVals.foreach { (block, oldIdent, branchVal) => // the widened members are INSERTED after the old terminal, inside the // block's span, keeping the flat member list properly nested @@ -176,12 +167,48 @@ private[core] object CarryPromote: // and, through it, the superseded narrow cone dfc.mutableDB.ignoreMember(oldIdent) } - Some(header.replaceMemberWith(header.updateDFType(newDT)).asValOf[DFSInt[Int]]) - end if + header.replaceMemberWith(header.updateDFType(newDT)).asValOf[DFSInt[Int]] + } case _ => None end match end widenedOpt + // The branch blocks, terminal idents, and terminal values of a conditional + // EXPRESSION, recovered from the raw creation-ordered member list of the current + // design context via plain ref walks. This runs MID-construction (the enclosing + // statement is still being built), where a designDB flat snapshot (`members`, + // `getCBList`) is unavailable: its owner-member generation requires the closed, + // properly-nested state. Each block's terminal is its LAST directly-owned value + // (nested constructs in a branch body own their internals, so they never shadow + // the terminal ident). Returns None when any branch lacks a terminal ident (an + // unexpected shape). + private def condBranchTerminals(header: ir.DFConditional.Header)(using + dfc: DFC + ): Option[List[(ir.DFConditional.Block, ir.DFVal, ir.DFVal)]] = + import dfc.getSet + import dfhdl.compiler.analysis.{getHeaderCB, Ident} + val memberList = dfc.mutableDB.DesignContext.current.getImmutableMemberList + val blocks = memberList.collect { + case cb: ir.DFConditional.Block if cb.getHeaderCB == header => cb + } + val blockSet = blocks.toSet + val lastOwnedByBlock = + memberList.foldLeft(Map.empty[ir.DFConditional.Block, ir.DFVal]) { (acc, m) => + m match + case v: ir.DFVal => + v.ownerRef.get match + case cb: ir.DFConditional.Block if blockSet(cb) => acc.updated(cb, v) + case _ => acc + case _ => acc + } + val branchVals = blocks.flatMap { block => + lastOwnedByBlock.get(block).collect { + case ident @ Ident(underlying) => (block, ident, underlying) + } + } + Option.when(branchVals.sizeCompare(blocks) == 0)(branchVals) + end condBranchTerminals + private[core] val verilogSemanticsWarnMsg = """|Implicit Scala/DFHDL Int conversion may produce different results than Verilog. |In Verilog, integer literals are 32-bit, which can widen intermediate arithmetic. @@ -248,7 +275,8 @@ private[core] object CarryPromote: // Check if an anonymous sub-tree contains non-carry +/-/* with width < 32. private[core] def containsNarrowNonCarryArith( dfVal: ir.DFVal - )(using ir.MemberGetSet): Boolean = + )(using dfc: DFC): Boolean = + import dfc.getSet dfVal match case func: ir.DFVal.Func if func.isAnonymous => func.op match @@ -270,14 +298,22 @@ private[core] object CarryPromote: dfhdl.compiler.analysis.Eby.unapply(alias) match case Some(relVal, _) => containsNarrowNonCarryArith(relVal) case None => false + // a conditional EXPRESSION hides a chain one selection away: each + // branch terminal is an operand position too (issue #464 warning gap) + case header: ir.DFConditional.Header if header.isAnonymous => + condBranchTerminals(header) + .exists(_.exists((_, _, v) => containsNarrowNonCarryArith(v))) case _ => false + end match + end containsNarrowNonCarryArith // Check if an anonymous sub-tree contains narrow non-carry arith that // also has an ImplicitlyFromIntTag operand (Verilog "Forcing Larger // Evaluation" pattern). private[core] def containsNarrowNonCarryArithWithTaggedOperand( dfVal: ir.DFVal - )(using ir.MemberGetSet): Boolean = + )(using dfc: DFC): Boolean = + import dfc.getSet dfVal match case func: ir.DFVal.Func if func.isAnonymous => func.op match @@ -303,7 +339,14 @@ private[core] object CarryPromote: case Some(relVal, _) => containsNarrowNonCarryArithWithTaggedOperand(relVal) case None => false + // a conditional EXPRESSION hides a chain one selection away: each + // branch terminal is an operand position too (issue #464 warning gap) + case header: ir.DFConditional.Header if header.isAnonymous => + condBranchTerminals(header) + .exists(_.exists((_, _, v) => containsNarrowNonCarryArithWithTaggedOperand(v))) case _ => false + end match + end containsNarrowNonCarryArithWithTaggedOperand // Unified Verilog-semantics warning trigger shared by `/`, `%` (arithOp) // and comparison operations (DFXIntCompare). Warns when a narrow non-carry @@ -312,9 +355,10 @@ private[core] object CarryPromote: private[core] def shouldWarnVerilogSemantics( lhs: ir.DFVal, rhs: ir.DFVal - )(using ir.MemberGetSet): Boolean = + )(using DFC): Boolean = + import dfc.getSet (hasImplicitlyFromIntTag(rhs) && containsNarrowNonCarryArith(lhs)) || - (hasImplicitlyFromIntTag(lhs) && containsNarrowNonCarryArith(rhs)) || - containsNarrowNonCarryArithWithTaggedOperand(lhs) || - containsNarrowNonCarryArithWithTaggedOperand(rhs) + (hasImplicitlyFromIntTag(lhs) && containsNarrowNonCarryArith(rhs)) || + containsNarrowNonCarryArithWithTaggedOperand(lhs) || + containsNarrowNonCarryArithWithTaggedOperand(rhs) end CarryPromote diff --git a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala index bec47f7ef..e22b22a06 100644 --- a/core/src/test/scala/CoreSpec/DFDecimalSpec.scala +++ b/core/src/test/scala/CoreSpec/DFDecimalSpec.scala @@ -172,9 +172,9 @@ class DFDecimalSpec extends DFSpec: |u8 := d"8'7" |u8 := b6.uint.eby(2) |u8 := u6.eby(2) - |s8 := (-u6.signed).eby(1) + |s8 := -u6.signed.eby(1) |s8 := -s8 - |s8 := (-b6.uint.signed).eby(1) + |s8 := -b6.uint.signed.eby(1) |s8 := sd"8'0" |s8 := sd"8'127" |s8 := sd"8'0" @@ -1033,6 +1033,39 @@ class DFDecimalSpec extends DFSpec: u9 := q } } + test("Arithmetic target-context widening through shifts and negation") { + val u8 = UInt(8) <> VAR + val s8 = SInt(8) <> VAR + val u9 = UInt(9) <> VAR + val u10 = UInt(10) <> VAR + val s10 = SInt(10) <> VAR + assertCodeString { + """|u10 := (u8.eby(2) + u8.eby(2)) >> 1 + |u10 := (u8.eby(2) + u8.eby(2)) << 1 + |s10 := (s8.eby(2) + s8.eby(2)) >> 1 + |u9 := u8.eby(1) >> 1 + |s10 := -(s8.eby(2) + s8.eby(2)) + |u10 := (u8.eby(2) >> 1) + u8.eby(2) + |s10 := ((u8 + u8) >> 1).signed.eby(1) + |""".stripMargin + } { + // a shift's LEFT operand is context-determined in Verilog (the amount is + // self-determined): the left operand re-evaluates at the target width, so the + // carry bit survives a `>>` and a `<<` pushes into the extension range + u10 := (u8 + u8) >> 1 + u10 := (u8 + u8) << 1 + s10 := (s8 + s8) >> 1 + // a leaf left operand extends the same way + u9 := u8 >> 1 + // negation is truncation-commutative and widens like binary arithmetic + s10 := -(s8 + s8) + // a shift nested inside a widened cone re-enters the widening + u10 := (u8 >> 1) + u8 + // a sign-CROSSING shift context stays a leaf: a shift evaluates at its + // operand's own signedness, so the conversion applies to the result + s10 := (u8 + u8) >> 1 + } + } test("Arithmetic target-context widening through conditional expressions") { val u8 = UInt(8) <> VAR val u9 = UInt(9) <> VAR @@ -1159,6 +1192,15 @@ class DFDecimalSpec extends DFSpec: // Should NOT warn: explicit bit-accurate literal val t5 = (a + b + c + d) / d"3'4" + // Should warn: the chain hides one conditional away (issue #464 warning gap) + val cnd = Bit <> VAR + assertRuntimeWarningLog(warnMsg) { + val t5b = (if (cnd) a + 1 else b) / 4 + } + + // Should NOT warn: carry ops inside the conditional + val t5c = (if (cnd) a +^ b else b.eby(1)) / 4 + // Should warn: DFHDL Int <> CONST used as divisor val p: Int <> CONST = 4 assertRuntimeWarningLog(warnMsg) { diff --git a/docs/user-guide/type-system/index.md b/docs/user-guide/type-system/index.md index 0bc46ae07..499e91abf 100755 --- a/docs/user-guide/type-system/index.md +++ b/docs/user-guide/type-system/index.md @@ -2476,9 +2476,9 @@ val r14 = d1 / d2 // Double type: warning Standard arithmetic operations wrap on overflow. For example, `d"8'255" + d"8'1"` produces `d"8'0"`. Use the carry variants (`+^`, `-^`, `*^`) described below to get a wider result that preserves the full value. -However, an **anonymous** arithmetic expression (`+`, `-`, `*`) that is assigned or connected to a variable **wider** than the operation's result is re-evaluated at the target's width and sign, exactly like Verilog's assignment-context width propagation: every operand, recursively through the anonymous expression, is widened to the target type, and the operations stay modular at that width. The carry operators are themselves shorthand for exactly this operand-widened evaluation (`x +^ y` is `x.eby(1) + y.eby(1)` with the operands first aligned to a common width), so when a widening lands exactly on a carry shape it prints back as the carry operator. +However, an **anonymous** arithmetic expression (`+`, `-`, `*`, unary `-`) that is assigned or connected to a variable **wider** than the operation's result is re-evaluated at the target's width and sign, exactly like Verilog's assignment-context width propagation: every operand, recursively through the anonymous expression, is widened to the target type, and the operations stay modular at that width. The carry operators are themselves shorthand for exactly this operand-widened evaluation (`x +^ y` is `x.eby(1) + y.eby(1)` with the operands first aligned to a common width), so when a widening lands exactly on a carry shape it prints back as the carry operator. -The widening context also crosses an anonymous `.sel` (matching Verilog's `?:`, whose branch operands are context-determined) and anonymous `if`/`match` **expressions** (matching the per-branch assignments they lower to): each branch re-evaluates at the target, while the selection condition or match selector is unaffected. The context stops at exactly three kinds of boundaries: a **named value** (a `val`-bound expression evaluates at its own declared width and only its result extends), a **carry operation** (its widened result is already exact), and any **other operation** (shifts, bitwise logic, comparisons), whose result converts as a plain value. +The widening context also crosses an anonymous `.sel` (matching Verilog's `?:`, whose branch operands are context-determined) and anonymous `if`/`match` **expressions** (matching the per-branch assignments they lower to): each branch re-evaluates at the target, while the selection condition or match selector is unaffected. A **shift**'s left operand is likewise context-determined (matching Verilog; the shift amount is self-determined), as long as the target keeps the operand's signedness: a shift evaluates at its operand's own signedness (an arithmetic-vs-logical `>>` difference), so a sign-crossing shift context is a boundary and the shifted result converts as a plain value there. The context stops at exactly three kinds of boundaries: a **named value** (a `val`-bound expression evaluates at its own declared width and only its result extends), a **carry operation** (its widened result is already exact), and any **other operation** (bitwise logic, comparisons, rotations), whose result converts as a plain value. ```scala val u8 = UInt(8) <> VAR @@ -2500,6 +2500,10 @@ u9 := c.sel(u8 + u8, u8 - u8) // elaborates to c.sel(u8 +^ u8, u8 -^ u8) // ... and if/match EXPRESSION branches the same way: u9 := (if (c) u8 + u8 else u8 - u8) // each branch elaborates as a carry op +// A shift's LEFT operand is context-determined (the amount is self-determined), +// so the carry bit survives a >> into a wider target: +u10 := (u8 + u8) >> 1 // elaborates to (u8.eby(2) + u8.eby(2)) >> 1 + // Implicit Int operands and whole chains evaluate at the target width: u10 := u8 + u8 + 1 // elaborates to u10 := u8.eby(2) + u8.eby(2) + d"10'1" diff --git a/lib/src/test/scala/ContextWidenSpec.scala b/lib/src/test/scala/ContextWidenSpec.scala index 6581d4602..0d0daa13d 100644 --- a/lib/src/test/scala/ContextWidenSpec.scala +++ b/lib/src/test/scala/ContextWidenSpec.scala @@ -160,6 +160,33 @@ class ContextWidenSpec extends DesignSpec: ) } + test("parametric shift and negation target-context widening") { + @top(false) class ShiftWiden(val W: Int <> CONST = 8) extends EDDesign: + val a, b = UInt(W) <> IN + val sa, sb = SInt(W) <> IN + val shr = UInt(W + 2) <> OUT + val neg = SInt(W + 2) <> OUT + // a shift's left operand is context-determined (the amount is self-determined), + // and negation is truncation-commutative: both re-evaluate at the target + shr <> (a + b) >> 1 + neg <> -(sa + sb) + end ShiftWiden + + ShiftWiden().assertCodeString( + """|class ShiftWiden(val W: Int <> CONST = 8) extends EDDesign: + | val a = UInt(W) <> IN + | val b = UInt(W) <> IN + | val sa = SInt(W) <> IN + | val sb = SInt(W) <> IN + | val shr = UInt(W + 2) <> OUT + | val neg = SInt(W + 2) <> OUT + | shr <> ((a.eby(2) + b.eby(2)) >> 1) + | neg <> (-(sa.eby(2) + sb.eby(2))) + |end ShiftWiden + |""".stripMargin + ) + } + test("explicit eby") { @top(false) class Eby(val W: Int <> CONST = 8) extends EDDesign: val a = SInt(W) <> IN From 477e737a5404e7d2f11d2f56db9f87a4f53e30b3 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Sun, 9 Aug 2026 18:47:14 +0300 Subject: [PATCH 24/25] lib+docs+ips: add NVC as a Verilog lint/simulation tool (v95/v2001) NVC gained Verilog support (matured in 1.22), so NVC.scala is split QuestaSim-style into NVCCommon + NVCVHDL + NVCVerilog. The Verilog front-end analyzes with `-a --keywords=1364-1995|2001` (an analysis option, unlike the VHDL front-end's global `--std`), predicts the Verilog work-library units (one WORK. per module plus WORK..elab, no secondary architecture unit), and requires NVC >= 1.22. The SystemVerilog dialects throw: NVC cannot parse the generated sv output (size casts, unpacked-array typedefs). Since NVC is one binary serving both languages, `nvc` is now a both-languages selection like `questa`/`vivado`: bare `-t nvc` sets both tools, and in the `a/b` slash syntax it resolves by slot (first=Verilog, second=VHDL). AvailableTools holds the per-language front-ends in the verilog*/vhdl* scopes plus a questa-style marker object with the matching SimulatorOptions conversions. Linting generated output additionally waits on an upstream fix: released NVC rejects a block comment inside a `define macro body (https://github.com/nickg/nvc/issues/1636), which dfhdl_defs.vh uses for its Verilator lint pragmas. Once a release carries the fix, raise NVCVerilog's version floor and enable nvc in FullCompileSpec's Verilog linters and testApps' verilogTools. Verified locally on NVC 1.22.1 that analyze/elaborate/run of the full AES CipherSim works end-to-end once past that preprocessor limitation. Co-Authored-By: Claude Fable 5 --- docs/user-guide/command-line/index.md | 1 + ips | 2 +- lib/src/main/scala/dfhdl/app/DFApp.scala | 16 +- .../scala/dfhdl/app/LintToolSelection.scala | 21 ++- .../dfhdl/app/SimulateToolSelection.scala | 23 ++- .../dfhdl/options/SimulatorOptions.scala | 2 + .../scala/dfhdl/tools/AvailableTools.scala | 15 +- .../scala/dfhdl/tools/toolsCore/NVC.scala | 141 +++++++++++++++--- lib/src/test/scala/util/FullCompileSpec.scala | 2 +- 9 files changed, 180 insertions(+), 43 deletions(-) diff --git a/docs/user-guide/command-line/index.md b/docs/user-guide/command-line/index.md index de78de00e..1063df844 100644 --- a/docs/user-guide/command-line/index.md +++ b/docs/user-guide/command-line/index.md @@ -101,6 +101,7 @@ Add `-s` / `--scan` to the two tool listings to also probe your system for each Selectable Verilog/SystemVerilog simulation tools: verilator - Verilator (default) Found version 5.049 iverilog - Icarus Verilog Found version 14.0 +nvc - NVC Found version 1.22.1 vlog|questa|modelsim - QuestaSim/ModelSim Found version 2023.3 xvlog|vivado|xsim - Vivado Simulator Not found on your system ``` diff --git a/ips b/ips index 4fb867c27..561170bcc 160000 --- a/ips +++ b/ips @@ -1 +1 @@ -Subproject commit 4fb867c27f77351f94c398d2493ebaa0b1d595cc +Subproject commit 561170bcccbd2b2e228450996edccccbd178d3fd diff --git a/lib/src/main/scala/dfhdl/app/DFApp.scala b/lib/src/main/scala/dfhdl/app/DFApp.scala index 99be3aed8..3d3bcaf3f 100644 --- a/lib/src/main/scala/dfhdl/app/DFApp.scala +++ b/lib/src/main/scala/dfhdl/app/DFApp.scala @@ -346,20 +346,22 @@ class DFApp: |one you intend to run according to your chosen backend. |Examples: |-t verilator - Set the Verilog linter to Verilator (VHDL linter remains default) - |-t nvc - Set the VHDL linter to NVC (Verilog linter remains default) + |-t ghdl - Set the VHDL linter to GHDL (Verilog linter remains default) |-t iverilog/ghdl - Set both Verilog and VHDL linters + |-t nvc - Set both Verilog and VHDL linters to NVC |-t questa - Set both Verilog and VHDL linters to QuestaSim/ModelSim |-t vivado - Set both Verilog and VHDL linters to Vivado Simulator | |Selectable Verilog/SystemVerilog linting tools: |verilator - Verilator (default) ${scanned(dfhdl.tools.linters.verilator)} |iverilog - Icarus Verilog ${scanned(dfhdl.tools.linters.iverilog)} + |nvc - NVC ${scanned(dfhdl.tools.linters.verilogLinters.nvc)} |vlog|questa|vsim - QuestaSim/ModelSim ${scanned(dfhdl.tools.linters.vlog)} |xvlog|vivado|xsim - Vivado Simulator ${scanned(dfhdl.tools.linters.xvlog)} | |Selectable VHDL linting tools: |ghdl - GHDL (default) ${scanned(dfhdl.tools.linters.ghdl)} - |nvc - NVC ${scanned(dfhdl.tools.linters.nvc)} + |nvc - NVC ${scanned(dfhdl.tools.linters.vhdlLinters.nvc)} |vcom|questa|vsim - QuestaSim/ModelSim ${scanned(dfhdl.tools.linters.vcom)} |xvhdl|vivado|xsim - Vivado Simulator ${scanned(dfhdl.tools.linters.xvhdl)} |""".stripMargin @@ -380,20 +382,26 @@ class DFApp: |one you intend to run according to your chosen backend. |Examples: |-t verilator - Set the Verilog simulator to Verilator (VHDL simulator remains default) - |-t nvc - Set the VHDL simulator to NVC (Verilog simulator remains default) + |-t ghdl - Set the VHDL simulator to GHDL (Verilog simulator remains default) |-t iverilog/ghdl - Set both Verilog and VHDL simulators + |-t nvc - Set both Verilog and VHDL simulators to NVC |-t questa - Set both Verilog and VHDL simulators to QuestaSim/ModelSim |-t vivado - Set both Verilog and VHDL simulators to Vivado Simulator | |Selectable Verilog/SystemVerilog simulation tools: |verilator - Verilator (default) ${scanned(dfhdl.tools.simulators.verilator)} |iverilog - Icarus Verilog ${scanned(dfhdl.tools.simulators.iverilog)} + |nvc - NVC ${scanned( + dfhdl.tools.simulators.verilogSimulators.nvc + )} |vlog|questa|modelsim - QuestaSim/ModelSim ${scanned(dfhdl.tools.simulators.vlog)} |xvlog|vivado|xsim - Vivado Simulator ${scanned(dfhdl.tools.simulators.xvlog)} | |Selectable VHDL simulation tools: |ghdl - GHDL (default) ${scanned(dfhdl.tools.simulators.ghdl)} - |nvc - NVC ${scanned(dfhdl.tools.simulators.nvc)} + |nvc - NVC ${scanned( + dfhdl.tools.simulators.vhdlSimulators.nvc + )} |vcom|questa|modelsim - QuestaSim/ModelSim ${scanned(dfhdl.tools.simulators.vcom)} |xvhdl|vivado|xsim - Vivado Simulator ${scanned(dfhdl.tools.simulators.xvhdl)} |""".stripMargin diff --git a/lib/src/main/scala/dfhdl/app/LintToolSelection.scala b/lib/src/main/scala/dfhdl/app/LintToolSelection.scala index 058591c15..6ce227005 100644 --- a/lib/src/main/scala/dfhdl/app/LintToolSelection.scala +++ b/lib/src/main/scala/dfhdl/app/LintToolSelection.scala @@ -13,22 +13,31 @@ object LintToolSelection: def parse( arg: String ): Either[String, Option[LintToolSelection]] = - def parseTool(toolName: String): Option[dfhdl.tools.toolsCore.Simulator] = + // `nvc` serves both languages, so in the two-tool `/` syntax it resolves by its slot + // (first is the Verilog side, second is the VHDL side); the bare single `nvc` is handled + // as a both-languages selection below, like `questa` and `vivado`. + def parseTool( + toolName: String, + verilogSlot: Boolean + ): Option[dfhdl.tools.toolsCore.Simulator] = toolName match case "verilator" => Some(linters.verilator) case "iverilog" => Some(linters.iverilog) case "vlog" => Some(linters.vlog) case "xvlog" => Some(linters.xvlog) case "ghdl" => Some(linters.ghdl) - case "nvc" => Some(linters.nvc) - case "vcom" => Some(linters.vcom) - case "xvhdl" => Some(linters.xvhdl) - case _ => None + case "nvc" => + if (verilogSlot) Some(linters.verilogLinters.nvc) else Some(linters.vhdlLinters.nvc) + case "vcom" => Some(linters.vcom) + case "xvhdl" => Some(linters.xvhdl) + case _ => None val toolNames = arg.split("\\/").toList val parsedTools = arg match case "questa" | "vsim" => List(Some(linters.vlog), Some(linters.vcom)) case "vivado" | "xsim" => List(Some(linters.xvlog), Some(linters.xvhdl)) - case _ => toolNames.map(parseTool) + case "nvc" => List(Some(linters.verilogLinters.nvc), Some(linters.vhdlLinters.nvc)) + case _ => + toolNames.zipWithIndex.map((name, idx) => parseTool(name, verilogSlot = idx == 0)) parsedTools match case Some(tool: VerilogLinter) :: Nil => Right(Some(LintToolSelection(tool, lo.vhdlLinter))) diff --git a/lib/src/main/scala/dfhdl/app/SimulateToolSelection.scala b/lib/src/main/scala/dfhdl/app/SimulateToolSelection.scala index e6982602c..5973429e0 100644 --- a/lib/src/main/scala/dfhdl/app/SimulateToolSelection.scala +++ b/lib/src/main/scala/dfhdl/app/SimulateToolSelection.scala @@ -15,22 +15,33 @@ object SimulateToolSelection: def parse( arg: String ): Either[String, Option[SimulateToolSelection]] = - def parseTool(toolName: String): Option[dfhdl.tools.toolsCore.Simulator] = + // `nvc` serves both languages, so in the two-tool `/` syntax it resolves by its slot + // (first is the Verilog side, second is the VHDL side); the bare single `nvc` is handled + // as a both-languages selection below, like `questa` and `vivado`. + def parseTool( + toolName: String, + verilogSlot: Boolean + ): Option[dfhdl.tools.toolsCore.Simulator] = toolName match case "verilator" => Some(simulators.verilator) case "iverilog" => Some(simulators.iverilog) case "vlog" => Some(simulators.vlog) case "xvlog" => Some(simulators.xvlog) case "ghdl" => Some(simulators.ghdl) - case "nvc" => Some(simulators.nvc) - case "vcom" => Some(simulators.vcom) - case "xvhdl" => Some(simulators.xvhdl) - case _ => None + case "nvc" => + if (verilogSlot) Some(simulators.verilogSimulators.nvc) + else Some(simulators.vhdlSimulators.nvc) + case "vcom" => Some(simulators.vcom) + case "xvhdl" => Some(simulators.xvhdl) + case _ => None val toolNames = arg.split("\\/").toList val parsedTools = arg match case "questa" | "vsim" => List(Some(simulators.vlog), Some(simulators.vcom)) case "vivado" | "xsim" => List(Some(simulators.xvlog), Some(simulators.xvhdl)) - case _ => toolNames.map(parseTool) + case "nvc" => + List(Some(simulators.verilogSimulators.nvc), Some(simulators.vhdlSimulators.nvc)) + case _ => + toolNames.zipWithIndex.map((name, idx) => parseTool(name, verilogSlot = idx == 0)) parsedTools match case Some(tool: VerilogSimulator) :: Nil => Right(Some(SimulateToolSelection(tool, so.vhdlSimulator))) diff --git a/lib/src/main/scala/dfhdl/options/SimulatorOptions.scala b/lib/src/main/scala/dfhdl/options/SimulatorOptions.scala index f5d9c41b8..e62f214ba 100644 --- a/lib/src/main/scala/dfhdl/options/SimulatorOptions.scala +++ b/lib/src/main/scala/dfhdl/options/SimulatorOptions.scala @@ -67,6 +67,7 @@ object SimulatorOptions: given Conversion[dfhdl.tools.toolsCore.VerilogSimulator, _VerilogSimulator] = identity given Conversion[dfhdl.tools.simulators.questa.type, VerilogSimulator] = _ => _.vlog given Conversion[dfhdl.tools.simulators.vivado.type, VerilogSimulator] = _ => _.xvlog + given Conversion[dfhdl.tools.simulators.nvc.type, VerilogSimulator] = _ => _.nvc type VHDLSimulator = dfhdl.tools.simulators.vhdlSimulators.type => _VHDLSimulator protected[dfhdl] into opaque type _VHDLSimulator <: dfhdl.tools.toolsCore.VHDLSimulator = @@ -76,6 +77,7 @@ object SimulatorOptions: given Conversion[dfhdl.tools.toolsCore.VHDLSimulator, _VHDLSimulator] = identity given Conversion[dfhdl.tools.simulators.questa.type, VHDLSimulator] = _ => _.vcom given Conversion[dfhdl.tools.simulators.vivado.type, VHDLSimulator] = _ => _.xvhdl + given Conversion[dfhdl.tools.simulators.nvc.type, VHDLSimulator] = _ => _.nvc into opaque type RunLimit <: (Duration | None.type) = (Duration | None.type) object RunLimit: diff --git a/lib/src/main/scala/dfhdl/tools/AvailableTools.scala b/lib/src/main/scala/dfhdl/tools/AvailableTools.scala index 8fc5d4bed..16c548395 100644 --- a/lib/src/main/scala/dfhdl/tools/AvailableTools.scala +++ b/lib/src/main/scala/dfhdl/tools/AvailableTools.scala @@ -7,21 +7,24 @@ object linters: val vlog = QuestaSimVerilog val xvlog = VivadoSimVerilog val ghdl = GHDL - val nvc = NVC val vcom = QuestaSimVHDL val xvhdl = VivadoSimVHDL object questa final val vsim = questa object vivado final val xsim = vivado + // NVC is a single binary serving both languages, so the bare `nvc` is a marker (like `questa`) + // that resolves per language, while each language scope below holds its actual front-end. + object nvc object verilogLinters: val verilator = linters.verilator val iverilog = linters.iverilog val vlog = linters.vlog val xvlog = linters.xvlog + val nvc = NVCVerilog object vhdlLinters: val ghdl = linters.ghdl - val nvc = linters.nvc + val nvc = NVCVHDL val vcom = linters.vcom val xvhdl = linters.xvhdl end linters @@ -32,17 +35,21 @@ object simulators: val vlog = QuestaSimVerilog val xvlog = VivadoSimVerilog val ghdl = GHDL - val nvc = NVC val vcom = QuestaSimVHDL val xvhdl = VivadoSimVHDL object questa final val vsim = questa object vivado final val xsim = vivado + // NVC is a single binary serving both languages, so the bare `nvc` is a marker (like `questa`) + // that resolves per language, while each language scope below holds its actual front-end. + object nvc object verilogSimulators: export simulators.{verilator, iverilog, vlog, xvlog, questa, vsim, vivado, xsim} + val nvc = NVCVerilog object vhdlSimulators: - export simulators.{ghdl, nvc, vcom, xvhdl, questa, vsim, vivado, xsim} + export simulators.{ghdl, vcom, xvhdl, questa, vsim, vivado, xsim} + val nvc = NVCVHDL end simulators enum builders derives CanEqual: diff --git a/lib/src/main/scala/dfhdl/tools/toolsCore/NVC.scala b/lib/src/main/scala/dfhdl/tools/toolsCore/NVC.scala index 871f2b487..d470f77aa 100644 --- a/lib/src/main/scala/dfhdl/tools/toolsCore/NVC.scala +++ b/lib/src/main/scala/dfhdl/tools/toolsCore/NVC.scala @@ -3,6 +3,7 @@ import dfhdl.core.Design import dfhdl.backends import dfhdl.compiler.stages.CompiledDesign import dfhdl.compiler.stages.vhdl.VHDLDialect +import dfhdl.compiler.stages.verilog.VerilogDialect import dfhdl.compiler.ir.* import dfhdl.internals.* import dfhdl.options.{PrinterOptions, CompilerOptions, ToolOptions, SimulatorOptions} @@ -13,15 +14,36 @@ import java.io.FileWriter import java.io.File.separatorChar import scala.sys.process.* -object NVC extends VHDLLinter, VHDLSimulator: - override val simRunsLint: Boolean = true - val toolName: String = "NVC" - protected def binExec: String = "nvc" - protected def versionCmd: String = s"--version" - protected def extractVersion(cmdRetStr: String): Option[String] = +trait NVCCommon extends Linter, Simulator: + final override val simRunsLint: Boolean = true + final val toolName: String = "NVC" + final protected def binExec: String = "nvc" + final protected def versionCmd: String = s"--version" + final protected def extractVersion(cmdRetStr: String): Option[String] = val versionPattern = """nvc\s+(\d+\.\d+\.\d+)""".r versionPattern.findFirstMatchIn(cmdRetStr).map(_.group(1)) + // The installed version as a comparable major.minor double (e.g. "1.22.1" -> 1.22). + final protected def installedVersionDouble(using ToolOptions): Double = + getInstalledVersion.split("\\.").take(2).mkString(".").toDouble + + // Expected when mixing multiple simulators/linters all using the same "work" folder. + final protected def lineIsForeignWorkDirWarning(line: String): Boolean = + line == "** Warning: directory work already exists and is not an NVC library" + + // Elaborate the analyzed top before running it (both languages share the same + // analyze -> elaborate -> run flow). + override protected[dfhdl] def simulatePreprocess(cd: CompiledDesign)(using + CompilerOptions, + SimulatorOptions + ): CompiledDesign = + val ret = super.simulatePreprocess(cd) + given MemberGetSet = ret.stagedDB.getSet + exec(constructCommand("-e", topName)) + ret +end NVCCommon + +object NVCVHDL extends NVCCommon, VHDLLinter, VHDLSimulator: protected def lintCmdLanguageFlag(dialect: VHDLDialect): String = val std = dialect match case VHDLDialect.v93 => "93" @@ -65,7 +87,7 @@ object NVC extends VHDLLinter, VHDLSimulator: ) else Nil ) - val versionDouble = getInstalledVersion.split("\\.").take(2).mkString(".").toDouble + val versionDouble = installedVersionDouble val topElabFile = if (versionDouble >= 1.20) "" else if (versionDouble >= 1.17) s"_WORK.${topNameUC}.elab.pack" @@ -78,15 +100,6 @@ object NVC extends VHDLLinter, VHDLSimulator: allFiles.map(name => s"work${separatorChar}${name}") end producedFiles - override protected[dfhdl] def simulatePreprocess(cd: CompiledDesign)(using - CompilerOptions, - SimulatorOptions - ): CompiledDesign = - val ret = super.simulatePreprocess(cd) - given MemberGetSet = ret.stagedDB.getSet - exec(constructCommand("-e", topName)) - ret - override protected def lintLogger(using CompilerOptions, ToolOptions, @@ -106,10 +119,7 @@ object NVC extends VHDLLinter, VHDLSimulator: // hit the end of the warning if (line.trim.endsWith("^")) insideWarning = false true - // this is expected when mixing multiple simulators/linters all using "work" folder - else if (line == "** Warning: directory work already exists and is not an NVC library") - true - else false + else lineIsForeignWorkDirWarning(line) ) ) end lintLogger @@ -172,4 +182,93 @@ object NVC extends VHDLLinter, VHDLSimulator: override protected def simulateCmdLanguageFlag(dialect: VHDLDialect): String = lintCmdLanguageFlag(dialect) -end NVC +end NVCVHDL + +object NVCVerilog extends NVCCommon, VerilogLinter, VerilogSimulator: + protected def includeFolderFlag: String = "-I" + + // NVC's Verilog frontend covers plain Verilog; the SystemVerilog constructs DFHDL emits for the + // sv dialects (size casts, unpacked-array typedefs) are not supported yet. + protected def lintCmdLanguageFlag(dialect: VerilogDialect): String = + val keywords = dialect match + case VerilogDialect.v95 => "1364-1995" + case VerilogDialect.v2001 => "1364-2001" + case _ => + throw new java.lang.IllegalArgumentException( + "Current dialect is not supported for NVC Verilog linting." + ) + s"--keywords=$keywords" + + // `--keywords` is an analysis option, so `-a` must precede the language flag (unlike the VHDL + // front-end's `--std`, which is a global option placed before `-a`). + override protected def lintCmdPreLangFlags(using + CompilerOptions, + ToolOptions, + MemberGetSet + ): String = "-a" + + // Verilog analysis matured in NVC 1.22; older versions cannot parse the generated files. + // Note: released NVC versions (through 1.22) still reject a block comment inside a `define + // macro body, which dfhdl_defs.vh uses for its Verilator lint pragmas, so linting DFHDL + // output requires an NVC build that fixes https://github.com/nickg/nvc/issues/1636 (once a + // release carries the fix, raise this version floor to it). + override protected def lintPrepare()(using CompilerOptions, ToolOptions, MemberGetSet): Unit = + if (installedVersionDouble < 1.22) + error( + s"NVC version 1.22 or later is required for Verilog support, but version ${getInstalledVersion} was found." + ) + + override protected[dfhdl] def producedFiles(using + getSet: MemberGetSet, + co: CompilerOptions, + so: SimulatorOptions + ): List[String] = + // A Verilog module is a single library unit (no VHDL-style secondary architecture unit). + // Foreign IP wrappers are excluded like in the VHDL front-end (NVC runs from the elaborated + // `.elab`, which already embeds them). + val designWorkFiles = getSet.designDB.designMemberList.view.map(_._1) + .filterNot(_.isForeignIPBlackbox) + .map(design => s"WORK.${design.dclName.toUpperCase()}") + .toList + val topWorkFiles = List(s"WORK.${topName.toUpperCase()}.elab") + val extraFiles = List("_index", "_NVC_LIB") + val allFiles = extraFiles ++ topWorkFiles ++ designWorkFiles + allFiles.map(name => s"work${separatorChar}${name}") + end producedFiles + + override protected def lintLogger(using + CompilerOptions, + ToolOptions, + MemberGetSet + ): Option[Tool.ProcessLogger] = + Some( + Tool.ProcessLogger( + lineIsWarning = (line: String) => line.startsWith("** Warning:"), + lineIsSuppressed = lineIsForeignWorkDirWarning + ) + ) + + override protected def simulateLogger(using + CompilerOptions, + SimulatorOptions, + MemberGetSet + ): Option[Tool.ProcessLogger] = + Some( + Tool.ProcessLogger( + lineIsWarning = (line: String) => line.contains("** Warning:"), + lineIsSuppressed = (line: String) => false + ) + ) + + override protected def simulateCmdPostLangFlags(using + CompilerOptions, + SimulatorOptions, + MemberGetSet + ): String = constructCommand( + "-r", + topName + ) + + override protected def simulateCmdLanguageFlag(dialect: VerilogDialect): String = "" + +end NVCVerilog diff --git a/lib/src/test/scala/util/FullCompileSpec.scala b/lib/src/test/scala/util/FullCompileSpec.scala index 0d3b6a9e6..64cdf130b 100644 --- a/lib/src/test/scala/util/FullCompileSpec.scala +++ b/lib/src/test/scala/util/FullCompileSpec.scala @@ -32,7 +32,7 @@ abstract class FullCompileSpec extends FunSuite: def verilogLinters(using CompilerOptions): List[LinterOptions._VerilogLinter] = List(verilator, iverilog, vlog, xvlog) def vhdlLinters(using CompilerOptions): List[LinterOptions._VHDLLinter] = - List(ghdl, nvc, vcom, xvhdl) + List(ghdl, dfhdl.tools.linters.vhdlLinters.nvc, vcom, xvhdl) extension [D <: core.Design](cd: CompiledDesign) def lintVerilog(using CompilerOptions): CompiledDesign = verilogLinters.foreach { linter => From e3a89a886fde7510a581aff3872a5842ace10b53 Mon Sep 17 00:00:00 2001 From: Oron Port Date: Sun, 9 Aug 2026 20:26:37 +0300 Subject: [PATCH 25/25] lib+docs: DFTools v1.2.0; add the Xezim simulator via its new sim-xezim image Xezim (https://github.com/aionhw/xezim) is a SystemVerilog simulator with no local install channel, so it runs from the DFTools v1.2.0 `sim-xezim` image (linux-x64 only): the `auto` tools-location falls back to the image whenever no local `xezim` exists. A single invocation parses, elaborates, and runs, so linting maps to `--compile -s ` and simulating to `--simulate -s `; all sv dialects map to `--sv2017` (1800-2023 grammar by default, no IEEE 1364 mode, so v95/v2001 throw). The simulate command pins `--max-time 1000s` because xezim's default silently caps simulated time at 100us, unlike the other simulators' unbounded default. xezim exits 0 even when it reports errors, so the tool's ProcessLogger owns error detection (compile `: error:` diagnostics, `Simulation error:` elaboration failures, and runtime `** Error`/`** Fatal`). Foreign IP DPI libraries are wired via `--dpi-lib`. Verified end-to-end through the image (WSL apptainer): EmptyDesign lint and simulate run green, and an AES lint fails correctly through the logger. Broader enablement waits on upstream fixes, all reported with minimal reproductions: - aionhw/xezim#106: false "Implicit net under `default_nettype none" on explicit wire ports with unpacked-array typedef types (blocks the vector/opaque-port designs, e.g. the AES suite); - aionhw/xezim#107: exit code 0 on parse/elaboration/runtime errors; - aionhw/xezim#108: DPI-C imports inside a parameterized child module are silently no-op'd (blocks every DPI foreign IP, so the ips sim specs do not list xezim yet; found via a vga_monitor validation run that confirmed the rest of the chain works under xezim). Co-Authored-By: Claude Fable 5 --- build.sbt | 2 +- docs/user-guide/command-line/index.md | 1 + lib/src/main/scala/dfhdl/app/DFApp.scala | 2 + .../scala/dfhdl/app/LintToolSelection.scala | 1 + .../dfhdl/app/SimulateToolSelection.scala | 1 + .../scala/dfhdl/tools/AvailableTools.scala | 5 +- .../dfhdl/tools/toolsCore/DFToolsImage.scala | 1 + .../scala/dfhdl/tools/toolsCore/Xezim.scala | 105 ++++++++++++++++++ 8 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 lib/src/main/scala/dfhdl/tools/toolsCore/Xezim.scala diff --git a/build.sbt b/build.sbt index 20c671c28..76bb09e88 100755 --- a/build.sbt +++ b/build.sbt @@ -14,7 +14,7 @@ val compilerVersion = "3.8.4" // The DFTools binary toolchain release this DFHDL build targets (versioned independently of // DFHDL). Surfaced to the library via lib's generated `dftools.properties` and read by // DFToolsImage. Bump when adopting a new DFTools release. -val dftoolsVersion = "v1.1.1" +val dftoolsVersion = "v1.2.0" // The vga-monitor-sim release wrapped by the `dfhdl.ips.video.vga.vga_monitor` foreign IP. This is // the single source of truth: it is surfaced to the IP code via the generated `vga-monitor.properties` // resource (read by `vga_monitor.version`), like core's version.properties. Since v0.3.0 the release names diff --git a/docs/user-guide/command-line/index.md b/docs/user-guide/command-line/index.md index 1063df844..6c224ee6c 100644 --- a/docs/user-guide/command-line/index.md +++ b/docs/user-guide/command-line/index.md @@ -101,6 +101,7 @@ Add `-s` / `--scan` to the two tool listings to also probe your system for each Selectable Verilog/SystemVerilog simulation tools: verilator - Verilator (default) Found version 5.049 iverilog - Icarus Verilog Found version 14.0 +xezim - Xezim Not found on your system nvc - NVC Found version 1.22.1 vlog|questa|modelsim - QuestaSim/ModelSim Found version 2023.3 xvlog|vivado|xsim - Vivado Simulator Not found on your system diff --git a/lib/src/main/scala/dfhdl/app/DFApp.scala b/lib/src/main/scala/dfhdl/app/DFApp.scala index 3d3bcaf3f..cd604cb14 100644 --- a/lib/src/main/scala/dfhdl/app/DFApp.scala +++ b/lib/src/main/scala/dfhdl/app/DFApp.scala @@ -355,6 +355,7 @@ class DFApp: |Selectable Verilog/SystemVerilog linting tools: |verilator - Verilator (default) ${scanned(dfhdl.tools.linters.verilator)} |iverilog - Icarus Verilog ${scanned(dfhdl.tools.linters.iverilog)} + |xezim - Xezim ${scanned(dfhdl.tools.linters.xezim)} |nvc - NVC ${scanned(dfhdl.tools.linters.verilogLinters.nvc)} |vlog|questa|vsim - QuestaSim/ModelSim ${scanned(dfhdl.tools.linters.vlog)} |xvlog|vivado|xsim - Vivado Simulator ${scanned(dfhdl.tools.linters.xvlog)} @@ -391,6 +392,7 @@ class DFApp: |Selectable Verilog/SystemVerilog simulation tools: |verilator - Verilator (default) ${scanned(dfhdl.tools.simulators.verilator)} |iverilog - Icarus Verilog ${scanned(dfhdl.tools.simulators.iverilog)} + |xezim - Xezim ${scanned(dfhdl.tools.simulators.xezim)} |nvc - NVC ${scanned( dfhdl.tools.simulators.verilogSimulators.nvc )} diff --git a/lib/src/main/scala/dfhdl/app/LintToolSelection.scala b/lib/src/main/scala/dfhdl/app/LintToolSelection.scala index 6ce227005..5f47adfea 100644 --- a/lib/src/main/scala/dfhdl/app/LintToolSelection.scala +++ b/lib/src/main/scala/dfhdl/app/LintToolSelection.scala @@ -23,6 +23,7 @@ object LintToolSelection: toolName match case "verilator" => Some(linters.verilator) case "iverilog" => Some(linters.iverilog) + case "xezim" => Some(linters.xezim) case "vlog" => Some(linters.vlog) case "xvlog" => Some(linters.xvlog) case "ghdl" => Some(linters.ghdl) diff --git a/lib/src/main/scala/dfhdl/app/SimulateToolSelection.scala b/lib/src/main/scala/dfhdl/app/SimulateToolSelection.scala index 5973429e0..dac2fb465 100644 --- a/lib/src/main/scala/dfhdl/app/SimulateToolSelection.scala +++ b/lib/src/main/scala/dfhdl/app/SimulateToolSelection.scala @@ -25,6 +25,7 @@ object SimulateToolSelection: toolName match case "verilator" => Some(simulators.verilator) case "iverilog" => Some(simulators.iverilog) + case "xezim" => Some(simulators.xezim) case "vlog" => Some(simulators.vlog) case "xvlog" => Some(simulators.xvlog) case "ghdl" => Some(simulators.ghdl) diff --git a/lib/src/main/scala/dfhdl/tools/AvailableTools.scala b/lib/src/main/scala/dfhdl/tools/AvailableTools.scala index 16c548395..400815087 100644 --- a/lib/src/main/scala/dfhdl/tools/AvailableTools.scala +++ b/lib/src/main/scala/dfhdl/tools/AvailableTools.scala @@ -4,6 +4,7 @@ import toolsCore.* object linters: val verilator = Verilator val iverilog = IcarusVerilog + val xezim = Xezim val vlog = QuestaSimVerilog val xvlog = VivadoSimVerilog val ghdl = GHDL @@ -19,6 +20,7 @@ object linters: object verilogLinters: val verilator = linters.verilator val iverilog = linters.iverilog + val xezim = linters.xezim val vlog = linters.vlog val xvlog = linters.xvlog val nvc = NVCVerilog @@ -32,6 +34,7 @@ end linters object simulators: val verilator = Verilator val iverilog = IcarusVerilog + val xezim = Xezim val vlog = QuestaSimVerilog val xvlog = VivadoSimVerilog val ghdl = GHDL @@ -45,7 +48,7 @@ object simulators: // that resolves per language, while each language scope below holds its actual front-end. object nvc object verilogSimulators: - export simulators.{verilator, iverilog, vlog, xvlog, questa, vsim, vivado, xsim} + export simulators.{verilator, iverilog, xezim, vlog, xvlog, questa, vsim, vivado, xsim} val nvc = NVCVerilog object vhdlSimulators: export simulators.{ghdl, vcom, xvhdl, questa, vsim, vivado, xsim} diff --git a/lib/src/main/scala/dfhdl/tools/toolsCore/DFToolsImage.scala b/lib/src/main/scala/dfhdl/tools/toolsCore/DFToolsImage.scala index 67cc212a3..cf6071c9d 100644 --- a/lib/src/main/scala/dfhdl/tools/toolsCore/DFToolsImage.scala +++ b/lib/src/main/scala/dfhdl/tools/toolsCore/DFToolsImage.scala @@ -43,6 +43,7 @@ object DFToolsImage: case "ghdl" | "nvc" => Some("sim-llvm") case "verilator" | "verilator_bin" => Some("sim-verilator") case "iverilog" | "vvp" => Some("sim-iverilog") + case "xezim" => Some("sim-xezim") case "surfer" => Some("wavegen") case "openFPGALoader" => Some("program") case _ => None diff --git a/lib/src/main/scala/dfhdl/tools/toolsCore/Xezim.scala b/lib/src/main/scala/dfhdl/tools/toolsCore/Xezim.scala new file mode 100644 index 000000000..9bbe60fee --- /dev/null +++ b/lib/src/main/scala/dfhdl/tools/toolsCore/Xezim.scala @@ -0,0 +1,105 @@ +package dfhdl.tools.toolsCore +import dfhdl.core.Design +import dfhdl.compiler.stages.CompiledDesign +import dfhdl.compiler.ir.* +import dfhdl.internals.* +import dfhdl.options.{PrinterOptions, CompilerOptions, ToolOptions, LinterOptions, SimulatorOptions} +import dfhdl.compiler.printing.Printer +import dfhdl.compiler.analysis.* +import dfhdl.compiler.stages.verilog.VerilogDialect + +/** Xezim SystemVerilog simulator (https://github.com/aionhw/xezim). Not distributed for local + * installation through the usual EDA channels, so it typically runs from its DFTools image + * (`sim-xezim`), which the `auto` tools-location falls back to when no local `xezim` is found. A + * single invocation parses, elaborates, and (for simulation) runs; there is no work-library or + * build-artifact step, so linting maps to `--compile` and simulating to `--simulate`. + * + * Known upstream limitations: a design port whose type is an unpacked-array typedef (how DFHDL + * prints vector and opaque ports) trips a false "Implicit net under `default_nettype none" + * elaboration error (https://github.com/aionhw/xezim/issues/106), so such designs (e.g. the AES + * suite) cannot run under xezim until that is fixed; and a DPI-C import inside a parameterized + * child module is silently no-op'd (https://github.com/aionhw/xezim/issues/108), which blocks + * every DPI foreign IP (the wrappers are parameterized child modules), so the ips sim specs do not + * list xezim yet. + */ +object Xezim extends VerilogLinter, VerilogSimulator: + val toolName: String = "Xezim" + protected def binExec: String = "xezim" + protected def versionCmd: String = "-V" + protected def extractVersion(cmdRetStr: String): Option[String] = + val versionPattern = """xezim version\s+(\d+\.\d+\.\d+)""".r + versionPattern.findFirstMatchIn(cmdRetStr).map(_.group(1)) + + protected def includeFolderFlag: String = "-I" + + // xezim is SystemVerilog-only: IEEE 1800-2023 grammar by default, and `--sv2017` opts back to + // the 1800-2017 edition, which covers every DFHDL sv dialect. There is no IEEE 1364 mode, so + // the plain-Verilog dialects are unsupported. + protected def lintCmdLanguageFlag(dialect: VerilogDialect): String = + dialect match + case VerilogDialect.v95 | VerilogDialect.v2001 => + throw new java.lang.IllegalArgumentException( + "Current dialect is not supported for Xezim linting." + ) + case _ => "--sv2017" + + override protected def lintCmdPreLangFlags(using + CompilerOptions, + ToolOptions, + MemberGetSet + ): String = constructCommand( + "--compile", + s"-s $topName" + ) + + // xezim exits 0 even when it reports errors (https://github.com/aionhw/xezim/issues/107), so + // the loggers own error detection: compile diagnostics read `[file] line:col: error: ...`, + // elaboration failures read `Simulation error: ...`, and the runtime severity tasks print + // Questa-style `** Error:`/`** Fatal:`. + private def xezimLogger: Option[Tool.ProcessLogger] = + Some( + Tool.ProcessLogger( + lineIsWarning = (line: String) => + line.startsWith("** Warning") || line.contains(": warning:"), + lineIsSuppressed = (line: String) => false, + lineIsErrorOpt = Some((line: String) => + line.startsWith("** Error") || line.startsWith("** Fatal") || + line.startsWith("Simulation error:") || line.contains(": error:") + ) + ) + ) + + override protected def lintLogger(using + CompilerOptions, + ToolOptions, + MemberGetSet + ): Option[Tool.ProcessLogger] = xezimLogger + + override protected def simulateLogger(using + CompilerOptions, + SimulatorOptions, + MemberGetSet + ): Option[Tool.ProcessLogger] = xezimLogger + + override protected def simulateCmdPreLangFlags(using + CompilerOptions, + SimulatorOptions, + MemberGetSet + ): String = constructCommand( + "--simulate", + s"-s $topName", + // xezim caps simulated time at 100us by default; DFHDL testbenches terminate themselves + // with $finish, so push the cap far away to match the other simulators' unbounded default. + "--max-time 1000s", + // Foreign IP DPI integration: load each IP's DPI shared library at run time. + constructCommand( + foreignSources.filter(_.dpiLib.nonEmpty).map { f => + s"--dpi-lib ${foreignLibDir(f)}/${foreignSharedLibFile(f.dpiLib)}" + }* + ) + ) + + override protected def simulateCmdLanguageFlag(dialect: VerilogDialect): String = + lintCmdLanguageFlag(dialect) + +end Xezim