Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions src/Fable.Transforms/Beam/FABLE-BEAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -809,11 +809,15 @@ type Module =

## Sized & Signed Integer Semantics

> **Status: not yet implemented.** Erlang's native arbitrary-precision integers make
> `int`, `int64`, and `bigint` work out of the box, so the test suite passes today without
> fixed-width wrapping. True sized-integer overflow semantics (`int8`/`int16`/`int32` and
> unsigned wrapping) are **not** implemented yet — the rest of this section is the design
> plan for when they are. Strategy A (bit-syntax wrapping) remains the recommendation.
> **Status: implemented** using Strategy A (bit-syntax wrapping), described below.
> The wrapping helpers live in `src/fable-library-beam/fable_int.erl` (`wrap_i8`..`wrap_i64`,
> `wrap_u8`..`wrap_u64`). Codegen routes the operations that can leave a type's width —
> `+`, `-`, `*`, `bsl`, negation, `bnot` and narrowing conversions — through them, and masks
> shift counts to the width the way .NET does. `band`/`bor`/`bxor`/`bsr`/`rem` cannot grow an
> in-range value, so they stay bare. `bigint` and the fixed-scale `decimal` are never wrapped.
>
> Unsigned types are represented as their unsigned value (0..2^n-1), not as a signed bit
> pattern, so `UInt64.MaxValue` is `18446744073709551615` and `bsr` is a logical shift.

### The Problem

Expand Down
75 changes: 75 additions & 0 deletions src/Fable.Transforms/Beam/Fable2Beam.Util.fs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,81 @@ let isIntegerType (typ: Fable.AST.Fable.Type) =
| _ -> true // Decimal is now a fixed-scale integer
| _ -> true // default to integer division

/// Bit width and signedness of a .NET sized integer type, if it has one.
let sizedIntInfo = Integers.sizedIntInfo

/// .NET only uses the low bits of a shift count: `x <<< 32` is `x` for an int32,
/// where Erlang's `bsl` would shift the value out entirely.
let maskShiftCount (bits: int) (count: Beam.ErlExpr) =
let mask = int64 bits - 1L

match count with
| Beam.ErlExpr.Literal(Beam.ErlLiteral.Integer n) -> Beam.ErlExpr.Literal(Beam.ErlLiteral.Integer(n &&& mask))
| _ -> Beam.ErlExpr.BinOp("band", count, Beam.ErlExpr.Literal(Beam.ErlLiteral.Integer mask))

// Constant folding of the wrapped operations. Every one of them is exact modulo 2^64, so
// the fold can be done in (unchecked, wrapping) int64 arithmetic and the literal carries
// the bit pattern; truncating that to a narrower width afterwards gives the same answer as
// truncating the true mathematical result would.

/// Bit pattern of an integer literal. Unsigned values above `Int64.MaxValue` are emitted as
/// `BigInt` literals, so both shapes have to be recognised. A genuine (unbounded) bigint
/// literal does not parse as a `uint64` and is left alone — those are never wrapped.
let private literalBits (expr: Beam.ErlExpr) =
match expr with
| Beam.ErlExpr.Literal(Beam.ErlLiteral.Integer n) -> Some n
| Beam.ErlExpr.Literal(Beam.ErlLiteral.BigInt s) ->
match System.UInt64.TryParse(s) with
| true, n -> Some(int64 n)
| _ -> None
| _ -> None

/// The value `fable_int:wrap_*` would produce, as a literal.
let private wrappedLiteral (bits: int, signed: bool) (value: int64) =
let truncated =
if bits = 64 then
value
else
let shift = 64 - bits

if signed then
(value <<< shift) >>> shift // sign-extend the low bits
else
int64 ((uint64 value <<< shift) >>> shift) // zero-extend them

if not signed && truncated < 0L then
// 64-bit unsigned above Int64.MaxValue: does not fit an Erlang int64 literal
Beam.ErlExpr.Literal(Beam.ErlLiteral.BigInt(string<uint64> (uint64 truncated)))
else
Beam.ErlExpr.Literal(Beam.ErlLiteral.Integer truncated)

/// Fold a wrapped operation over integer literals, so constant expressions do not pay for a
/// runtime wrapping call. `None` when the operands are not all statically known.
let private foldConstant (expr: Beam.ErlExpr) =
let binary op left right =
match literalBits left, literalBits right with
| Some a, Some b -> Some(op a b)
| _ -> None

match expr with
| Beam.ErlExpr.BinOp("+", left, right) -> binary (+) left right
| Beam.ErlExpr.BinOp("-", left, right) -> binary (-) left right
| Beam.ErlExpr.BinOp("*", left, right) -> binary (*) left right
| Beam.ErlExpr.BinOp("bsl", left, right) -> binary (fun a b -> a <<< int b) left right
| Beam.ErlExpr.UnaryOp("-", operand) -> literalBits operand |> Option.map (~-)
| Beam.ErlExpr.UnaryOp("bnot", operand) -> literalBits operand |> Option.map (~~~)
| _ -> literalBits expr

/// Wrap an expression to the fixed width of `typ`. A no-op for unsized types, and folded at
/// compile time when the operands are literals.
let wrapToIntType (typ: Fable.AST.Fable.Type) (expr: Beam.ErlExpr) =
match Integers.sizedIntInfo typ with
| None -> expr
| Some info ->
match foldConstant expr with
| Some value -> wrappedLiteral info value
| None -> Beam.ErlExpr.Call(Some "fable_int", Integers.wrapFunctionName info, [ expr ])

/// Check if a Fable expression contains a reference to the given identifier name
let rec containsIdentRef (name: string) (expr: Expr) : bool =
match expr with
Expand Down
59 changes: 54 additions & 5 deletions src/Fable.Transforms/Beam/Fable2Beam.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1540,7 +1540,10 @@ and transformValue (com: IBeamCompiler) (ctx: Context) (value: ValueKind) : Beam
| NumberConstant(NumberValue.Int16 i, _) -> Beam.ErlExpr.Literal(Beam.ErlLiteral.Integer(int64 i))
| NumberConstant(NumberValue.UInt16 i, _) -> Beam.ErlExpr.Literal(Beam.ErlLiteral.Integer(int64 i))
| NumberConstant(NumberValue.UInt32 i, _) -> Beam.ErlExpr.Literal(Beam.ErlLiteral.Integer(int64 i))
| NumberConstant(NumberValue.UInt64 i, _) -> Beam.ErlExpr.Literal(Beam.ErlLiteral.Integer(int64 i))
// Unsigned 64-bit values are represented as the unsigned integer they are, so the
// upper half of the range does not fit in an int64 literal (`UInt64.MaxValue` would
// otherwise print as -1). Erlang integers are unbounded, so print the digits directly.
| NumberConstant(NumberValue.UInt64 i, _) -> Beam.ErlExpr.Literal(Beam.ErlLiteral.BigInt(string<uint64> i))
| NumberConstant(NumberValue.Float32 f, _) ->
let d = float f

Expand All @@ -1553,7 +1556,7 @@ and transformValue (com: IBeamCompiler) (ctx: Context) (value: ValueKind) : Beam
else
Beam.ErlExpr.Literal(Beam.ErlLiteral.Float d)
| NumberConstant(NumberValue.NativeInt i, _) -> Beam.ErlExpr.Literal(Beam.ErlLiteral.Integer(int64 i))
| NumberConstant(NumberValue.UNativeInt i, _) -> Beam.ErlExpr.Literal(Beam.ErlLiteral.Integer(int64 i))
| NumberConstant(NumberValue.UNativeInt i, _) -> Beam.ErlExpr.Literal(Beam.ErlLiteral.BigInt(string<unativeint> i))
| NumberConstant(NumberValue.BigInt i, _) -> Beam.ErlExpr.Literal(Beam.ErlLiteral.BigInt(string<bigint> i))
| NumberConstant(NumberValue.Decimal d, _) ->
// Decimal as fixed-scale integer: value × 10^28
Expand Down Expand Up @@ -1823,18 +1826,64 @@ and transformOperation
| BinaryAndBitwise -> "band"
| BinaryExponent -> "+" // unreachable, handled above

Beam.ErlExpr.BinOp(erlOp, cleanLeft, cleanRight) |> wrapWithHoisted allHoisted
// .NET only uses the low bits of a shift count (`x <<< 32` is `x` for an
// int32); Erlang would shift by the full amount.
let cleanRight =
match op, sizedIntInfo typ with
| (BinaryShiftLeft | BinaryShiftRightSignPropagating | BinaryShiftRightZeroFill), Some(bits, _) ->
maskShiftCount bits cleanRight
| _ -> cleanRight

// Erlang's `bsr` propagates the sign, so a zero-filling shift of a *signed*
// value has to shift its unsigned reinterpretation instead.
let unsignedTyp =
match op, typ with
| BinaryShiftRightZeroFill, Fable.AST.Fable.Type.Number(kind, info) ->
match kind with
| Int8 -> Some(Fable.AST.Fable.Type.Number(UInt8, info))
| Int16 -> Some(Fable.AST.Fable.Type.Number(UInt16, info))
| Int32 -> Some(Fable.AST.Fable.Type.Number(UInt32, info))
| Int64 -> Some(Fable.AST.Fable.Type.Number(UInt64, info))
| NativeInt -> Some(Fable.AST.Fable.Type.Number(UNativeInt, info))
| _ -> None
| _ -> None

let cleanLeft =
match unsignedTyp with
| Some unsignedTyp -> wrapToIntType unsignedTyp cleanLeft
| None -> cleanLeft

let result = Beam.ErlExpr.BinOp(erlOp, cleanLeft, cleanRight)

// Erlang integers are unbounded, so anything that can leave the width of a
// sized .NET integer has to be truncated back into it. `band`/`bor`/`bxor`/
// `bsr`/`rem` cannot grow an in-range value, so they stay bare.
let result =
match op with
| BinaryPlus
| BinaryMinus
| BinaryMultiply
| BinaryShiftLeft -> wrapToIntType typ result
// A zero-filled shift of a signed value was done on the unsigned
// reinterpretation, so it has to come back to the signed one.
| BinaryShiftRightZeroFill when unsignedTyp.IsSome -> wrapToIntType typ result
| _ -> result

result |> wrapWithHoisted allHoisted

| Unary(op, operand) ->
let erlOperand = transformExpr com ctx operand
let hoisted, cleanOperand = extractBlock erlOperand

let result =
match op with
| UnaryMinus -> Beam.ErlExpr.UnaryOp("-", cleanOperand)
// Negation leaves the width for the minimum signed value (`-Int32.MinValue`
// is `Int32.MinValue`) and for every non-zero unsigned value.
| UnaryMinus -> Beam.ErlExpr.UnaryOp("-", cleanOperand) |> wrapToIntType typ
| UnaryPlus -> cleanOperand
| UnaryNot -> Beam.ErlExpr.UnaryOp("not", cleanOperand)
| UnaryNotBitwise -> Beam.ErlExpr.UnaryOp("bnot", cleanOperand)
// `bnot` of an unsigned value is negative in Erlang, so it needs wrapping back.
| UnaryNotBitwise -> Beam.ErlExpr.UnaryOp("bnot", cleanOperand) |> wrapToIntType typ
| UnaryAddressOf ->
// For mutable variables, pass the atom key (process dict key)
// instead of the dereferenced value. This enables out-parameter support
Expand Down
53 changes: 53 additions & 0 deletions src/Fable.Transforms/Beam/Prelude.fs
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,56 @@ module Naming =
// Remove/replace characters invalid in Erlang variable names
name.Replace("$", "_").Replace("@", "_")
|> fun s -> Regex.Replace(s, "[^a-zA-Z0-9_]", "_")

/// Fixed-width integer semantics. Erlang integers are arbitrary precision and never
/// overflow, so operations that can leave the width of a .NET sized integer are routed
/// through the `fable_int` runtime module to truncate them back (two's complement).
module Integers =
open Fable.AST // NumberKind
open Fable.AST.Fable

/// Bit width and signedness of a .NET sized integer type. `None` for types whose
/// Erlang representation is legitimately unbounded (bigint, the fixed-scale decimal)
/// or not an integer at all — those must never be wrapped.
let sizedIntInfo (typ: Type) : (int * bool) option =
match typ with
// Chars are integers on Erlang, and .NET truncates conversions into them like a uint16
| Type.Char -> Some(16, false)
| Type.Number(kind, _) ->
match kind with
| Int8 -> Some(8, true)
| UInt8 -> Some(8, false)
| Int16 -> Some(16, true)
| UInt16 -> Some(16, false)
| Int32 -> Some(32, true)
| UInt32 -> Some(32, false)
| Int64 -> Some(64, true)
| UInt64 -> Some(64, false)
| NativeInt -> Some(64, true)
| UNativeInt -> Some(64, false)
| _ -> None
| _ -> None

/// Name of the `fable_int` function that wraps a value to the given width/signedness.
let wrapFunctionName (bits: int, signed: bool) =
let prefix =
if signed then
"i"
else
"u"

$"wrap_%s{prefix}%d{bits}"

/// Whether converting a value of type `source` to type `target` can leave `target`'s
/// range, i.e. whether the conversion needs truncating.
let conversionNeedsWrap (source: Type) (target: Type) =
match sizedIntInfo target, sizedIntInfo source with
| None, _ -> false
| Some _, None -> true // unbounded or non-integer source (float, bigint, decimal)
| Some(targetBits, targetSigned), Some(sourceBits, sourceSigned) ->
if sourceSigned = targetSigned then
sourceBits > targetBits
elif targetSigned then
sourceBits >= targetBits // unsigned source only fits in a strictly wider signed target
else
true // a signed source can be negative, which never fits an unsigned target
Loading