diff --git a/Sources/AST/DecimalExtensions.swift b/Sources/AST/DecimalExtensions.swift index 884f85bf..457f42d4 100644 --- a/Sources/AST/DecimalExtensions.swift +++ b/Sources/AST/DecimalExtensions.swift @@ -74,4 +74,27 @@ extension Decimal { guard self >= Self.int64Min && self <= Self.int64Max else { return nil } return Int64(truncating: self as NSNumber) } + + /// Whether this Decimal represents a whole number (no fractional part), regardless of + /// magnitude. Unlike ``safeInt64Value`` this does not require the value to fit in `Int64`, + /// so very large integers such as `99999999999999999999` still report `true`. + public var isWholeNumber: Bool { + guard !self.isNaN && self.isFinite else { return false } + + // exponent >= 0 means the value is already a whole number (significand * 10^exp). + guard exponent < 0 else { return true } + + #if canImport(ObjectiveC) + // Compaction strips trailing zeros from the significand. If the exponent is still + // negative afterwards, the value is fractional. + var copy = self + NSDecimalCompact(©) + return copy.exponent >= 0 + #else + var rounded = Decimal() + var copy = self + NSDecimalRound(&rounded, ©, 0, .plain) + return rounded == self + #endif + } } diff --git a/Sources/AST/RegoNumber.swift b/Sources/AST/RegoNumber.swift index f72332c9..e24440da 100644 --- a/Sources/AST/RegoNumber.swift +++ b/Sources/AST/RegoNumber.swift @@ -133,6 +133,27 @@ public struct RegoNumber: Sendable, Hashable { } } + /// Whether this number is a whole number (no fractional part), regardless of magnitude. + /// Unlike ``int64Value`` this returns true even when the value is too large for `Int64`. + public var isInteger: Bool { + switch storage { + case .int(_): + return true + case .decimal(let v): + return v.isWholeNumber + } + } + + /// Whether this number is strictly greater than zero. + public var isPositive: Bool { + switch storage { + case .int(let v): + return v > 0 + case .decimal(let v): + return v > 0 + } + } + } // MARK: - CustomStringConvertible & CustomDebugStringConvertible diff --git a/Sources/Rego/Builtins/Numbers.swift b/Sources/Rego/Builtins/Numbers.swift index 39a0db34..156a9d7a 100644 --- a/Sources/Rego/Builtins/Numbers.swift +++ b/Sources/Rego/Builtins/Numbers.swift @@ -41,24 +41,39 @@ extension BuiltinFuncs { var step: Int64 = 1 if withStep { - guard case .number(_) = args[2] else { + guard case .number(let stepNum) = args[2] else { throw BuiltinError.argumentTypeMismatch(arg: "step", got: args[2].typeName, want: "number") } - // NOTE that we are okay with this argument being a float with integer value + // We are okay with this argument being a float with integer value, e.g. // numbers.range_step(1.0, 3.0, 1.0) works just fine - guard let stepValue = args[2].integerValue else { + // + // Validate number is a positive whole number directly (not via int64Value), so that + // a whole-number step too large for Int64 is still accepted rather than being + // mistaken for a fractional value. + guard stepNum.isInteger else { throw BuiltinError.evalError(msg: "step must be integer number but got floating-point number") } - guard stepValue > 0 else { + guard stepNum.isPositive else { throw BuiltinError.evalError(msg: "step must be a positive integer") } + guard let stepValue = stepNum.int64Value else { + // The step is a positive integer too large for an Int64. The span between two + // Int64 endpoints is below 2*Int64.max, so at most only a few elements fall in + // the range. A step just above Int64.max can still be smaller than that span, + // so we use a Decimal for the step size. + return rangeWithDecimalStep(intA: intA, intB: intB, step: stepNum.decimalValue) + } + step = stepValue } - var result: [RegoValue] = [] + return rangeWithInt64Step(intA: intA, intB: intB, step: step) + } + private static func rangeWithInt64Step(intA: Int64, intB: Int64, step: Int64) -> RegoValue { + var result: [RegoValue] = [] if intB > intA { result.reserveCapacity(Int((intB - intA) / step) + 1) @@ -76,7 +91,31 @@ extension BuiltinFuncs { current -= step } } + return .array(result) + } + + /// Handle `numbers.range_step` when the step is a positive whole number too large for Int64. + /// The endpoints still fit in Int64, so the span `|b - a|` is below 2·Int64.max and the + /// result holds at most a handful of elements. We iterate in Decimal space (arbitrary + /// precision) to avoid Int64 overflow while remaining correct for steps that are only + /// slightly larger than Int64.max. + private static func rangeWithDecimalStep(intA: Int64, intB: Int64, step: Decimal) -> RegoValue { + let a = Decimal(intA) + let b = Decimal(intB) + var result: [RegoValue] = [] + var current = a + if b >= a { + while current <= b { + result.append(.number(RegoNumber(current))) + current += step + } + } else { + while current >= b { + result.append(.number(RegoNumber(current))) + current -= step + } + } return .array(result) }