Skip to content

Commit f135226

Browse files
committed
hdl: disallow signed(0) values with unclear semantics.
Fixes #807.
1 parent 21b5451 commit f135226

File tree

3 files changed

+36
-13
lines changed

3 files changed

+36
-13
lines changed

amaranth/hdl/ast.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,15 @@ class Shape:
7878
If ``False``, the value is unsigned. If ``True``, the value is signed two's complement.
7979
"""
8080
def __init__(self, width=1, signed=False):
81-
if not isinstance(width, int) or width < 0:
82-
raise TypeError("Width must be a non-negative integer, not {!r}"
83-
.format(width))
81+
if not isinstance(width, int):
82+
raise TypeError(f"Width must be an integer, not {width!r}")
83+
if not signed and width < 0:
84+
raise TypeError(f"Width of an unsigned value must be zero or a positive integer, "
85+
f"not {width}")
86+
if signed and width <= 0:
87+
raise TypeError(f"Width of a signed value must be a positive integer, not {width}")
8488
self.width = width
85-
self.signed = signed
89+
self.signed = bool(signed)
8690

8791
# The algorithm for inferring shape for standard Python enumerations is factored out so that
8892
# `Shape.cast()` and Amaranth's `EnumMeta.as_shape()` can both use it.
@@ -116,7 +120,7 @@ def cast(obj, *, src_loc_at=0):
116120
return Shape(obj)
117121
elif isinstance(obj, range):
118122
if len(obj) == 0:
119-
return Shape(0, obj.start < 0)
123+
return Shape(0)
120124
signed = obj[0] < 0 or obj[-1] < 0
121125
width = max(bits_for(obj[0], signed),
122126
bits_for(obj[-1], signed))

docs/lang.rst

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,8 @@ Shapes from ranges
153153

154154
Casting a shape from a :class:`range` ``r`` produces a shape that:
155155

156-
* has a width large enough to represent both ``min(r)`` and ``max(r)``, and
157-
* is signed if either ``min(r)`` or ``max(r)`` are negative, unsigned otherwise.
156+
* has a width large enough to represent both ``min(r)`` and ``max(r)``, but not larger, and
157+
* is signed if ``r`` contains any negative values, unsigned otherwise.
158158

159159
Specifying a shape with a range is convenient for counters, indexes, and all other values whose width is derived from a set of numbers they must be able to fit:
160160

@@ -184,6 +184,16 @@ Specifying a shape with a range is convenient for counters, indexes, and all oth
184184

185185
Amaranth detects uses of :class:`Const` and :class:`Signal` that invoke such an off-by-one error, and emits a diagnostic message.
186186

187+
.. note::
188+
189+
An empty range always casts to an ``unsigned(0)``, even if both of its bounds are negative.
190+
This happens because, being empty, it does not contain any negative values.
191+
192+
.. doctest::
193+
194+
>>> Shape.cast(range(-1, -1))
195+
unsigned(0)
196+
187197

188198
.. _lang-shapeenum:
189199

tests/test_hdl_ast.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,20 @@ def test_make(self):
4242
s3 = Shape(3, True)
4343
self.assertEqual(s3.width, 3)
4444
self.assertEqual(s3.signed, True)
45+
s4 = Shape(0)
46+
self.assertEqual(s4.width, 0)
47+
self.assertEqual(s4.signed, False)
4548

4649
def test_make_wrong(self):
4750
with self.assertRaisesRegex(TypeError,
48-
r"^Width must be a non-negative integer, not -1$"):
49-
Shape(-1)
51+
r"^Width must be an integer, not 'a'$"):
52+
Shape("a")
53+
with self.assertRaisesRegex(TypeError,
54+
r"^Width of an unsigned value must be zero or a positive integer, not -1$"):
55+
Shape(-1, signed=False)
56+
with self.assertRaisesRegex(TypeError,
57+
r"^Width of a signed value must be a positive integer, not 0$"):
58+
Shape(0, signed=True)
5059

5160
def test_compare_non_shape(self):
5261
self.assertNotEqual(Shape(1, True), "hi")
@@ -87,7 +96,7 @@ def test_cast_int(self):
8796

8897
def test_cast_int_wrong(self):
8998
with self.assertRaisesRegex(TypeError,
90-
r"^Width must be a non-negative integer, not -1$"):
99+
r"^Width of an unsigned value must be zero or a positive integer, not -1$"):
91100
Shape.cast(-1)
92101

93102
def test_cast_tuple_wrong(self):
@@ -116,7 +125,7 @@ def test_cast_range(self):
116125
self.assertEqual(s6.signed, False)
117126
s7 = Shape.cast(range(-1, -1))
118127
self.assertEqual(s7.width, 0)
119-
self.assertEqual(s7.signed, True)
128+
self.assertEqual(s7.signed, False)
120129
s8 = Shape.cast(range(0, 10, 3))
121130
self.assertEqual(s8.width, 4)
122131
self.assertEqual(s8.signed, False)
@@ -386,7 +395,7 @@ def test_shape(self):
386395

387396
def test_shape_wrong(self):
388397
with self.assertRaisesRegex(TypeError,
389-
r"^Width must be a non-negative integer, not -1$"):
398+
r"^Width of an unsigned value must be zero or a positive integer, not -1$"):
390399
Const(1, -1)
391400

392401
def test_wrong_fencepost(self):
@@ -1022,7 +1031,7 @@ def test_shape(self):
10221031

10231032
def test_shape_wrong(self):
10241033
with self.assertRaisesRegex(TypeError,
1025-
r"^Width must be a non-negative integer, not -10$"):
1034+
r"^Width of an unsigned value must be zero or a positive integer, not -10$"):
10261035
Signal(-10)
10271036

10281037
def test_name(self):

0 commit comments

Comments
 (0)