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
11 changes: 11 additions & 0 deletions packages/bugc/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,16 @@ support. Changes to the specification itself are tracked in the root
way. Before, these compiled to a copy of the slot's word, or wrote
the memory address to the slot. An array literal assigned to a storage array variable
(`items = [1, 2];`) still compiles ([#377]).
- Each instruction the compiler adds now has a source range. A called
function's entry JUMPDEST has the function's declaration, as its
`invoke` names. The end of a function body that has no `return`
(the frame teardown and its exit JUMP, or the `STOP` of `code`) has
the function's range. The instructions that return a value from
`code` have the `return`'s range. A block's JUMPDEST has the range
of the instruction after it. Before, these had none, so a debugger
stopped there showed no source. Two exceptions remain: the end of the
create program, which copies and returns the runtime code, and a
JUMPDEST just before an inlined call ([#400]).

## 0.1.0-preview.2 — 2026-10-07

Expand Down Expand Up @@ -596,3 +606,4 @@ First publication.
[#395]: https://github.com/ethdebug/format/pull/395
[#397]: https://github.com/ethdebug/format/pull/397
[#398]: https://github.com/ethdebug/format/pull/398
[#400]: https://github.com/ethdebug/format/pull/400
174 changes: 174 additions & 0 deletions packages/bugc/src/evmgen/code-ranges.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
* Every instruction has a source range, so a debugger stopped at any
* of them has something to show: the instructions the compiler adds
* (a called function's entry, a block's JUMPDEST, a return the source
* does not write) get the range of what they belong to.
*/
import { describe, it, expect } from "vitest";
import type * as Format from "@ethdebug/format";
import { Program } from "@ethdebug/format";
import { compile } from "#compiler";
import { exampleSources } from "../examples/index.js";
import { hasCode } from "./generation/control-flow/terminator.js";

const { Context } = Program;

/** Whether a context starts an inlined call */
function invokesInline(context: Format.Program.Context | undefined): boolean {
return !!context && invokes(context) && inlined(context);
}

/** Whether a context starts a call with no jump target (in a gather
* too), as an inlined call's invoke has none */
function invokes(context: Format.Program.Context): boolean {
return (
(Context.isInvoke(context) && !("target" in context.invoke)) ||
(Context.isGather(context) && context.gather.some(invokes))
);
}

/** Whether a context is in an inlined body (in a gather too) */
function inlined(context: Format.Program.Context): boolean {
return (
(Context.isTransform(context) && context.transform.includes("inline")) ||
(Context.isGather(context) && context.gather.some(inlined))
);
}

/** Why an instruction has no source range, if it may have none */
function exception(
instructions: Format.Program.Instruction[],
index: number,
): string | undefined {
const { operation, context } = instructions[index];
const remark = context && Context.isRemark(context) ? context.remark : "";
// The source has no `code` block: the runtime is the compiler's own
if (remark === "initialize free memory pointer") return "no code block";
// The create program's end: it returns the runtime code
if (remark.startsWith("deployment:")) return "deployment";
// A block that starts with an inlined call: the JUMPDEST is at the
// call site, which the call's first instruction does not always give
if (
operation?.mnemonic === "JUMPDEST" &&
invokesInline(instructions[index + 1]?.context)
) {
return "before an inlined call";
}
return undefined;
}

describe("source ranges", () => {
it("are on every instruction of every example", async () => {
const missing: string[] = [];
for (const [name, source] of Object.entries(exampleSources)) {
for (const level of [0, 1, 2, 3] as const) {
const result = await compile({
to: "bytecode",
source,
optimizer: { level },
});
if (!result.success) continue;
const { runtimeProgram, createProgram } = result.value.bytecode;
for (const program of [runtimeProgram, createProgram]) {
const { instructions } = program ?? { instructions: [] };
instructions.forEach(({ offset, context }, index) => {
if (hasCode(context) || exception(instructions, index)) return;
missing.push(`${name} at O${level}, offset ${offset}`);
});
}
}
}
expect(missing).toEqual([]);
}, 120_000);

it("give a called function's entry its declaration", async () => {
const source = `name Entry;
define {
function f(n: uint256) -> uint256 {
if (n > 0) { return n; }
return f(n + 1);
};
}
storage { [0] r: uint256; }
create {}
code { r = f(0); }`;
for (const level of [0, 1, 2, 3] as const) {
const result = await compile({
to: "bytecode",
source,
optimizer: { level },
});
if (!result.success) throw new Error("compilation failed");
const entries = result.value.bytecode.runtimeProgram.instructions.filter(
({ operation, context }) =>
operation?.mnemonic === "JUMPDEST" &&
context &&
Context.isInvoke(context),
);
expect(entries.length).toBeGreaterThan(0);
for (const { context } of entries) {
const { code, invoke } = context as Format.Program.Context.Code &
Format.Program.Context.Invoke;
expect(code).toEqual((invoke as { declaration?: unknown }).declaration);
}
}
});

it("give a block's JUMPDEST the range of what runs next", async () => {
const source = `name Labels;
storage { [0] r: uint256; }
create {}
code {
let n = r;
if (n > 3) { r = n - 1; } else { r = n + 2; }
for (let i = 0; i < n; i = i + 1) { r = r + i; }
}`;
const result = await compile({
to: "bytecode",
source,
optimizer: { level: 0 },
});
if (!result.success) throw new Error("compilation failed");
const { instructions } = result.value.bytecode.runtimeProgram;
const labels = instructions.flatMap(({ operation, context }, index) =>
operation?.mnemonic === "JUMPDEST" ? [{ context, index }] : [],
);
expect(labels.length).toBeGreaterThan(2);
for (const { context, index } of labels) {
const next = instructions[index + 1].context;
expect(hasCode(context)).toBe(true);
expect((context as Format.Program.Context.Code).code).toEqual(
(next as Format.Program.Context.Code).code,
);
}
});

it("give the end of a body with no return the function's", async () => {
const source = `name End;
define {
function g(n: uint256) { r = n; };
}
storage { [0] r: uint256; }
create {}
code { g(3); g(4); }`;
for (const level of [0, 1] as const) {
const result = await compile({
to: "bytecode",
source,
optimizer: { level },
});
if (!result.success) throw new Error("compilation failed");
const exits = result.value.bytecode.runtimeProgram.instructions.filter(
({ operation, context }) =>
operation?.mnemonic === "JUMP" &&
context &&
Context.isReturn(context),
);
expect(exits.length).toBe(1);
const { code, return: info } = exits[0]
.context as Format.Program.Context.Code & Format.Program.Context.Return;
expect(info.declaration).toBeDefined();
expect(code).toEqual(info.declaration);
}
});
});
28 changes: 23 additions & 5 deletions packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,13 +182,31 @@ storage { [0] r: uint256; }
create {}
code { r = f(3); }`;
const declared = source.indexOf("let x");
const func = source.indexOf("function f");
const rows = await listed(source);
for (const { instruction, names } of rows) {
// The end of the body (a return the source does not write) has the
// function's range, which starts before `x` is declared; after the
// body's first instruction at or after the declaration, it may
// list `x`. The entry and the prologue, before it, may not.
const end = source.indexOf("};", func);
const body = rows.findIndex(({ instruction }) => {
const offset = codeOffset(instruction.context) ?? -1;
return offset >= declared && offset < end;
});
expect(body).toBeGreaterThan(0);
// the entry and the prologue are checked
expect(
rows.some(
({ instruction }, index) =>
index < body && codeOffset(instruction.context) === func,
),
).toBe(true);
rows.forEach(({ instruction, names }, index) => {
const offset = codeOffset(instruction.context);
if (offset !== undefined && offset < declared) {
expect(names, `x listed at offset ${offset}`).not.toContain("x");
}
}
if (offset === undefined || offset >= declared) return;
if (offset === func && index > body) return;
expect(names, `x listed at offset ${offset}`).not.toContain("x");
});
expect(rows.some(({ names }) => names.includes("x"))).toBe(true);
});

Expand Down
51 changes: 40 additions & 11 deletions packages/bugc/src/evmgen/generation/control-flow/terminator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,31 @@ function codeContext(
return undefined;
}

/** Whether a context gives a source range, in a gather or a pick too */
export function hasCode(context: Format.Program.Context | undefined): boolean {
if (!context || typeof context !== "object") return false;
if ("code" in context) return true;
if ("gather" in context) return context.gather.some(hasCode);
if ("pick" in context) return context.pick.some(hasCode);
return false;
}

/**
* A debug with the function's source range (its declaration), if it
* gives none of its own
*/
function withFunctionRange(
debug: Ir.Block.Debug,
func: Ir.Function | undefined,
): Ir.Block.Debug {
if (hasCode(debug.context) || !func?.sourceId || !func.loc) return debug;
const code = { source: { id: func.sourceId }, range: func.loc };
return {
...debug,
context: { ...debug.context, code } as Format.Program.Context,
};
}

/**
* Extract the in-scope `variables` list from an instruction/terminator
* debug, if present, so a call's caller JUMP still lists the locals
Expand Down Expand Up @@ -68,12 +93,14 @@ export function generateTerminator<S extends Stack>(
// Note: For returns with a value, we assume the return value is already
// on TOS from the previous instruction in the block. This avoids an
// unnecessary DUP that would leave an extra value on the stack.
// A return the source does not write (the end of a body) has
// no source range of its own: it gets the function's
const debug = withFunctionRange(term.operationDebug, func);
if (isUserFunction) {
// Internal function return epilogue.
// Uses the same imperative pattern as
// generateCallTerminator — returns
// Transition<S, Stack> to erase output type.
const debug = term.operationDebug;
return generateReturnEpilogue(term.value, debug, func);
}

Expand All @@ -89,23 +116,25 @@ export function generateTerminator<S extends Stack>(
if (allocation === undefined) {
const offset = state.memory.nextStaticOffset;
return builder
.then(loadValue(value))
.then(PUSHn(BigInt(offset)), { as: "offset" })
.then(MSTORE())
.then(PUSHn(32n), { as: "size" })
.then(PUSHn(BigInt(offset)), { as: "offset" })
.then(RETURN());
.then(loadValue(value, { debug }))
.then(PUSHn(BigInt(offset), { debug }), { as: "offset" })
.then(MSTORE({ debug }))
.then(PUSHn(32n, { debug }), { as: "size" })
.then(PUSHn(BigInt(offset), { debug }), { as: "offset" })
.then(RETURN({ debug }));
} else {
const offset = allocation.offset;
return builder
.then(PUSHn(32n), { as: "size" })
.then(PUSHn(BigInt(offset)), { as: "offset" })
.then(RETURN());
.then(PUSHn(32n, { debug }), { as: "size" })
.then(PUSHn(BigInt(offset), { debug }), { as: "offset" })
.then(RETURN({ debug }));
}
})
.done();
} else {
return isLastBlock ? (state) => state : pipe<S>().then(STOP()).done();
return isLastBlock
? (state) => state
: pipe<S>().then(STOP({ debug })).done();
}
}

Expand Down
Loading
Loading