feat(lang): integer numeric-limit accessors (s64.max, u8.min, s3.max) [NL.1]

A field-like access on a builtin INTEGER type name folds to a compile-time
constant of the queried type, driven by (width, signedness) arithmetic:
  sN: min=-(2^(N-1)), max=2^(N-1)-1;  uN: min=0, max=2^N-1
for every width s1..s64 / u1..u64 (not just power-of-two), plus usize/isize.

- type_resolver.zig: extract the single width parser (parseWidthInt) reused by
  resolveNamed AND the new accessors (no second parser — issue-0083 class);
  add resolveBuiltinName / integerWidthSign / integerLimitBits / integerLimitFor.
- lower.zig: lowerNumericLimit intercept beside the error.X / Struct.CONST /
  pack-arity identifier-receiver intercepts; folds ints via constInt, emits a
  clean diagnostic for a non-numeric receiver (bool/string/void/Any/noreturn),
  falls through for floats (NL.2).
- expr_typer.zig: mirror the result type so inferExprType reports the queried type.
- program_index.zig: recognize the accessors in the comptime-int / array-dim path
  so [u8.max]T (255) / [s16.max]T (32767) work; [u64.max]T is rejected oversized.
- u64.max / usize.max stored as the all-ones bit pattern with TYPE u64 (i64 -1),
  asserted via union { u: u64; s: s64 } reinterpret.

Docs: specs.md numeric-limits subsection (formulas + result-type + u64 note);
readme.md language overview. Examples 0148 (positive) / 0149 (negative-receiver).
Unit tests for the value computation in type_resolver.test.zig.

Gate: zig build, zig build test (359/359), tests/run_examples.sh (416 ok, 0 failed).
This commit is contained in:
agra
2026-06-04 16:14:06 +03:00
parent bc9777d81f
commit 04f46ef384
15 changed files with 384 additions and 14 deletions

View File

@@ -6,6 +6,7 @@ const lower = @import("lower.zig");
const Node = ast.Node;
const TypeId = types.TypeId;
const Lowering = lower.Lowering;
const TypeResolver = @import("type_resolver.zig").TypeResolver;
/// AST-level expression typing (architecture phase A3.1), extracted from
/// `Lowering.inferExprType`. Owns the structural / non-call expression shapes —
@@ -125,6 +126,22 @@ pub const ExprTyper = struct {
if (info.ty) |t| return t;
}
}
// Numeric-limit accessor: `<IntType>.min` / `.max` is a comptime
// const of the queried integer type — mirrors the lowerFieldAccess
// intercept so inference reports the same type (without it the
// const would be mistyped, e.g. boxed into an Any slot).
{
const type_name: ?[]const u8 = switch (fa.object.data) {
.identifier => |id| id.name,
.type_expr => |te| te.name,
else => null,
};
if (type_name) |tn| {
if (TypeResolver.integerLimitFor(tn, fa.field) != null) {
if (TypeResolver.resolveBuiltinName(tn, &self.l.module.types)) |t| return t;
}
}
}
// M1.3 — `obj.class` on an Obj-C-class pointer returns Class (*void).
if (std.mem.eql(u8, fa.field, "class")) {
if (self.l.objc().isObjcClassPointer(self.l.inferExprType(fa.object))) {

View File

@@ -4823,6 +4823,13 @@ pub const Lowering = struct {
}
}
// Numeric-limit accessor: `<IntType>.min` / `.max` folds to a comptime
// const of the queried type (sibling of the identifier-receiver
// intercepts above). Placed AFTER `Struct.CONST` so a user const named
// `min`/`max` wins on its own struct; a builtin type name can never
// name a user struct (reserved — issue 0076), so they never collide.
if (self.lowerNumericLimit(fa, span)) |ref| return ref;
// M1.3 — `obj.class` on any Obj-C-class pointer lowers to
// `object_getClass(obj)`. Sugar; the receiver is opaque so
// we don't auto-deref. Returns `Class` (alias for *void;
@@ -4897,6 +4904,37 @@ pub const Lowering = struct {
return self.lowerFieldAccessOnType(obj, obj_ty, fa.field, span);
}
/// Numeric-limit accessor intercept (`<IntType>.min` / `.max`), a sibling of
/// the `error.X` / `Struct.CONST` / pack-arity identifier-receiver intercepts
/// in `lowerFieldAccess`. Folds an integer type's `.min`/`.max` to a comptime
/// const of that type via the shared `TypeResolver` width logic (no second
/// width parser) + the existing `constInt` const path. Returns null when this
/// is not an integer-limit access, so the caller continues normal field
/// lowering. A `.min`/`.max` on a builtin NON-numeric receiver
/// (`bool`/`string`/`void`/`Any`/`noreturn`) is a clean diagnostic here (then
/// a placeholder, so lowering finishes and `hasErrors()` aborts the build); a
/// float receiver falls through (float limits are NL.2).
fn lowerNumericLimit(self: *Lowering, fa: *const ast.FieldAccess, span: ast.Span) ?Ref {
const name = switch (fa.object.data) {
.identifier => |id| id.name,
.type_expr => |te| te.name,
else => return null,
};
if (!std.mem.eql(u8, fa.field, "min") and !std.mem.eql(u8, fa.field, "max")) return null;
const ty = TypeResolver.resolveBuiltinName(name, &self.module.types) orelse return null;
if (TypeResolver.integerLimitFor(name, fa.field)) |value| {
return self.builder.constInt(value, ty);
}
// A builtin receiver that is not an integer: floats are NL.2 (fall
// through), every other builtin (bool/string/void/Any/noreturn) has no
// numeric limit.
if (ty == .f32 or ty == .f64) return null;
if (self.diagnostics) |d| {
d.addFmt(.err, span, "type '{s}' has no '.{s}' — numeric limits apply only to integer and float types", .{ name, fa.field });
}
return self.emitPlaceholder(fa.field);
}
/// Lower each pack element to a Ref: `pack_name[i]` when `method` is null,
/// or `pack_name[i].method()` when given. Synthesizes the index/field/call
/// AST per element and lowers it (substitution turns `xs[i]` into the

View File

@@ -3,6 +3,7 @@ const ast = @import("../ast.zig");
const types = @import("types.zig");
const inst = @import("inst.zig");
const errors = @import("../errors.zig");
const type_resolver = @import("type_resolver.zig");
const Node = ast.Node;
const TypeId = types.TypeId;
@@ -156,12 +157,22 @@ pub fn evalConstIntExpr(node: *const Node, ctx: anytype) ?i64 {
.identifier => |id| ctx.lookupDimName(id.name),
.type_expr => |te| ctx.lookupDimName(te.name),
.field_access => |fa| blk: {
// `<pack>.len` resolves to the monomorphised arity (e.g. an
// `inline for 0..xs.len` bound). Any other field access is not a
// compile-time integer leaf.
if (fa.object.data == .identifier and std.mem.eql(u8, fa.field, "len")) {
break :blk ctx.lookupPackLen(fa.object.data.identifier.name);
const obj_name: ?[]const u8 = switch (fa.object.data) {
.identifier => |id| id.name,
.type_expr => |te| te.name,
else => null,
};
if (obj_name) |on| {
// `<pack>.len` resolves to the monomorphised arity (e.g. an
// `inline for 0..xs.len` bound).
if (std.mem.eql(u8, fa.field, "len")) break :blk ctx.lookupPackLen(on);
// `<IntType>.min` / `.max` — the same fold the value path uses
// (type_resolver), so `[u8.max]T` agrees with `u8.max` in
// expression position. A `u64.max` (= -1 as i64) folds here too;
// `foldDimU32` then rejects it as a negative array dimension.
if (type_resolver.TypeResolver.integerLimitFor(on, fa.field)) |v| break :blk v;
}
// Any other field access is not a compile-time integer leaf.
break :blk null;
},
.unary_op => |u| switch (u.op) {

View File

@@ -160,3 +160,77 @@ test "TypeResolver.resolveNamed: width-int, string-prefix, unknown→stub" {
// never `.unresolved`, which is reserved for failed *generic* resolution).
try std.testing.expect(TypeResolver.resolveNamed("Unknown", &table, null) != .unresolved);
}
test "TypeResolver.parseWidthInt: every width 1..64, both signs; rejects out-of-range / non-int" {
// The single width parser — covers the named primitives (s8/u64/…) too.
try std.testing.expectEqual(@as(?TypeResolver.WidthInt, .{ .width = 1, .signed = true }), TypeResolver.parseWidthInt("s1"));
try std.testing.expectEqual(@as(?TypeResolver.WidthInt, .{ .width = 3, .signed = true }), TypeResolver.parseWidthInt("s3"));
try std.testing.expectEqual(@as(?TypeResolver.WidthInt, .{ .width = 64, .signed = true }), TypeResolver.parseWidthInt("s64"));
try std.testing.expectEqual(@as(?TypeResolver.WidthInt, .{ .width = 1, .signed = false }), TypeResolver.parseWidthInt("u1"));
try std.testing.expectEqual(@as(?TypeResolver.WidthInt, .{ .width = 64, .signed = false }), TypeResolver.parseWidthInt("u64"));
// Width 0 and >64, and non-`s`/`u` names, are not width-ints.
try std.testing.expect(TypeResolver.parseWidthInt("s0") == null);
try std.testing.expect(TypeResolver.parseWidthInt("u65") == null);
try std.testing.expect(TypeResolver.parseWidthInt("usize") == null);
try std.testing.expect(TypeResolver.parseWidthInt("f32") == null);
try std.testing.expect(TypeResolver.parseWidthInt("sx") == null);
try std.testing.expect(TypeResolver.parseWidthInt("s") == null);
}
test "TypeResolver.integerWidthSign: width-ints plus usize/isize, null for non-integers" {
try std.testing.expectEqual(@as(?TypeResolver.WidthInt, .{ .width = 64, .signed = false }), TypeResolver.integerWidthSign("usize"));
try std.testing.expectEqual(@as(?TypeResolver.WidthInt, .{ .width = 64, .signed = true }), TypeResolver.integerWidthSign("isize"));
try std.testing.expectEqual(@as(?TypeResolver.WidthInt, .{ .width = 8, .signed = false }), TypeResolver.integerWidthSign("u8"));
// Non-integer builtins and user names are not integer types.
try std.testing.expect(TypeResolver.integerWidthSign("f64") == null);
try std.testing.expect(TypeResolver.integerWidthSign("bool") == null);
try std.testing.expect(TypeResolver.integerWidthSign("void") == null);
try std.testing.expect(TypeResolver.integerWidthSign("MyStruct") == null);
}
test "TypeResolver.integerLimitFor: pinned min/max across widths and extremes" {
const L = struct {
fn v(name: []const u8, field: []const u8) i64 {
return TypeResolver.integerLimitFor(name, field).?;
}
};
// Sub-byte widths (arbitrary bit-width arithmetic, not a per-name table).
try std.testing.expectEqual(@as(i64, -1), L.v("s1", "min"));
try std.testing.expectEqual(@as(i64, 0), L.v("s1", "max"));
try std.testing.expectEqual(@as(i64, -2), L.v("s2", "min"));
try std.testing.expectEqual(@as(i64, 1), L.v("s2", "max"));
try std.testing.expectEqual(@as(i64, 3), L.v("s3", "max"));
try std.testing.expectEqual(@as(i64, 0), L.v("u1", "min"));
try std.testing.expectEqual(@as(i64, 1), L.v("u1", "max"));
try std.testing.expectEqual(@as(i64, 3), L.v("u2", "max"));
// Byte / word.
try std.testing.expectEqual(@as(i64, -128), L.v("s8", "min"));
try std.testing.expectEqual(@as(i64, 127), L.v("s8", "max"));
try std.testing.expectEqual(@as(i64, 255), L.v("u8", "max"));
try std.testing.expectEqual(@as(i64, -2147483648), L.v("s32", "min"));
try std.testing.expectEqual(@as(i64, 2147483647), L.v("s32", "max"));
// s64 extremes = i64 extremes.
try std.testing.expectEqual(std.math.minInt(i64), L.v("s64", "min"));
try std.testing.expectEqual(std.math.maxInt(i64), L.v("s64", "max"));
// u63.max fits i64; u64.max is all-ones (= -1 as i64, maxInt(u64) as u64).
try std.testing.expectEqual(std.math.maxInt(i64), L.v("u63", "max"));
try std.testing.expectEqual(@as(i64, -1), L.v("u64", "max"));
try std.testing.expectEqual(std.math.maxInt(u64), @as(u64, @bitCast(L.v("u64", "max"))));
try std.testing.expectEqual(@as(i64, 0), L.v("u64", "min"));
// usize/isize track u64/s64 on the host.
try std.testing.expectEqual(L.v("u64", "max"), L.v("usize", "max"));
try std.testing.expectEqual(@as(i64, 0), L.v("usize", "min"));
try std.testing.expectEqual(L.v("s64", "min"), L.v("isize", "min"));
try std.testing.expectEqual(L.v("s64", "max"), L.v("isize", "max"));
}
test "TypeResolver.integerLimitFor: null for non-integer receivers and non-limit fields" {
// Float / non-numeric / user names are not integer-limit folds.
try std.testing.expect(TypeResolver.integerLimitFor("f64", "max") == null);
try std.testing.expect(TypeResolver.integerLimitFor("bool", "max") == null);
try std.testing.expect(TypeResolver.integerLimitFor("void", "min") == null);
try std.testing.expect(TypeResolver.integerLimitFor("MyStruct", "min") == null);
// A builtin int with a non-limit field is not a fold here.
try std.testing.expect(TypeResolver.integerLimitFor("s64", "len") == null);
try std.testing.expect(TypeResolver.integerLimitFor("u8", "epsilon") == null);
}

View File

@@ -67,6 +67,76 @@ pub const TypeResolver = struct {
return null;
}
/// An arbitrary-bit-width integer type NAME (`s1``s64`, `u1``u64`, which
/// also subsumes `s8`/`u8`/…/`s64`/`u64`): its width + signedness, else
/// null. THE single width parser — `resolveBuiltinName` (to intern the
/// `TypeId`) and the numeric-limit accessors (`.min`/`.max`, via
/// `integerWidthSign`) both classify through here, so the recognized width
/// set cannot diverge (the issue-0083 two-resolver defect class).
pub const WidthInt = struct { width: u8, signed: bool };
pub fn parseWidthInt(name: []const u8) ?WidthInt {
if (name.len < 2) return null;
if (name[0] != 's' and name[0] != 'u') return null;
const width = std.fmt.parseInt(u8, name[1..], 10) catch return null;
if (width < 1 or width > 64) return null;
return .{ .width = width, .signed = name[0] == 's' };
}
/// A bare name → its builtin `TypeId` (primitive keyword OR arbitrary-width
/// integer), WITHOUT the named-struct / alias / stub fallthrough of
/// `resolveNamed`. null for any non-builtin name. The shared builtin
/// classifier: `resolveNamed` resolves through it first (then continues to
/// struct/alias resolution), and the numeric-limit accessor intercept uses
/// it to recover the queried type.
pub fn resolveBuiltinName(name: []const u8, table: *TypeTable) ?TypeId {
if (resolvePrimitive(name)) |id| return id;
if (parseWidthInt(name)) |wi| {
return if (wi.signed) table.intern(.{ .signed = wi.width }) else table.intern(.{ .unsigned = wi.width });
}
return null;
}
/// Width + signedness of a builtin INTEGER type NAME: the `s`/`u` widths via
/// `parseWidthInt`, plus `usize`/`isize` (target-width = 64 on the host).
/// null for a non-integer name (floats, `bool`, `string`, a user type, …) —
/// so the `.min`/`.max` fold fires for integers only.
pub fn integerWidthSign(name: []const u8) ?WidthInt {
if (parseWidthInt(name)) |wi| return wi;
if (std.mem.eql(u8, name, "usize")) return .{ .width = 64, .signed = false };
if (std.mem.eql(u8, name, "isize")) return .{ .width = 64, .signed = true };
return null;
}
/// The two's-complement bit pattern (as a raw `i64`) of a fixed-width integer
/// type's `.min`/`.max`. Pure `(width, signedness)` arithmetic — never a
/// per-name table. `sN`: min = -(2^(N-1)), max = 2^(N-1)-1. `uN`: min = 0,
/// max = 2^N-1. The all-ones `u64.max`/`usize.max` (18446744073709551615)
/// exceeds `i64`'s max, so it is returned as its bit pattern (`-1` as `i64`);
/// the caller pairs it with the `u64`/`usize` `TypeId` so no signed path
/// re-signs it.
pub fn integerLimitBits(wi: WidthInt, want_max: bool) i64 {
if (wi.signed) {
const half_shift: u6 = @intCast(wi.width - 1);
const half: u64 = @as(u64, 1) << half_shift; // 2^(width-1)
return if (want_max) @bitCast(half - 1) else @bitCast(0 -% half);
}
if (!want_max) return 0; // unsigned min
const lead: u6 = @intCast(64 - @as(u8, wi.width));
return @bitCast((~@as(u64, 0)) >> lead); // 2^width - 1
}
/// `<IntType>.min` / `.max` → the type's limit as a raw `i64`, or null when
/// `name` is not a builtin integer type or `field` is not `min`/`max`. THE
/// single name+field → value fold, shared by the value path (lower.zig) and
/// the comptime-int / array-dim path (program_index.evalConstIntExpr) so the
/// two cannot disagree on what `u8.max` evaluates to.
pub fn integerLimitFor(name: []const u8, field: []const u8) ?i64 {
const want_max = std.mem.eql(u8, field, "max");
if (!want_max and !std.mem.eql(u8, field, "min")) return null;
const wi = integerWidthSign(name) orelse return null;
return integerLimitBits(wi, want_max);
}
/// Single owner of structural AST-type-shape construction. Builds the
/// shapes whose `TypeId` is fully determined by their node kind plus their
/// element types resolved through `inner.resolveInner`: `*T`, `[*]T`, `[]T`,
@@ -175,15 +245,10 @@ pub const TypeResolver = struct {
/// stub fall-through preserves long-standing behavior for as-yet-
/// unregistered names.
pub fn resolveNamed(name: []const u8, table: *TypeTable, alias_map: ?*const std.StringHashMap(TypeId)) TypeId {
if (resolvePrimitive(name)) |id| return id;
// Arbitrary bit-width integers: s1-s64, u1-u64.
if (name.len >= 2 and (name[0] == 's' or name[0] == 'u')) {
if (std.fmt.parseInt(u8, name[1..], 10)) |width| {
if (width >= 1 and width <= 64) {
return if (name[0] == 's') table.intern(.{ .signed = width }) else table.intern(.{ .unsigned = width });
}
} else |_| {}
}
// Builtin primitive keyword or arbitrary-width integer (`s1`-`s64`,
// `u1`-`u64`) — the single builtin classifier, also reused by the
// numeric-limit accessor intercept.
if (resolveBuiltinName(name, table)) |id| return id;
// Sentinel-terminated slice: [:0]u8 → string.
if (name.len >= 5 and name[0] == '[' and name[1] == ':') {
if (std.mem.indexOfScalar(u8, name, ']')) |close| {