diff --git a/docs/followups.md b/docs/followups.md index a2c57f3..4ad7c88 100644 --- a/docs/followups.md +++ b/docs/followups.md @@ -7,8 +7,12 @@ IN-PROGRESS / DONE. These are not bugs that produce wrong output today; they are seams worth closing when the surrounding code is next touched. Resolved items are moved to -`docs/old/old-followups.md` once they ship (most recently the device codegen- -quality gap vs `nvcc` — predication, FMA, alignment — which was item 2 here). +`docs/old/old-followups.md` once they ship (most recently the MAXWORD32 / +MAXWORD64 parity constants, which was item 5 here — the wide unsigned types now +predeclare `MAXWORD32` / `MAXWORD64` alongside `MAXINT32` / `MAXINT64`, gated on +`wide-integers`; before that, the wide same-width WORD/INTEGER signedness mix, +item 6, where `_check_word_int_mix` now covers `WORD32`/`INTEGER32` and +`WORD64`/`INTEGER64` at equal rank under the same `strict-word-int` discipline). --- @@ -51,7 +55,7 @@ variant-record long-form `NEW` behavior. --- -## 3. WORD/INTEGER constant exemption: fold constant expressions [OPEN] +## 2. WORD/INTEGER constant exemption: fold constant expressions [OPEN] **Where.** `type_checker.py::_is_constant_integer_expr` (consulted by `_check_word_int_assign` and `_check_word_int_mix`). @@ -81,7 +85,7 @@ confirm `tests/test_word_int_strictness.py` still rejects genuine variables. --- -## 4. ODD(WORD) is rejected but should be accepted [OPEN] +## 3. ODD(WORD) is rejected but should be accepted [OPEN] **Where.** `builtins_registry.py` registers `ODD` as `FunctionType('ODD', [('n', INTEGER_TYPE)], BOOLEAN_TYPE)`; the argument check rejects a WORD actual. @@ -103,53 +107,3 @@ signedness-independent. **How to verify.** Flip `TestManualKnownGaps::test_odd_accepts_word_is_a_known_gap` to assert ACCEPT (and add a build-and-run parity check for `ODD(WORD)` vs `ODD(INTEGER)`). - ---- - -## 5. MAXWORD32 / MAXWORD64 parity constants [OPEN] - -**Where.** `builtins_registry.py` (constant registration) and -`codegen/base.py` (`self.constants` seeding), alongside `MAXINT32`/`MAXINT64`. - -**What.** The wide *signed* types ship with `MAXINT32`/`MAXINT64`, but the new -wide *unsigned* types `WORD32`/`WORD64` do not yet have `MAXWORD32` -(`4294967295`) / `MAXWORD64` (`18446744073709551615`) predeclared constants. - -**Why it matters.** Minor parity gap only. The types are fully usable without -them (literals, variables, widening, arithmetic, and unsigned `WRITE` all work); -this is a convenience constant, deferred to avoid the unsigned-constant width -selection in the codegen const path (`MAXWORD64` needs an i64 whose value exceeds -the signed i64 max). - -**Suggested resolution.** Seed `MAXWORD32`/`MAXWORD64` in both the checker -registry (gated on `wide-integers`, as `MAXINT32`/`MAXINT64` are) and the codegen -constants, verifying `_const_ir` emits them at i32/i64 with the correct unsigned -bit pattern (follow how `MAXWORD = 65535` is already emitted as an i16). - -**How to verify.** Add a build-and-run check that `WRITELN(MAXWORD32)` prints -`4294967295` and `WRITELN(MAXWORD64)` prints `18446744073709551615`. - ---- - -## 6. WORD32/INTEGER32 (same-width) signedness mix is undiagnosed [OPEN] - -**Where.** `type_checker.py::_check_word_int_mix` (fires only for the 16-bit -`WORD`/`INTEGER` pair); `type_system.py::binary_op_result_type` resolves a -same-width unsigned/signed mix to the unsigned type. - -**What.** The vintage WORD/INTEGER (16-bit) expression mix warns (and errors -under `-f strict-word-int`). The analogous *wide* same-width mixes -(`WORD32`/`INTEGER32`, `WORD64`/`INTEGER64`) silently resolve to the unsigned -type with no diagnostic. - -**Why it matters.** These are extension types outside the 1981 manual, so there -is no vintage rule to conform to; leaving them undiagnosed is a deliberate, safe -default. But a user who opted into `-f strict-word-int` might reasonably expect -the same signedness discipline at all widths. - -**Suggested resolution.** If desired, generalize `_check_word_int_mix` to the -full WORD-family/INTEGER-family at equal rank, keeping the INTEGER-constant -exemption, behind the existing `strict-word-int` flag. - -**How to verify.** Add matrix rows for `WORD32 + INTEGER32 variable` asserting a -warning by default and an error under `strict-word-int`. diff --git a/docs/old/old-followups.md b/docs/old/old-followups.md index 400e1a4..f335759 100644 --- a/docs/old/old-followups.md +++ b/docs/old/old-followups.md @@ -369,3 +369,87 @@ already rejects an addrspace mismatch), and it is now hardened to raise loudly under `is_device_module` instead of silently dropping a segment — implementing this item's "treat the seg→flat path as a type error in device context" resolution. Covered by `tests/test_device_ads_no_segment.py`. + +--- + +## 6. WORD32/INTEGER32 (same-width) signedness mix is undiagnosed [DONE] + +**Where.** `type_checker.py::_check_word_int_mix` (the same-width unsigned/signed +diagnostic) and `type_system.py::binary_op_result_type` (which resolves a +same-width mix to the unsigned type). + +**What.** The vintage WORD/INTEGER (16-bit) expression mix warned (and errored +under `-f strict-word-int`), but the analogous *wide* same-width mixes +(`WORD32`/`INTEGER32`, `WORD64`/`INTEGER64`) silently resolved to the unsigned +type with no diagnostic — even under `strict-word-int`. The check was hard-wired +to the rank-0 pair (`a_t == WORD_TYPE and b_t == INTEGER_TYPE`), so it never fired +for the wide extension types. + +**Why it mattered.** The wide types are extensions outside the 1981 manual, so +leaving them undiagnosed was a safe default rather than a wrong result. But a +same-width unsigned/signed mix carries the identical "which signedness does the +arithmetic use?" ambiguity at every width, and a user who opted into +`-f strict-word-int` could reasonably expect the same signedness discipline +across the whole integer family. + +**Resolution.** `_check_word_int_mix` now generalizes to the full +WORD-family/INTEGER-family at **equal rank** (WORD/INTEGER, WORD32/INTEGER32, +WORD64/INTEGER64) via small `_WORD_FAMILY_RANK`/`_INT_FAMILY_RANK` maps. The +behavior is uniform across widths: a warning by default, a hard error under +`-f strict-word-int`, and the INTEGER-constant exemption preserved at every +width. Unequal-width mixes are deliberately **not** flagged — there the wider +operand's signedness wins unambiguously (e.g. `WORD(16) + INTEGER32 -> +INTEGER32`), so there is no coin-flip to warn about. The 16-bit behavior is +byte-for-byte unchanged. The stale "wide-type mixes are not diagnosed" comment in +`binary_op_result_type` was corrected. + +**How verified.** New rows in `tests/test_conversion_matrix.py` +(`word32_plus_int32_var_default` ACCEPT-with-warning, `word32_plus_int32_var_strict` +REJECT, `word64_plus_int64_var_strict` REJECT, the constant-exemption row, and an +unequal-width clean row) plus a new `TestWideSameWidthMix` class in +`tests/test_word_int_strictness.py` (warns by default, errors under strict, holds +the constant exemption, leaves unequal-width mixes clean). The existing 16-bit +WORD/INTEGER strictness tests remain green, confirming no regression. + +--- + +## 5. MAXWORD32 / MAXWORD64 parity constants [DONE] + +**Where.** `builtins_registry.py` (constant registration), `codegen/base.py` +(`self.constants` / `self.constant_types` seeding), `codegen/constfold.py` +(`_const_ir` width selection), and `codegen/io_write_read.py` (`_pas_type`, the +WRITE signed/unsigned format selector). + +**What.** The wide *signed* types shipped with `MAXINT32`/`MAXINT64`, but the wide +*unsigned* types `WORD32`/`WORD64` had no `MAXWORD32` (`4294967295`) / +`MAXWORD64` (`18446744073709551615`) predeclared constants. They are now seeded +on both the checker and codegen sides, gated on `wide-integers` exactly like +`MAXINT32`/`MAXINT64`, and carry full `WORD32`/`WORD64` type identity. + +**Why it mattered.** Minor parity gap only — the types were already fully usable +without them. The deferral was about the unsigned-constant width selection in the +codegen const path: `MAXWORD64` is `2**64-1`, which exceeds the signed i64 max, +so it cannot fall through to the i32 default in `_const_ir`. + +**Resolution.** `_is...`/registration mirrors `MAXINT32`/`MAXINT64`: +`builtins_registry.py` registers both constants under `wide-integers` (with the +WORD32/WORD64 types now imported), and `codegen/base.py` seeds their magnitudes +into `self.constants`. `_const_ir` emits `MAXWORD64` at i64 alongside `MAXINT64` +(the all-ones bit pattern); `MAXWORD32` emits at the i32 default, which already +held its value. One step beyond the original touchpoints was required: WRITE +picks signed vs unsigned formatting from the argument's Pascal type via +`_pas_type`, and builtin constants are not seeded into the codegen scope, so +`_pas_type` returned `None` and both constants formatted signed (printing `-1`). +A small `self.constant_types` tag map (seeded alongside `self.constants`, gated +identically) now lets `_pas_type` recover the `WORD32`/`WORD64` tag so the wide +unsigned max constants print unsigned. (`MAXWORD` only ever printed correctly by +luck — `65535` fits in a positive signed i32 — which is why the high-bit-set wide +constants exposed the gap.) + +**How verified.** New `TestWideMaxConstants` (gating + WORD32/WORD64 type +identity: same-type ACCEPT, WORD32->WORD64 widen ACCEPT, INTEGER assign REJECT, +WORD64->WORD32 narrow REJECT) and `TestWideMaxConstantsRun` (build-and-run: +`WRITELN(MAXWORD32)` prints `4294967295`, `WRITELN(MAXWORD64)` prints +`18446744073709551615`, and a round-trip through WORD32/WORD64 variables) in +`tests/test_wide_unsigned_types.py`. Full suite green: `971 passed, 1 skipped, +115 subtests passed`. diff --git a/src/pascal1981/builtins_registry.py b/src/pascal1981/builtins_registry.py index b2bef02..a9eac1f 100644 --- a/src/pascal1981/builtins_registry.py +++ b/src/pascal1981/builtins_registry.py @@ -7,7 +7,7 @@ from .symbol_table import Symbol from .features import is_extended -from .type_system import (BOOLEAN_TYPE, CHAR_TYPE, INTEGER32_TYPE, INTEGER64_TYPE, INTEGER_TYPE, REAL_TYPE, WORD_TYPE, EnumType, FileType, FunctionType, LStringType, PointerType, +from .type_system import (BOOLEAN_TYPE, CHAR_TYPE, INTEGER32_TYPE, INTEGER64_TYPE, INTEGER_TYPE, REAL_TYPE, WORD32_TYPE, WORD64_TYPE, WORD_TYPE, EnumType, FileType, FunctionType, LStringType, PointerType, ProcedureType, RecordType, StringType) # Lists of all built-in function and procedure names @@ -150,6 +150,11 @@ def define_builtin(name: str, symbol_type, kind: str): if features and features.get('wide-integers', False): define_builtin('MAXINT32', INTEGER32_TYPE, 'const') define_builtin('MAXINT64', INTEGER64_TYPE, 'const') + # Unsigned siblings of MAXINT32/MAXINT64 for the wide WORD types. + # MAXWORD32 = 2**32-1, MAXWORD64 = 2**64-1. Gated identically so the + # wide signed/unsigned max-constant surfaces never drift apart. + define_builtin('MAXWORD32', WORD32_TYPE, 'const') + define_builtin('MAXWORD64', WORD64_TYPE, 'const') define_builtin('NULL', LStringType(0), 'const') filemodes_type = EnumType(['SEQUENTIAL', 'TERMINAL', 'DIRECT'], name='FILEMODES') define_builtin('SEQUENTIAL', filemodes_type, 'const') diff --git a/src/pascal1981/codegen/base.py b/src/pascal1981/codegen/base.py index 8a5afab..b171cd0 100644 --- a/src/pascal1981/codegen/base.py +++ b/src/pascal1981/codegen/base.py @@ -168,6 +168,21 @@ def __init__(self, if self.feature_enabled('wide-integers'): self.constants['MAXINT32'] = 2147483647 self.constants['MAXINT64'] = 9223372036854775807 + # Unsigned wide max constants (see builtins_registry). Stored as + # their true positive magnitudes; _const_ir emits MAXWORD64 at i64 + # (the value exceeds the signed i64 max, so it must not fall through + # to the i32 default) and MAXWORD32 at i32. + self.constants['MAXWORD32'] = 4294967295 + self.constants['MAXWORD64'] = 18446744073709551615 + # Pascal-type tags for builtin constants that are not seeded into the + # codegen scope as symbols. Consulted by the WRITE path (_pas_type) to + # pick signed vs unsigned formatting: MAXWORD32/MAXWORD64 have the high + # bit set, so they must format unsigned or they print as -1. Keyed + # UPPER; values are Pascal type names matched by the formatter. + self.constant_types: Dict[str, str] = {} + if self.feature_enabled('wide-integers'): + self.constant_types['MAXWORD32'] = 'WORD32' + self.constant_types['MAXWORD64'] = 'WORD64' self.type_aliases: Dict[str, Type] = {} # compile-time type aliases, keyed UPPER # Seed the C-ABI fixed-width aliases (Phase 1 of the C-FFI plan) so a # foreign `[C]` routine spelled with CINT/CLONG/CPTR/etc. lowers through diff --git a/src/pascal1981/codegen/constfold.py b/src/pascal1981/codegen/constfold.py index 4c7cafa..2860a82 100644 --- a/src/pascal1981/codegen/constfold.py +++ b/src/pascal1981/codegen/constfold.py @@ -27,7 +27,10 @@ def _const_ir(self, name_upper: str) -> ir.Constant: return ir.Constant(ir.DoubleType(), v) if name_upper == 'MAXINT': return ir.Constant(ir.IntType(16), int(v)) - if name_upper == 'MAXINT64': + if name_upper in ('MAXINT64', 'MAXWORD64'): + # MAXWORD64 = 2**64-1 exceeds the signed i64 max; it must be emitted + # at i64 width (the bit pattern is all-ones) rather than falling + # through to the i32 default, which would not hold the value. return ir.Constant(ir.IntType(64), int(v)) return ir.Constant(ir.IntType(32), int(v)) diff --git a/src/pascal1981/codegen/io_write_read.py b/src/pascal1981/codegen/io_write_read.py index 9b5c305..2b65c0f 100644 --- a/src/pascal1981/codegen/io_write_read.py +++ b/src/pascal1981/codegen/io_write_read.py @@ -41,6 +41,14 @@ def _pas_type(self, expr) -> Optional[object]: if isinstance(expr, (Identifier, Designator)): sym = self.scope.lookup(expr.name) or self.scope.lookup(expr.name.upper()) ty = getattr(sym, 'type_expr', None) if sym else None + if ty is None and (not isinstance(expr, Designator) or not expr.selectors): + # Builtin constants (e.g. MAXWORD32/MAXWORD64) are not seeded + # into the codegen scope, so fall back to their recorded Pascal + # type tag. This drives unsigned WRITE formatting for the wide + # unsigned max constants, which would otherwise print as -1. + tag = self.constant_types.get(expr.name.upper()) + if tag is not None: + return NamedType(tag, None) if isinstance(expr, Designator) and expr.selectors and ty is not None: cur = ty for sel in expr.selectors: diff --git a/src/pascal1981/type_checker.py b/src/pascal1981/type_checker.py index e0a7b3e..c0eabfb 100644 --- a/src/pascal1981/type_checker.py +++ b/src/pascal1981/type_checker.py @@ -2303,22 +2303,58 @@ def _check_word_int_assign(self, value_type, target_type, value_expr, node) -> N "constants change to WORD; convert a signed INTEGER value with " "WRD(...)", node) - def _check_word_int_mix(self, left_type, right_type, left_expr, right_expr, op, node) -> None: - """Diagnose a WORD/INTEGER mix in an arithmetic or bitwise expression. + # Equal-rank unsigned/signed integer pairs that carry the WORD/INTEGER + # signedness ambiguity. Rank == index into each tuple: rank 0 is the vintage + # 16-bit WORD/INTEGER pair the manual actually rules on; ranks 1 and 2 are the + # wide extension types (WORD32/INTEGER32, WORD64/INTEGER64), which the manual + # does not cover but which inherit the same "which signedness does the + # arithmetic use?" hazard. `binary_op_result_type` resolves every one of + # these same-width mixes to the unsigned member, so the diagnostic below is + # what makes that silent choice visible (and, under -f strict-word-int, + # refusable). Membership is tested with ``==`` rather than a dict keyed by + # type instance, because some operand types (e.g. SetType) are unhashable. + _WORD_FAMILY_BY_RANK = (WORD_TYPE, WORD32_TYPE, WORD64_TYPE) + _INT_FAMILY_BY_RANK = (INTEGER_TYPE, INTEGER32_TYPE, INTEGER64_TYPE) + + @staticmethod + def _family_rank(t, family) -> Optional[int]: + """Rank (0/1/2) of ``t`` within ``family``, or None if it is not a member. - Allowed when the INTEGER operand is a constant (it changes to WORD). - Otherwise a warning by default (the vintage compiler arbitrarily picks - signed or unsigned arithmetic), promoted to a hard error under - -f strict-word-int. + Uses equality, not a hash lookup, so unhashable operand types (SetType, + ArrayType, ...) simply compare unequal instead of raising. + """ + for rank, member in enumerate(family): + if t == member: + return rank + return None + + def _check_word_int_mix(self, left_type, right_type, left_expr, right_expr, op, node) -> None: + """Diagnose an unsigned/signed (WORD-family/INTEGER-family) mix at equal + width in an arithmetic or bitwise expression. + + Covers the vintage 16-bit WORD/INTEGER pair *and* the equal-width wide + extension pairs WORD32/INTEGER32 and WORD64/INTEGER64. A mix at unequal + width is not flagged here: there the wider operand's signedness + unambiguously wins (see `binary_op_result_type`), so there is no + signedness coin-flip to warn about. + + Allowed when the signed (INTEGER-family) operand is a constant (it + changes to the unsigned type). Otherwise a warning by default (the + vintage compiler arbitrarily picks signed or unsigned arithmetic), + promoted to a hard error under -f strict-word-int. """ ARITH_BITWISE = {'PLUS', 'MINUS', 'MUL', 'DIV', 'MOD', 'AND', 'OR', 'XOR'} if op not in ARITH_BITWISE: return for a_t, b_t, b_e in ((left_type, right_type, right_expr), (right_type, left_type, left_expr)): - if a_t == WORD_TYPE and b_t == INTEGER_TYPE: + # a_t is the unsigned (WORD-family) operand, b_t the signed + # (INTEGER-family) operand; only flag them at equal width/rank. + a_rank = self._family_rank(a_t, self._WORD_FAMILY_BY_RANK) + b_rank = self._family_rank(b_t, self._INT_FAMILY_BY_RANK) + if a_rank is not None and a_rank == b_rank: if self._is_constant_integer_expr(b_e): - return # constant INTEGER changes to WORD: clean + return # constant INTEGER changes to the WORD type: clean msg = ("WORD and INTEGER values cannot be mixed in an expression " "unless the INTEGER operand is a constant; convert " "explicitly with WRD(...) or ORD(...)") diff --git a/src/pascal1981/type_system.py b/src/pascal1981/type_system.py index 1fa20ac..2365892 100644 --- a/src/pascal1981/type_system.py +++ b/src/pascal1981/type_system.py @@ -492,8 +492,10 @@ def binary_op_result_type(left_type: Type, op: str, right_type: Type) -> Optiona # value zero-extends into the i32); when they are the SAME width, an unsigned # operand makes the result unsigned (WORD + INTEGER -> WORD, WORD32 + # INTEGER32 -> WORD32), consistent with the vintage rank-0 WORD/INTEGER rule. - # The vintage WORD/INTEGER (16-bit) mix is additionally diagnosed in the type - # checker; the wide-type mixes are extension territory and are not diagnosed. + # Every such same-width unsigned/signed mix (the vintage 16-bit pair and the + # wide WORD32/INTEGER32, WORD64/INTEGER64 pairs alike) is additionally + # diagnosed in the type checker (_check_word_int_mix): a warning by default, + # an error under -f strict-word-int, with the INTEGER-constant exemption. int_rank = {IntegerType: 0, WordType: 0, Integer32Type: 1, Word32Type: 1, Integer64Type: 2, Word64Type: 2} diff --git a/tests/test_conversion_matrix.py b/tests/test_conversion_matrix.py index 64f7229..6ff9c09 100644 --- a/tests/test_conversion_matrix.py +++ b/tests/test_conversion_matrix.py @@ -36,6 +36,9 @@ VINTAGE = None # faithful 1981 dialect: no flags WIDE = {"wide-integers": True, "wide-reals": True} STRICT = {"strict-word-int": True} +# The wide extension types only exist under wide-integers; pairing that surface +# with strict-word-int is how we exercise the wide same-width signedness rule. +WIDE_STRICT = {"wide-integers": True, "wide-reals": True, "strict-word-int": True} def verdict(src, features=VINTAGE): @@ -160,6 +163,18 @@ class TestArithmeticMatrix(unittest.TestCase): "constant exemption holds even under strict-word-int"), ("int_plus_int32", "INTEGER32", "i: INTEGER;", "i", "j: INTEGER32;", "j", "+", WIDE, ACCEPT, "ext: rank promotion to INTEGER32"), + # --- wide same-width signedness mix: same rule as 16-bit WORD/INTEGER --- + ("word32_plus_int32_var_default", "WORD32", "w: WORD32;", "w", "i: INTEGER32;", "i", "+", WIDE, ACCEPT, + "ext: WORD32+INTEGER32-var warns by default, resolves to WORD32 so it compiles"), + ("word32_plus_int32_var_strict", "WORD32", "w: WORD32;", "w", "i: INTEGER32;", "i", "+", WIDE_STRICT, REJECT, + "ext: strict-word-int promotes the wide same-width mix to an error"), + ("word32_plus_int32_const_strict", "WORD32", "w: WORD32;", "w", "", "1", "+", WIDE_STRICT, ACCEPT, + "ext: INTEGER constant exemption holds at width 32 even under strict"), + ("word64_plus_int64_var_strict", "WORD64", "w: WORD64;", "w", "i: INTEGER64;", "i", "+", WIDE_STRICT, REJECT, + "ext: strict-word-int promotes the WORD64/INTEGER64 mix to an error"), + # --- unequal width is NOT a signedness coin-flip: wider signed wins --- + ("word_plus_int32_unequal_clean", "INTEGER32", "w: WORD;", "w", "i: INTEGER32;", "i", "+", WIDE_STRICT, ACCEPT, + "ext: WORD(16)+INTEGER32 widens unambiguously to INTEGER32; not flagged even under strict"), ("real_plus_int", "REAL", "r: REAL;", "r", "i: INTEGER;", "i", "*", VINTAGE, ACCEPT, "manual: INTEGER widens to REAL"), ] @@ -179,6 +194,30 @@ def test_word_int_const_mix_is_clean(self): src = arith_prog("WORD", "w: WORD;", "w", "", "1", "+") self.assertFalse(has_word_int_warning(src), "constant INTEGER mix should be clean") + def test_wide_same_width_mix_emits_warning_by_default(self): + # The wide same-width unsigned/signed mix carries the same vintage-style + # warning the 16-bit pair does (it resolves to the unsigned type, so the + # signedness of the arithmetic is otherwise a silent coin-flip). + for dst, ad, bd in (("WORD32", "w: WORD32;", "i: INTEGER32;"), + ("WORD64", "w: WORD64;", "i: INTEGER64;")): + with self.subTest(dst=dst): + src = arith_prog(dst, ad, "w", bd, "i", "+") + self.assertTrue(has_word_int_warning(src, WIDE), + f"expected a {dst}/signed mix warning") + + def test_wide_same_width_const_mix_is_clean(self): + # INTEGER-constant exemption carries to the wide types. + src = arith_prog("WORD32", "w: WORD32;", "w", "", "1", "+") + self.assertFalse(has_word_int_warning(src, WIDE), + "constant INTEGER mix should be clean at width 32 too") + + def test_unequal_width_mix_is_not_flagged(self): + # WORD(16) + INTEGER32: the wider signed operand wins unambiguously, so + # there is no signedness ambiguity and no diagnostic -- even under strict. + src = arith_prog("INTEGER32", "w: WORD;", "w", "i: INTEGER32;", "i", "+") + self.assertFalse(has_word_int_warning(src, WIDE), + "unequal-width mix must not warn") + class TestManualKnownGaps(unittest.TestCase): """Direct checks of individual manual rules; tracks remaining gaps explicitly.""" diff --git a/tests/test_wide_unsigned_types.py b/tests/test_wide_unsigned_types.py index f9377cd..edf6568 100644 --- a/tests/test_wide_unsigned_types.py +++ b/tests/test_wide_unsigned_types.py @@ -127,5 +127,60 @@ def test_synonyms_run(self): self.assertEqual([l.strip() for l in out.splitlines() if l.strip()], ["60000", "-100"]) +class TestWideMaxConstants(unittest.TestCase): + """MAXWORD32 / MAXWORD64: the unsigned siblings of MAXINT32 / MAXINT64. + + Gated on ``wide-integers`` exactly like the wide types and the signed wide + max constants. They carry full WORD32 / WORD64 type identity (assignable to + their own type, widen WORD32 -> WORD64, but not assignment-compatible with + INTEGER and not narrowable). + """ + + def test_gated_on_wide_integers(self): + self.assertTrue(ok("PROGRAM P; VAR w: WORD32; BEGIN w := MAXWORD32 END.", WI)) + self.assertTrue(ok("PROGRAM P; VAR w: WORD64; BEGIN w := MAXWORD64 END.", WI)) + # Absent in the vintage dialect, and absent under wide-reals alone. + self.assertFalse(ok("PROGRAM P; VAR w: WORD32; BEGIN w := MAXWORD32 END.", None)) + self.assertFalse(ok("PROGRAM P; VAR w: WORD64; BEGIN w := MAXWORD64 END.", None)) + self.assertFalse(ok("PROGRAM P; VAR w: WORD32; BEGIN w := MAXWORD32 END.", WR)) + + def test_type_identity(self): + # WORD32 widens to WORD64; the constants follow the same rules as values. + self.assertTrue(ok("PROGRAM P; VAR w: WORD64; BEGIN w := MAXWORD32 END.", WI)) + # WORD-family is not assignment-compatible with signed INTEGER. + self.assertFalse(ok("PROGRAM P; VAR i: INTEGER; BEGIN i := MAXWORD32 END.", WI)) + # No implicit narrowing WORD64 -> WORD32. + self.assertFalse(ok("PROGRAM P; VAR w: WORD32; BEGIN w := MAXWORD64 END.", WI)) + + +@requires_exe +class TestWideMaxConstantsRun(unittest.TestCase): + def test_maxword32_prints_unsigned(self): + # 2**32-1; has the high bit set, so it must print unsigned (not -1). + src = "PROGRAM P(output);\nBEGIN WRITELN(MAXWORD32) END." + rc, out, err = run(src, WI, "maxword32-print") + self.assertEqual(rc, 0, msg=err) + self.assertEqual(out.strip(), "4294967295") + + def test_maxword64_prints_unsigned(self): + # 2**64-1; exceeds the signed i64 max, so _const_ir must emit it at i64 + # and the formatter must print it unsigned (not -1). + src = "PROGRAM P(output);\nBEGIN WRITELN(MAXWORD64) END." + rc, out, err = run(src, WI, "maxword64-print") + self.assertEqual(rc, 0, msg=err) + self.assertEqual(out.strip(), "18446744073709551615") + + def test_constants_match_type_maxima(self): + # The constant equals the widened max of its type: a WORD32 var set to + # its largest literal prints the same as MAXWORD32, and likewise WORD64. + src = ("PROGRAM P(output);\nVAR a: WORD32; b: WORD64;\n" + "BEGIN a := MAXWORD32; b := MAXWORD64;\n" + "WRITELN(a); WRITELN(b) END.") + rc, out, err = run(src, WI, "maxword-roundtrip") + self.assertEqual(rc, 0, msg=err) + self.assertEqual([l.strip() for l in out.splitlines() if l.strip()], + ["4294967295", "18446744073709551615"]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_word_int_strictness.py b/tests/test_word_int_strictness.py index 7b3fb1a..da35f36 100644 --- a/tests/test_word_int_strictness.py +++ b/tests/test_word_int_strictness.py @@ -104,6 +104,60 @@ def test_minus_32768_is_invalid_integer(self): self.assertTrue(typecheck_source("PROGRAM P; VAR i: INTEGER; BEGIN i := -32767 END.").success) +# --------------------------------------------------------------------------- +# Wide same-width signedness mix (extension types, but same discipline) +# --------------------------------------------------------------------------- + +class TestWideSameWidthMix(unittest.TestCase): + """The wide extension pairs WORD32/INTEGER32 and WORD64/INTEGER64 inherit the + vintage WORD/INTEGER same-width signedness rule: warn by default, error under + -f strict-word-int, with the INTEGER-constant exemption. They are not in the + 1981 manual, but a same-width unsigned/signed mix has the identical + "which signedness does the arithmetic use?" ambiguity at every width. + """ + + WIDE = {"wide-integers": True} + WIDE_STRICT = {"wide-integers": True, "strict-word-int": True} + + def test_word32_int32_mix_warns_by_default(self): + for dst, decl in (("WORD32", "w: WORD32; i: INTEGER32;"), + ("WORD64", "w: WORD64; i: INTEGER64;")): + with self.subTest(dst=dst): + r = typecheck_source( + f"PROGRAM P; VAR d: {dst}; {decl} BEGIN d := w + i END.", + features=self.WIDE) + self.assertTrue(r.success) + self.assertTrue(any("WORD and INTEGER" in m for m in _warnings(r)), + f"{dst} mix should warn by default") + + def test_word32_int32_mix_errors_under_strict(self): + for dst, decl in (("WORD32", "w: WORD32; i: INTEGER32;"), + ("WORD64", "w: WORD64; i: INTEGER64;")): + with self.subTest(dst=dst): + r = typecheck_source( + f"PROGRAM P; VAR d: {dst}; {decl} BEGIN d := w + i END.", + features=self.WIDE_STRICT) + self.assertFalse(r.success) + self.assertTrue(any("WORD and INTEGER" in e.message for e in r.errors), + f"{dst} mix should be a hard error under strict-word-int") + + def test_wide_constant_exemption_holds(self): + # An INTEGER constant changes to the unsigned type at every width. + r = typecheck_source("PROGRAM P; VAR d: WORD32; w: WORD32; BEGIN d := w + 1 END.", + features=self.WIDE_STRICT) + self.assertTrue(r.success) + self.assertFalse(any("WORD and INTEGER" in m for m in _warnings(r))) + + def test_unequal_width_mix_is_not_flagged(self): + # WORD(16) + INTEGER32: the wider signed operand wins unambiguously, so + # there is no coin-flip and no diagnostic, even under strict-word-int. + r = typecheck_source( + "PROGRAM P; VAR d: INTEGER32; w: WORD; i: INTEGER32; BEGIN d := w + i END.", + features=self.WIDE_STRICT) + self.assertTrue(r.success, msg=str(r.errors)) + self.assertFalse(any("WORD and INTEGER" in m for m in _warnings(r))) + + # --------------------------------------------------------------------------- # Flag orthogonality # ---------------------------------------------------------------------------