Skip to content

Commit bb2d677

Browse files
authored
bugc: give a called function's entry its source range (#400)
* bugc: give a called function's entry its source range A called function's entry JUMPDEST has the function's declaration, the one its invoke names. The end of a body that has no return (the frame teardown and exit JUMP, or the STOP of code) has the function's range, and a block's JUMPDEST has the range of the instruction after it. Code generation is unchanged. * bugc: test the source range rules, keep the strict locals check Check that a block's JUMPDEST has the range of the instruction after it, and that the exit of a body with no return has the function's. Exempt only a JUMPDEST before an inlined call. In the locals check, exempt only the function's range after the body, not the entry and the prologue.
1 parent 9c8d7cc commit bb2d677

5 files changed

Lines changed: 328 additions & 17 deletions

File tree

‎packages/bugc/CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,16 @@ support. Changes to the specification itself are tracked in the root
131131
way. Before, these compiled to a copy of the slot's word, or wrote
132132
the memory address to the slot. An array literal assigned to a storage array variable
133133
(`items = [1, 2];`) still compiles ([#377]).
134+
- Each instruction the compiler adds now has a source range. A called
135+
function's entry JUMPDEST has the function's declaration, as its
136+
`invoke` names. The end of a function body that has no `return`
137+
(the frame teardown and its exit JUMP, or the `STOP` of `code`) has
138+
the function's range. The instructions that return a value from
139+
`code` have the `return`'s range. A block's JUMPDEST has the range
140+
of the instruction after it. Before, these had none, so a debugger
141+
stopped there showed no source. Two exceptions remain: the end of the
142+
create program, which copies and returns the runtime code, and a
143+
JUMPDEST just before an inlined call ([#400]).
134144

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

@@ -596,3 +606,4 @@ First publication.
596606
[#395]: https://github.com/ethdebug/format/pull/395
597607
[#397]: https://github.com/ethdebug/format/pull/397
598608
[#398]: https://github.com/ethdebug/format/pull/398
609+
[#400]: https://github.com/ethdebug/format/pull/400
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/**
2+
* Every instruction has a source range, so a debugger stopped at any
3+
* of them has something to show: the instructions the compiler adds
4+
* (a called function's entry, a block's JUMPDEST, a return the source
5+
* does not write) get the range of what they belong to.
6+
*/
7+
import { describe, it, expect } from "vitest";
8+
import type * as Format from "@ethdebug/format";
9+
import { Program } from "@ethdebug/format";
10+
import { compile } from "#compiler";
11+
import { exampleSources } from "../examples/index.js";
12+
import { hasCode } from "./generation/control-flow/terminator.js";
13+
14+
const { Context } = Program;
15+
16+
/** Whether a context starts an inlined call */
17+
function invokesInline(context: Format.Program.Context | undefined): boolean {
18+
return !!context && invokes(context) && inlined(context);
19+
}
20+
21+
/** Whether a context starts a call with no jump target (in a gather
22+
* too), as an inlined call's invoke has none */
23+
function invokes(context: Format.Program.Context): boolean {
24+
return (
25+
(Context.isInvoke(context) && !("target" in context.invoke)) ||
26+
(Context.isGather(context) && context.gather.some(invokes))
27+
);
28+
}
29+
30+
/** Whether a context is in an inlined body (in a gather too) */
31+
function inlined(context: Format.Program.Context): boolean {
32+
return (
33+
(Context.isTransform(context) && context.transform.includes("inline")) ||
34+
(Context.isGather(context) && context.gather.some(inlined))
35+
);
36+
}
37+
38+
/** Why an instruction has no source range, if it may have none */
39+
function exception(
40+
instructions: Format.Program.Instruction[],
41+
index: number,
42+
): string | undefined {
43+
const { operation, context } = instructions[index];
44+
const remark = context && Context.isRemark(context) ? context.remark : "";
45+
// The source has no `code` block: the runtime is the compiler's own
46+
if (remark === "initialize free memory pointer") return "no code block";
47+
// The create program's end: it returns the runtime code
48+
if (remark.startsWith("deployment:")) return "deployment";
49+
// A block that starts with an inlined call: the JUMPDEST is at the
50+
// call site, which the call's first instruction does not always give
51+
if (
52+
operation?.mnemonic === "JUMPDEST" &&
53+
invokesInline(instructions[index + 1]?.context)
54+
) {
55+
return "before an inlined call";
56+
}
57+
return undefined;
58+
}
59+
60+
describe("source ranges", () => {
61+
it("are on every instruction of every example", async () => {
62+
const missing: string[] = [];
63+
for (const [name, source] of Object.entries(exampleSources)) {
64+
for (const level of [0, 1, 2, 3] as const) {
65+
const result = await compile({
66+
to: "bytecode",
67+
source,
68+
optimizer: { level },
69+
});
70+
if (!result.success) continue;
71+
const { runtimeProgram, createProgram } = result.value.bytecode;
72+
for (const program of [runtimeProgram, createProgram]) {
73+
const { instructions } = program ?? { instructions: [] };
74+
instructions.forEach(({ offset, context }, index) => {
75+
if (hasCode(context) || exception(instructions, index)) return;
76+
missing.push(`${name} at O${level}, offset ${offset}`);
77+
});
78+
}
79+
}
80+
}
81+
expect(missing).toEqual([]);
82+
}, 120_000);
83+
84+
it("give a called function's entry its declaration", async () => {
85+
const source = `name Entry;
86+
define {
87+
function f(n: uint256) -> uint256 {
88+
if (n > 0) { return n; }
89+
return f(n + 1);
90+
};
91+
}
92+
storage { [0] r: uint256; }
93+
create {}
94+
code { r = f(0); }`;
95+
for (const level of [0, 1, 2, 3] as const) {
96+
const result = await compile({
97+
to: "bytecode",
98+
source,
99+
optimizer: { level },
100+
});
101+
if (!result.success) throw new Error("compilation failed");
102+
const entries = result.value.bytecode.runtimeProgram.instructions.filter(
103+
({ operation, context }) =>
104+
operation?.mnemonic === "JUMPDEST" &&
105+
context &&
106+
Context.isInvoke(context),
107+
);
108+
expect(entries.length).toBeGreaterThan(0);
109+
for (const { context } of entries) {
110+
const { code, invoke } = context as Format.Program.Context.Code &
111+
Format.Program.Context.Invoke;
112+
expect(code).toEqual((invoke as { declaration?: unknown }).declaration);
113+
}
114+
}
115+
});
116+
117+
it("give a block's JUMPDEST the range of what runs next", async () => {
118+
const source = `name Labels;
119+
storage { [0] r: uint256; }
120+
create {}
121+
code {
122+
let n = r;
123+
if (n > 3) { r = n - 1; } else { r = n + 2; }
124+
for (let i = 0; i < n; i = i + 1) { r = r + i; }
125+
}`;
126+
const result = await compile({
127+
to: "bytecode",
128+
source,
129+
optimizer: { level: 0 },
130+
});
131+
if (!result.success) throw new Error("compilation failed");
132+
const { instructions } = result.value.bytecode.runtimeProgram;
133+
const labels = instructions.flatMap(({ operation, context }, index) =>
134+
operation?.mnemonic === "JUMPDEST" ? [{ context, index }] : [],
135+
);
136+
expect(labels.length).toBeGreaterThan(2);
137+
for (const { context, index } of labels) {
138+
const next = instructions[index + 1].context;
139+
expect(hasCode(context)).toBe(true);
140+
expect((context as Format.Program.Context.Code).code).toEqual(
141+
(next as Format.Program.Context.Code).code,
142+
);
143+
}
144+
});
145+
146+
it("give the end of a body with no return the function's", async () => {
147+
const source = `name End;
148+
define {
149+
function g(n: uint256) { r = n; };
150+
}
151+
storage { [0] r: uint256; }
152+
create {}
153+
code { g(3); g(4); }`;
154+
for (const level of [0, 1] as const) {
155+
const result = await compile({
156+
to: "bytecode",
157+
source,
158+
optimizer: { level },
159+
});
160+
if (!result.success) throw new Error("compilation failed");
161+
const exits = result.value.bytecode.runtimeProgram.instructions.filter(
162+
({ operation, context }) =>
163+
operation?.mnemonic === "JUMP" &&
164+
context &&
165+
Context.isReturn(context),
166+
);
167+
expect(exits.length).toBe(1);
168+
const { code, return: info } = exits[0]
169+
.context as Format.Program.Context.Code & Format.Program.Context.Return;
170+
expect(info.declaration).toBeDefined();
171+
expect(code).toEqual(info.declaration);
172+
}
173+
});
174+
});

‎packages/bugc/src/evmgen/debug/local-variables.soundness.test.ts‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,13 +182,31 @@ storage { [0] r: uint256; }
182182
create {}
183183
code { r = f(3); }`;
184184
const declared = source.indexOf("let x");
185+
const func = source.indexOf("function f");
185186
const rows = await listed(source);
186-
for (const { instruction, names } of rows) {
187+
// The end of the body (a return the source does not write) has the
188+
// function's range, which starts before `x` is declared; after the
189+
// body's first instruction at or after the declaration, it may
190+
// list `x`. The entry and the prologue, before it, may not.
191+
const end = source.indexOf("};", func);
192+
const body = rows.findIndex(({ instruction }) => {
193+
const offset = codeOffset(instruction.context) ?? -1;
194+
return offset >= declared && offset < end;
195+
});
196+
expect(body).toBeGreaterThan(0);
197+
// the entry and the prologue are checked
198+
expect(
199+
rows.some(
200+
({ instruction }, index) =>
201+
index < body && codeOffset(instruction.context) === func,
202+
),
203+
).toBe(true);
204+
rows.forEach(({ instruction, names }, index) => {
187205
const offset = codeOffset(instruction.context);
188-
if (offset !== undefined && offset < declared) {
189-
expect(names, `x listed at offset ${offset}`).not.toContain("x");
190-
}
191-
}
206+
if (offset === undefined || offset >= declared) return;
207+
if (offset === func && index > body) return;
208+
expect(names, `x listed at offset ${offset}`).not.toContain("x");
209+
});
192210
expect(rows.some(({ names }) => names.includes("x"))).toBe(true);
193211
});
194212

‎packages/bugc/src/evmgen/generation/control-flow/terminator.ts‎

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,31 @@ function codeContext(
2929
return undefined;
3030
}
3131

32+
/** Whether a context gives a source range, in a gather or a pick too */
33+
export function hasCode(context: Format.Program.Context | undefined): boolean {
34+
if (!context || typeof context !== "object") return false;
35+
if ("code" in context) return true;
36+
if ("gather" in context) return context.gather.some(hasCode);
37+
if ("pick" in context) return context.pick.some(hasCode);
38+
return false;
39+
}
40+
41+
/**
42+
* A debug with the function's source range (its declaration), if it
43+
* gives none of its own
44+
*/
45+
function withFunctionRange(
46+
debug: Ir.Block.Debug,
47+
func: Ir.Function | undefined,
48+
): Ir.Block.Debug {
49+
if (hasCode(debug.context) || !func?.sourceId || !func.loc) return debug;
50+
const code = { source: { id: func.sourceId }, range: func.loc };
51+
return {
52+
...debug,
53+
context: { ...debug.context, code } as Format.Program.Context,
54+
};
55+
}
56+
3257
/**
3358
* Extract the in-scope `variables` list from an instruction/terminator
3459
* debug, if present, so a call's caller JUMP still lists the locals
@@ -68,12 +93,14 @@ export function generateTerminator<S extends Stack>(
6893
// Note: For returns with a value, we assume the return value is already
6994
// on TOS from the previous instruction in the block. This avoids an
7095
// unnecessary DUP that would leave an extra value on the stack.
96+
// A return the source does not write (the end of a body) has
97+
// no source range of its own: it gets the function's
98+
const debug = withFunctionRange(term.operationDebug, func);
7199
if (isUserFunction) {
72100
// Internal function return epilogue.
73101
// Uses the same imperative pattern as
74102
// generateCallTerminator — returns
75103
// Transition<S, Stack> to erase output type.
76-
const debug = term.operationDebug;
77104
return generateReturnEpilogue(term.value, debug, func);
78105
}
79106

@@ -89,23 +116,25 @@ export function generateTerminator<S extends Stack>(
89116
if (allocation === undefined) {
90117
const offset = state.memory.nextStaticOffset;
91118
return builder
92-
.then(loadValue(value))
93-
.then(PUSHn(BigInt(offset)), { as: "offset" })
94-
.then(MSTORE())
95-
.then(PUSHn(32n), { as: "size" })
96-
.then(PUSHn(BigInt(offset)), { as: "offset" })
97-
.then(RETURN());
119+
.then(loadValue(value, { debug }))
120+
.then(PUSHn(BigInt(offset), { debug }), { as: "offset" })
121+
.then(MSTORE({ debug }))
122+
.then(PUSHn(32n, { debug }), { as: "size" })
123+
.then(PUSHn(BigInt(offset), { debug }), { as: "offset" })
124+
.then(RETURN({ debug }));
98125
} else {
99126
const offset = allocation.offset;
100127
return builder
101-
.then(PUSHn(32n), { as: "size" })
102-
.then(PUSHn(BigInt(offset)), { as: "offset" })
103-
.then(RETURN());
128+
.then(PUSHn(32n, { debug }), { as: "size" })
129+
.then(PUSHn(BigInt(offset), { debug }), { as: "offset" })
130+
.then(RETURN({ debug }));
104131
}
105132
})
106133
.done();
107134
} else {
108-
return isLastBlock ? (state) => state : pipe<S>().then(STOP()).done();
135+
return isLastBlock
136+
? (state) => state
137+
: pipe<S>().then(STOP({ debug })).done();
109138
}
110139
}
111140

0 commit comments

Comments
 (0)