diff --git a/packages/bugc/CHANGELOG.md b/packages/bugc/CHANGELOG.md index baa50493ee..42f47af3ff 100644 --- a/packages/bugc/CHANGELOG.md +++ b/packages/bugc/CHANGELOG.md @@ -10,7 +10,8 @@ support. Changes to the specification itself are tracked in the root ### Added - The `%` operator, with the precedence of `*` and `/`. It compiles to - the EVM's unsigned `MOD`, so `x % 0` is `0`, as `x / 0` is ([#321]). + the EVM's `MOD` (`SMOD` for signed operands), so `x % 0` is `0`, as + `x / 0` is ([#321]). - Each instruction has a `variables` context that lists the local variables in scope (the innermost declaration of each name, which hides a storage variable of that name), with a type, and a pointer to the @@ -24,6 +25,16 @@ support. Changes to the specification itself are tracked in the root ### Changed +- An integer literal operand of an arithmetic or comparison operator now + takes the type of the other operand when its value fits, so with + `x: int8`, `x < 0`, `x == 1` and `-1 < x` compare as `int8`. A literal + that type cannot hold, such as `x < 128`, is a type error. Before, + every integer literal was a `uint256`, so `x == 1` was a type error + while `x < 1` compiled ([#339]). +- Arithmetic and comparison operators now reject operands of mixed + signedness, as Solidity does: cast one operand to the other's type. + Before, a comparison such as `u > x`, with `u: uint256` and `x: int8`, + compiled ([#339]). - A cast now converts its value as Solidity does. A cast to a narrower integer type or to `address` keeps the low bits, and a cast to a signed type then sign-extends: `300 as uint8` is 44, `200 as int8` is -56, and @@ -121,6 +132,27 @@ support. Changes to the specification itself are tracked in the root - A local initialized with a string literal (`let s = "hello";`) now holds the string. Before, it held an empty allocation, so `s.length` was `0` ([#328]). +- Comparisons (`<`, `<=`, `>`, `>=`) of signed integers are now signed. + Before, at every optimization level, they compiled to the EVM's + unsigned `LT` and `GT`, so a negative value compared greater than any + positive one: `(-56 as int8) < (0 as int8)` was false. The optimizer + folds constant comparisons by the same rule ([#339]). +- `/` and `%` on signed integers now compile to the EVM's `SDIV` and + `SMOD`, so division truncates toward zero and a remainder has the sign + of the dividend, as in Solidity. Before, at every optimization level, + they were unsigned, so `(-7 as int8) / (2 as int8)` was a large + positive number. Arithmetic does not check for overflow, so the least + `int256` divided by `-1` is itself. A result narrower than a word does + not wrap to its type yet: `(-128 as int8) / (-1 as int8)` is 128, and + the same goes for a narrow `+`, `-` or `*` that overflows; a follow-up + change makes them wrap. The optimizer folds constants by the same rules + ([#339]). +- A write to a mapping value or array element narrower than a slot now + writes only that value's bytes. Before, at every optimization level, + `m[5] = -2 as int16` with `m: mapping` stored a full + word of `ff` bytes ending in `fffe`, where Solidity stores `fffe`. + bugc gives each array element its own slot, without Solidity's + packing, so only a mapping value's layout matches Solidity's ([#339]). ## 0.1.0-preview.1 — 2026-10-03 @@ -170,3 +202,4 @@ First publication. [#335]: https://github.com/ethdebug/format/pull/335 [#336]: https://github.com/ethdebug/format/pull/336 [#337]: https://github.com/ethdebug/format/pull/337 +[#339]: https://github.com/ethdebug/format/pull/339 diff --git a/packages/bugc/src/evm/spec/definitions.ts b/packages/bugc/src/evm/spec/definitions.ts index 89f4933e45..98055b37ab 100644 --- a/packages/bugc/src/evm/spec/definitions.ts +++ b/packages/bugc/src/evm/spec/definitions.ts @@ -189,7 +189,7 @@ export const makeOperations = (controls: State.Controls) => { }), ), ...mapInstruction( - { opcode: 0x13, mnemonic: "SGT" }, + { opcode: 0x13, mnemonic: "SGT" } as const, makeOperationForInstruction({ consumes: ["a", "b"] as const, produces: ["a > b"] as const, diff --git a/packages/bugc/src/evmgen/behavioral.test.ts b/packages/bugc/src/evmgen/behavioral.test.ts index 1b934f90dd..92993727ac 100644 --- a/packages/bugc/src/evmgen/behavioral.test.ts +++ b/packages/bugc/src/evmgen/behavioral.test.ts @@ -1549,4 +1549,289 @@ code { }); } }); + + describe("signed operators", () => { + const word = 2n ** 256n; + const toWord = (n: bigint) => ((n % word) + word) % word; + // A negative literal is a cast of its two's complement, which the + // optimizer folds (it does not fold negation) + const literal = (n: bigint, type: string) => { + const bits = BigInt(type.replace(/^u?int/, "")); + return `(${n < 0n ? 2n ** bits + n : n} as ${type})`; + }; + + // Store the result of each statement on operands `a` and `b` of + // `type`, read at runtime from int256 storage, or given as + // literals, which the optimizer folds + const program = ( + type: string, + [a, b]: readonly [bigint, bigint], + statements: readonly (( + a: string, + b: string, + out: string, + type: string, + ) => string)[], + folded: boolean, + ) => { + const out = type.startsWith("u") ? "uint256" : "int256"; + const outputs = statements.map((_, j) => `[${10 + j}] o${j}: ${out};`); + const [x, y] = folded ? [literal(a, type), literal(b, type)] : ["x", "y"]; + return `name SignedOperators; +storage { + [0] a: int256; + [1] b: int256; + ${outputs.join("\n ")} +} +create { + a = ${literal(a, "int256")}; + b = ${literal(b, "int256")}; +} +code { + let x = a as ${type}; + let y = b as ${type}; + ${statements.map((statement, j) => statement(x, y, `o${j}`, out)).join("\n ")} +}`; + }; + + const check = async ( + source: string, + level: 0 | 1 | 2 | 3, + expected: readonly bigint[], + ) => { + const result = await executeProgram(source, { + calldata: "", + optimizationLevel: level, + }); + expect(result.callSuccess).toBe(true); + const actual = []; + for (const j of expected.keys()) { + actual.push(await result.getStorage(10n + BigInt(j))); + } + expect(actual).toEqual(expected.map(toWord)); + }; + + const min = -(2n ** 255n); + + describe("comparisons", () => { + const comparisons = [ + ["<", (a: bigint, b: bigint) => a < b], + ["<=", (a: bigint, b: bigint) => a <= b], + [">", (a: bigint, b: bigint) => a > b], + [">=", (a: bigint, b: bigint) => a >= b], + ["==", (a: bigint, b: bigint) => a === b], + ["!=", (a: bigint, b: bigint) => a !== b], + ] as const; + + // Each comparison stores 1 when it holds + const statements = comparisons.map( + ([op]) => + (a: string, b: string, out: string, type: string) => + `if (${a} ${op} ${b}) { ${out} = 1 as ${type}; }`, + ); + + const signed = [ + [-3n, -2n], + [-2n, -3n], + [-5n, 4n], + [4n, -5n], + [-7n, -7n], + [6n, 6n], + ] as const; + + const cases = [ + ...["int8", "int16", "int128", "int256"].flatMap((type) => + signed.map((operands) => [type, operands] as const), + ), + ["int256", [min, -min - 1n]], + ["uint256", [2n ** 255n, 1n]], + ["uint256", [1n, 2n ** 255n]], + ] as const; + + for (const [type, operands] of cases) { + const [a, b] = operands; + const expected = comparisons.map(([, compare]) => + compare(a, b) ? 1n : 0n, + ); + for (const folded of [false, true]) { + const source = program(type, operands, statements, folded); + for (const level of [0, 1, 2, 3] as const) { + it(`should compare ${type} ${operands.join(" and ")}${folded ? " as literals" : ""} (level ${level})`, async () => { + await check(source, level, expected); + }); + } + } + } + }); + + describe("literal operands", () => { + // A literal takes the type of the other operand + const statements = [ + (x: string, _: string, out: string) => + `if (${x} < 0) { ${out} = 1 as int256; }`, + (x: string, _: string, out: string) => + `if (${x} == 1) { ${out} = 1 as int256; }`, + (x: string, _: string, out: string) => + `if (-1 < ${x}) { ${out} = 1 as int256; }`, + (x: string, _: string, out: string) => + `if (0 >= ${x}) { ${out} = 1 as int256; }`, + (x: string, _: string, out: string) => `${out} = ${x} / -2;`, + ]; + const expected = (x: bigint) => [ + x < 0n ? 1n : 0n, + x === 1n ? 1n : 0n, + -1n < x ? 1n : 0n, + 0n >= x ? 1n : 0n, + x / -2n, + ]; + + for (const x of [-3n, -1n, 0n, 1n, 5n]) { + for (const type of ["int8", "int256"]) { + const source = program(type, [x, 0n], statements, false); + for (const level of [0, 1, 2, 3] as const) { + it(`should compare ${type} ${x} with literals (level ${level})`, async () => { + await check(source, level, expected(x)); + }); + } + } + } + + it("should reject a literal too large for the other operand", async () => { + await expect( + executeProgram(`name TooLarge; +storage { [0] n: int8; } +code { if (n < 128) { n = 1 as int8; } }`), + ).rejects.toThrow("Literal 128 does not fit in int8"); + }); + + it("should reject mixed signedness", async () => { + await expect( + executeProgram(`name Mixed; +storage { [0] u: uint256; [1] o: uint256; } +code { let x = 1 as int8; if (u > x) { o = 1; } }`), + ).rejects.toThrow("cannot mix signed and unsigned operands"); + }); + }); + + describe("division and remainder", () => { + // Division truncates toward zero, and the remainder has the sign + // of the dividend, as in Solidity (and as bigint does). BUG + // arithmetic does not check for overflow, so the minimum int256 + // divided by -1 wraps to itself. + const statements = [ + (a: string, b: string, out: string) => `${out} = ${a} / ${b};`, + (a: string, b: string, out: string) => `${out} = ${a} % ${b};`, + ]; + + const signed = [ + [7n, 2n], + [-7n, 2n], + [7n, -2n], + [-7n, -2n], + [-6n, 3n], + [5n, -7n], + ] as const; + + const cases = [ + ...["int8", "int16", "int128", "int256"].flatMap((type) => + signed.map((operands) => [type, operands, null] as const), + ), + ["int256", [min, -1n], [min, 0n]], + ["uint256", [word - 2n, 2n], null], + ["uint256", [word - 7n, 2n ** 255n], null], + ] as const; + + for (const [type, [a, b], wrapped] of cases) { + const expected = wrapped ?? [a / b, a % b]; + for (const folded of [false, true]) { + const source = program(type, [a, b], statements, folded); + for (const level of [0, 1, 2, 3] as const) { + it(`should divide ${type} ${a} by ${b}${folded ? " as literals" : ""} (level ${level})`, async () => { + await check(source, level, expected); + }); + } + } + } + }); + }); + + describe("narrow mapping values and array elements", () => { + const neg = (n: bigint) => 2n ** 256n - n; + const pad = (n: bigint) => n.toString(16).padStart(64, "0"); + const hash = (hex: string) => + BigInt("0x" + bytesToHex(keccak256(Buffer.from(hex, "hex")))); + + const mappings = `name NarrowMappings; +storage { + [0] m8: mapping; + [1] m16: mapping; + [2] m128: mapping; + [3] n: int256; + [10] o0: int256; + [11] o1: int256; + [12] o2: int256; + [13] o3: int256; + [14] o4: int256; + [15] o5: int256; +} +create { n = -2 as int256; } +code { + m8[5] = n as int8; + m16[5] = n as int16; + m128[5] = n as int128; + m8[6] = -3 as int8; + m16[6] = -3 as int16; + m128[6] = -3 as int128; + o0 = m8[5]; + o1 = m16[5]; + o2 = m128[5]; + o3 = m8[6]; + o4 = m16[6]; + o5 = m128[6]; +}`; + + // bugc gives each array element its own slot + const arrays = `name NarrowArrays; +storage { [0] a: array; [1] n: int256; [10] o0: int256; } +create { n = -2 as int256; } +code { a[1] = n as int16; o0 = a[1]; }`; + + for (const level of [0, 1, 2, 3] as const) { + it(`should write only a mapping value's bytes (level ${level})`, async () => { + const result = await executeProgram(mappings, { + calldata: "", + optimizationLevel: level, + }); + expect(result.callSuccess).toBe(true); + for (const [key, value] of [ + [5n, 2n], + [6n, 3n], + ] as const) { + for (const [slot, bytes] of [ + [0n, 1n], + [1n, 2n], + [2n, 16n], + ] as const) { + expect(await result.getStorage(hash(pad(key) + pad(slot)))).toBe( + 2n ** (8n * bytes) - value, + ); + } + } + const expected = [2n, 2n, 2n, 3n, 3n, 3n].map(neg); + for (const [j, value] of expected.entries()) { + expect(await result.getStorage(10n + BigInt(j))).toBe(value); + } + }); + + it(`should write only an array element's bytes (level ${level})`, async () => { + const result = await executeProgram(arrays, { + calldata: "", + optimizationLevel: level, + }); + expect(result.callSuccess).toBe(true); + expect(await result.getStorage(hash(pad(0n)) + 1n)).toBe(0xfffen); + expect(await result.getStorage(10n)).toBe(neg(2n)); + }); + } + }); }); diff --git a/packages/bugc/src/evmgen/generation/instructions/binary.ts b/packages/bugc/src/evmgen/generation/instructions/binary.ts index 5bbcd07043..d4f8ec78f6 100644 --- a/packages/bugc/src/evmgen/generation/instructions/binary.ts +++ b/packages/bugc/src/evmgen/generation/instructions/binary.ts @@ -1,4 +1,4 @@ -import type * as Ir from "#ir"; +import * as Ir from "#ir"; import type { Stack } from "#evm"; import type { State } from "#evmgen/state"; @@ -11,10 +11,14 @@ const { SUB, MUL, DIV, + SDIV, MOD, + SMOD, EQ, LT, GT, + SLT, + SGT, AND, OR, ISZERO, @@ -31,6 +35,11 @@ export function generateBinary( ): Transition { const debug = inst.operationDebug; + // Signed operands compare and divide with the signed opcodes + const signed = Ir.Utils.isSignedBinary(inst); + const [lt, gt] = signed ? [SLT, SGT] : [LT, GT]; + const [div, mod] = signed ? [SDIV, SMOD] : [DIV, MOD]; + const map: { [O in Ir.Instruction.BinaryOp["op"]]: ( state: State, @@ -49,12 +58,12 @@ export function generateBinary( div: pipe() .then(SWAP1({ debug })) .then(rebrand<"b", "a", "a", "b">({ 1: "a", 2: "b" })) - .then(DIV({ debug })) + .then(div({ debug })) .done(), mod: pipe() .then(SWAP1({ debug })) .then(rebrand<"b", "a", "a", "b">({ 1: "a", 2: "b" })) - .then(MOD({ debug })) + .then(mod({ debug })) .done(), shl: pipe() .then(rebrand<"a", "shift", "b", "value">({ 1: "shift", 2: "value" })) @@ -71,14 +80,14 @@ export function generateBinary( .done(), // Note: operands are loaded as [left=b, right=a] so EVM comparisons are reversed // EVM LT returns a < b (right < left), so use GT for IR lt (left < right) - lt: GT({ debug }), + lt: gt({ debug }), le: pipe() - .then(LT({ debug }), { as: "a" }) + .then(lt({ debug }), { as: "a" }) .then(ISZERO({ debug })) .done(), - gt: LT({ debug }), + gt: lt({ debug }), ge: pipe() - .then(GT({ debug }), { as: "a" }) + .then(gt({ debug }), { as: "a" }) .then(ISZERO({ debug })) .done(), and: AND({ debug }), diff --git a/packages/bugc/src/ir/utils/index.ts b/packages/bugc/src/ir/utils/index.ts index da8e7954bf..9a9db3395c 100644 --- a/packages/bugc/src/ir/utils/index.ts +++ b/packages/bugc/src/ir/utils/index.ts @@ -2,3 +2,4 @@ export * from "./debug.js"; export * from "./cast.js"; export * from "./def-sites.js"; export * from "./debug-info.js"; +export * from "./signed.js"; diff --git a/packages/bugc/src/ir/utils/signed.ts b/packages/bugc/src/ir/utils/signed.ts new file mode 100644 index 0000000000..7346955675 --- /dev/null +++ b/packages/bugc/src/ir/utils/signed.ts @@ -0,0 +1,68 @@ +import { Type as BugType } from "#types"; + +import { Type } from "../spec/type.js"; +import type { Instruction } from "../spec/instruction.js"; + +/** + * Whether a value of this type is a signed integer + */ +export function isSigned(type: Type): boolean { + return ( + Type.isScalar(type) && + type.origin !== "synthetic" && + BugType.isElementary(type.origin) && + BugType.Elementary.isInt(type.origin) + ); +} + +/** + * Whether a binary operation acts on signed integers, so that it + * compares, divides, and takes the remainder of two's complement + * values. A signed value is sign-extended across its word, so the + * signed opcodes apply at any width. The type checker rejects + * operands of mixed signedness, so both operands agree. + */ +export function isSignedBinary(inst: Instruction.BinaryOp): boolean { + return isSigned(inst.left.type) || isSigned(inst.right.type); +} + +const word = 2n ** 256n; + +/** + * Evaluate a binary operation on two signed constants as the EVM's + * signed opcodes do: a comparison gives a boolean, and division or + * remainder a word. Returns undefined for an operation that does not + * depend on sign, and for division by zero. + */ +export function foldSigned( + op: Instruction.BinaryOp["op"], + left: bigint, + right: bigint, +): bigint | boolean | undefined { + const a = toSigned(left); + const b = toSigned(right); + + switch (op) { + case "lt": + return a < b; + case "le": + return a <= b; + case "gt": + return a > b; + case "ge": + return a >= b; + case "div": + return b !== 0n ? toWord(a / b) : undefined; + case "mod": + return b !== 0n ? toWord(a % b) : undefined; + default: + return undefined; + } +} + +const toWord = (value: bigint): bigint => ((value % word) + word) % word; + +const toSigned = (value: bigint): bigint => { + const unsigned = toWord(value); + return unsigned >= word / 2n ? unsigned - word : unsigned; +}; diff --git a/packages/bugc/src/irgen/generate/storage.ts b/packages/bugc/src/irgen/generate/storage.ts index 7d63cc9982..be3297e094 100644 --- a/packages/bugc/src/irgen/generate/storage.ts +++ b/packages/bugc/src/irgen/generate/storage.ts @@ -415,6 +415,10 @@ export function* emitStorageChainStore( if (lastAccess.fieldType) { fieldSize = getFieldSize(lastAccess.fieldType); } + } else if (currentOrigin && Type.isElementary(currentOrigin)) { + // A mapping value or array element narrower than a slot writes + // only its own bytes, at the low-order end of its slot + fieldSize = getFieldSize(fromBugType(currentOrigin)); } // Determine the actual field size to write diff --git a/packages/bugc/src/optimizer/steps/constant-folding.ts b/packages/bugc/src/optimizer/steps/constant-folding.ts index 8c957fdfd8..b31a89a8f0 100644 --- a/packages/bugc/src/optimizer/steps/constant-folding.ts +++ b/packages/bugc/src/optimizer/steps/constant-folding.ts @@ -142,7 +142,14 @@ export class ConstantFoldingStep extends BaseOptimizationStep { if (leftValue === undefined || rightValue === undefined) return null; - const result = this.evaluateBinary(inst.op, leftValue, rightValue); + const signed = + Ir.Utils.isSignedBinary(inst) && + typeof leftValue === "bigint" && + typeof rightValue === "bigint" + ? Ir.Utils.foldSigned(inst.op, leftValue, rightValue) + : undefined; + const result = + signed ?? this.evaluateBinary(inst.op, leftValue, rightValue); if (result === undefined) return null; return { diff --git a/packages/bugc/src/typechecker/checker.test.ts b/packages/bugc/src/typechecker/checker.test.ts index 97ddbcc8cd..57c8bce8c1 100644 --- a/packages/bugc/src/typechecker/checker.test.ts +++ b/packages/bugc/src/typechecker/checker.test.ts @@ -183,6 +183,59 @@ describe("checkProgram", () => { }); }); + describe("Integer literals and signedness", () => { + const program = (body: string) => ` + name Test; + storage { [0] n: int8; [1] u: uint256; } + code { let x = n; ${body} } + `; + + for (const body of [ + "let a = x < 0;", + "let b = x == 1;", + "let c = -1 < x;", + "let d = x + 1;", + "let e = x / -2;", + "let f = x != -128;", + "let g = u > 0;", + ]) { + it(`should give a literal the other operand's type: \`${body}\``, () => { + const result = check(program(body)); + expect(result.success).toBe(true); + expect(Result.hasMessages(result)).toBe(false); + }); + } + + for (const [body, message] of [ + ["let a = x < 128;", "Literal 128 does not fit in int8"], + ["let b = x == -129;", "Literal -129 does not fit in int8"], + ["let c = u > -1;", "Literal -1 does not fit in uint256"], + ]) { + it(`should reject a literal that does not fit: \`${body}\``, () => { + const result = check(program(body)); + expect(result.success).toBe(false); + expect(result).toHaveMessage({ severity: Severity.Error, message }); + }); + } + + for (const body of [ + "let a = u > x;", + "let b = x <= u;", + "let c = x + u;", + "let d = u / x;", + "let e = u % x;", + ]) { + it(`should reject mixed signedness: \`${body}\``, () => { + const result = check(program(body)); + expect(result.success).toBe(false); + expect(result).toHaveMessage({ + severity: Severity.Error, + message: "cannot mix signed and unsigned operands", + }); + }); + } + }); + describe("Structs", () => { it("should type check struct field access", () => { const result = check(` diff --git a/packages/bugc/src/typechecker/expressions.ts b/packages/bugc/src/typechecker/expressions.ts index cbe8329894..81fe0c6be5 100644 --- a/packages/bugc/src/typechecker/expressions.ts +++ b/packages/bugc/src/typechecker/expressions.ts @@ -9,6 +9,7 @@ import { assertExhausted, } from "./errors.js"; import { isAssignable, commonType } from "./assignable.js"; +import { fits, integerLiteral, isInteger } from "./literals.js"; /** * Type checker for expression nodes. @@ -191,6 +192,9 @@ export const expressionChecker: Pick, "expression"> = { } } else if (node.operands.length === 2) { // Binary operator + if (integerOperators.has(node.operator)) { + adaptLiterals(node, operandTypes, nodeTypes, errors); + } const [leftType, rightType] = operandTypes; switch (node.operator) { @@ -213,6 +217,8 @@ export const expressionChecker: Pick, "expression"> = { ErrorCode.INVALID_OPERAND, ); errors.push(error); + } else { + errors.push(...checkSignedness(node, leftType, rightType)); } resultType = commonType(leftType, rightType) || undefined; break; @@ -235,6 +241,8 @@ export const expressionChecker: Pick, "expression"> = { ErrorCode.INVALID_OPERAND, ); errors.push(error); + } else { + errors.push(...checkSignedness(node, leftType, rightType)); } resultType = Type.Elementary.bool(); break; @@ -1113,3 +1121,86 @@ function isValidCast(fromType: Type, toType: Type): boolean { // No other casts are allowed return false; } + +const integerOperators = new Set([ + "+", + "-", + "*", + "/", + "%", + "<", + ">", + "<=", + ">=", + "==", + "!=", +]); + +/** + * Give an integer literal operand the type of the other operand, when + * the other operand is an integer and not a literal itself. A literal + * whose value that type cannot hold is an error. + */ +function adaptLiterals( + node: Ast.Expression.Operator, + operandTypes: Type[], + nodeTypes: Map, + errors: TypeError[], +): void { + const literals = node.operands.map(integerLiteral); + + for (const [index, other] of [ + [0, 1], + [1, 0], + ] as const) { + const literal = literals[index]; + const type = operandTypes[other]; + if (!literal || literals[other] || !isInteger(type)) { + continue; + } + + if (!fits(literal.value, type)) { + errors.push( + new TypeError( + `Literal ${literal.value} does not fit in ${Type.format(type)}`, + literal.nodes[0].loc || undefined, + Type.format(type), + undefined, + ErrorCode.TYPE_MISMATCH, + ), + ); + continue; + } + + for (const literalNode of literal.nodes) { + nodeTypes.set(literalNode.id, type); + } + operandTypes[index] = type; + } +} + +/** + * Operands of an arithmetic or comparison operator must be both signed + * or both unsigned, as in Solidity + */ +function checkSignedness( + node: Ast.Expression.Operator, + left: Type.Elementary, + right: Type.Elementary, +): TypeError[] { + if (Type.Elementary.isInt(left) === Type.Elementary.isInt(right)) { + return []; + } + + return [ + new TypeError( + `Operator ${node.operator} cannot mix signed and unsigned operands ` + + `(${Type.format(left)} and ${Type.format(right)}); cast one ` + + `operand to the other's type, as in \`x as ${Type.format(left)}\``, + node.loc || undefined, + undefined, + undefined, + ErrorCode.INVALID_OPERAND, + ), + ]; +} diff --git a/packages/bugc/src/typechecker/literals.ts b/packages/bugc/src/typechecker/literals.ts new file mode 100644 index 0000000000..2e65421b3b --- /dev/null +++ b/packages/bugc/src/typechecker/literals.ts @@ -0,0 +1,53 @@ +import * as Ast from "#ast"; +import { Type } from "#types"; + +/** + * An integer literal operand: a number literal, or `-` applied to one. + * `nodes` are the expression's nodes that take the literal's type. + */ +export interface IntegerLiteral { + value: bigint; + nodes: Ast.Expression[]; +} + +export function integerLiteral( + node: Ast.Expression, +): IntegerLiteral | undefined { + if ( + Ast.Expression.isLiteral(node) && + node.kind === "expression:literal:number" && + !node.unit + ) { + return { value: BigInt(node.value), nodes: [node] }; + } + + if ( + Ast.Expression.isOperator(node) && + node.operator === "-" && + node.operands.length === 1 + ) { + const operand = integerLiteral(node.operands[0]); + return ( + operand && { value: -operand.value, nodes: [node, ...operand.nodes] } + ); + } + + return undefined; +} + +/** + * Whether a type is an integer type, signed or unsigned + */ +export function isInteger(type: Type): type is Type.Elementary { + return Type.isElementary(type) && Type.Elementary.isNumeric(type); +} + +/** + * Whether an integer type holds a value + */ +export function fits(value: bigint, type: Type.Elementary): boolean { + const bits = BigInt((type as { bits?: number }).bits ?? 256); + return Type.Elementary.isInt(type) + ? value >= -(1n << (bits - 1n)) && value < 1n << (bits - 1n) + : value >= 0n && value < 1n << bits; +} diff --git a/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx b/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx index ad7e569a85..a2756e9d21 100644 --- a/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx +++ b/packages/web/docs/implementation-guides/compiler/case-study-bug.mdx @@ -69,6 +69,28 @@ msg.data[0:4] as bytes4; // the function selector msg.data[4:36] as bytes32 as address; // an ABI-encoded address ``` +### Signed operators + +Comparisons, `/`, and `%` on signed integers are signed, as in Solidity. +Division truncates toward zero, and a remainder has the sign of the +dividend. Arithmetic does not check for overflow. + +Both operands of an arithmetic or comparison operator must be signed, or +both unsigned; cast one to mix them. An integer literal takes the type of +the other operand, if that type can hold it, so `x < 0` and `-1 < x` work +for a signed `x`, and `x < 128` is an error for an `int8`. + +```bug +(-7 as int8) < (2 as int8); // true +(-7 as int8) / (2 as int8); // -3 +(-7 as int8) % (2 as int8); // -1 +(7 as int8) % (-2 as int8); // 1 +``` + +A result narrower than a word does not wrap to its type yet: +`(-128 as int8) / (-1 as int8)` is 128, not -128, and the same goes for a +narrow `+`, `-` or `*` that overflows. A follow-up change makes them wrap. + ## Compilation pipeline BUG uses a multi-stage compilation pipeline: