Skip to content

Commit 703bf32

Browse files
committed
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.
1 parent 9c8d7cc commit 703bf32

5 files changed

Lines changed: 256 additions & 15 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 of the
140+
instruction after it. Before, these had none, so a debugger stopped
141+
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: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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+
13+
const { Context } = Program;
14+
15+
/** Whether a context gives a source range, in a gather or a pick too */
16+
function hasCode(context: Format.Program.Context | undefined): boolean {
17+
if (!context) return false;
18+
if (Context.isCode(context)) return true;
19+
if (Context.isGather(context)) return context.gather.some(hasCode);
20+
if (Context.isPick(context)) return context.pick.some(hasCode);
21+
return false;
22+
}
23+
24+
/** Whether a context starts a call, in a gather too */
25+
function hasInvoke(context: Format.Program.Context | undefined): boolean {
26+
if (!context) return false;
27+
if (Context.isInvoke(context)) return true;
28+
return Context.isGather(context) && context.gather.some(hasInvoke);
29+
}
30+
31+
/** Why an instruction has no source range, if it may have none */
32+
function exception(
33+
instructions: Format.Program.Instruction[],
34+
index: number,
35+
): string | undefined {
36+
const { operation, context } = instructions[index];
37+
const remark = context && Context.isRemark(context) ? context.remark : "";
38+
// The source has no `code` block: the runtime is the compiler's own
39+
if (remark === "initialize free memory pointer") return "no code block";
40+
// The create program's end: it returns the runtime code
41+
if (remark.startsWith("deployment:")) return "deployment";
42+
// A block that starts with an inlined call: the JUMPDEST is at the
43+
// call site, which the call's first instruction does not always give
44+
if (
45+
operation?.mnemonic === "JUMPDEST" &&
46+
hasInvoke(instructions[index + 1]?.context)
47+
) {
48+
return "before an inlined call";
49+
}
50+
return undefined;
51+
}
52+
53+
describe("source ranges", () => {
54+
it("are on every instruction of every example", async () => {
55+
const missing: string[] = [];
56+
for (const [name, source] of Object.entries(exampleSources)) {
57+
for (const level of [0, 1, 2, 3] as const) {
58+
const result = await compile({
59+
to: "bytecode",
60+
source,
61+
optimizer: { level },
62+
});
63+
if (!result.success) continue;
64+
const { runtimeProgram, createProgram } = result.value.bytecode;
65+
for (const program of [runtimeProgram, createProgram]) {
66+
const { instructions } = program ?? { instructions: [] };
67+
instructions.forEach(({ offset, context }, index) => {
68+
if (hasCode(context) || exception(instructions, index)) return;
69+
missing.push(`${name} at O${level}, offset ${offset}`);
70+
});
71+
}
72+
}
73+
}
74+
expect(missing).toEqual([]);
75+
}, 120_000);
76+
77+
it("give a called function's entry its declaration", async () => {
78+
const source = `name Entry;
79+
define {
80+
function f(n: uint256) -> uint256 {
81+
if (n > 0) { return n; }
82+
return f(n + 1);
83+
};
84+
}
85+
storage { [0] r: uint256; }
86+
create {}
87+
code { r = f(0); }`;
88+
for (const level of [0, 1, 2, 3] as const) {
89+
const result = await compile({
90+
to: "bytecode",
91+
source,
92+
optimizer: { level },
93+
});
94+
if (!result.success) throw new Error("compilation failed");
95+
const entries = result.value.bytecode.runtimeProgram.instructions.filter(
96+
({ operation, context }) =>
97+
operation?.mnemonic === "JUMPDEST" &&
98+
context &&
99+
Context.isInvoke(context),
100+
);
101+
expect(entries.length).toBeGreaterThan(0);
102+
for (const { context } of entries) {
103+
const { code, invoke } = context as Format.Program.Context.Code &
104+
Format.Program.Context.Invoke;
105+
expect(code).toEqual((invoke as { declaration?: unknown }).declaration);
106+
}
107+
}
108+
});
109+
});

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ function codeOffset(context: unknown): number | undefined {
4343
?.code?.range?.offset;
4444
}
4545

46+
/** The range of an instruction's `code`, if it has one */
47+
function codeRange(
48+
context: unknown,
49+
): { offset: number; length: number } | undefined {
50+
return (
51+
context as
52+
| { code?: { range?: { offset: number; length: number } } }
53+
| undefined
54+
)?.code?.range;
55+
}
56+
4657
/** A struct local, a call, then the local again */
4758
const acrossCall = `name AcrossCall;
4859
define {
@@ -184,9 +195,11 @@ code { r = f(3); }`;
184195
const declared = source.indexOf("let x");
185196
const rows = await listed(source);
186197
for (const { instruction, names } of rows) {
187-
const offset = codeOffset(instruction.context);
188-
if (offset !== undefined && offset < declared) {
189-
expect(names, `x listed at offset ${offset}`).not.toContain("x");
198+
// code whose whole range is before the declaration (a range
199+
// that holds it, as the function's, may list `x`)
200+
const range = codeRange(instruction.context);
201+
if (range && range.offset + range.length <= declared) {
202+
expect(names, `x listed at offset ${range.offset}`).not.toContain("x");
190203
}
191204
}
192205
expect(rows.some(({ names }) => names.includes("x"))).toBe(true);

‎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+
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

‎packages/bugc/src/evmgen/generation/function.ts‎

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,12 @@ function generatePrologue<S extends Stack>(
6161
// Until the prologue has stored the arguments in the frame, the
6262
// parameters are listed without a pointer (the invoke's
6363
// `arguments` point at them on the stack)
64+
// The entry's source range is the function's declaration, the
65+
// one its invoke names
6466
const variables = preludeVariables(func);
6567
const entryDebug = {
6668
context: {
69+
...(declaration ? { code: declaration } : {}),
6770
...entryInvoke,
6871
...(variables.length > 0 ? { variables } : {}),
6972
} as Format.Program.Context,
@@ -364,7 +367,7 @@ export function generate(
364367
}
365368
}
366369

367-
return Block.generate(
370+
const generated = Block.generate(
368371
block,
369372
predecessor,
370373
isLastBlock,
@@ -373,6 +376,9 @@ export function generate(
373376
func,
374377
options.functions,
375378
)(entry);
379+
return isFirstBlock
380+
? generated
381+
: withLabelRange(generated, state.instructions.length, func);
376382
},
377383
stateAfterPrologue,
378384
);
@@ -407,6 +413,79 @@ export function generate(
407413
};
408414
}
409415

416+
/**
417+
* A block's entry JUMPDEST, at `index`, with a source range when it has
418+
* none of its own: that of what runs next, the instruction after it.
419+
* Only when that instruction is in the same inlined bodies and starts
420+
* and ends no call, so its range is the label's, too. A label with
421+
* nothing after it (a block that holds only a return the code leaves
422+
* out) gets the function's range, as that return would.
423+
*/
424+
function withLabelRange<S extends Stack>(
425+
state: State<S>,
426+
index: number,
427+
func: Ir.Function,
428+
): State<S> {
429+
const label = state.instructions[index];
430+
if (label?.mnemonic !== "JUMPDEST") return state;
431+
const context = (label.debug?.context ?? {}) as Format.Program.Context;
432+
if ("code" in context || "gather" in context) return state;
433+
434+
const next = state.instructions[index + 1]?.debug?.context;
435+
const range = next
436+
? inlines(next) === inlines(context)
437+
? rangeOf(next)
438+
: undefined
439+
: func.sourceId && func.loc
440+
? { code: { source: { id: func.sourceId }, range: func.loc } }
441+
: undefined;
442+
if (!range) return state;
443+
444+
const instructions = [...state.instructions];
445+
instructions[index] = {
446+
...label,
447+
debug: {
448+
...label.debug,
449+
context: { ...context, ...range } as Format.Program.Context,
450+
},
451+
};
452+
return { ...state, instructions };
453+
}
454+
455+
/** How many `inline` transforms a context has at its top */
456+
function inlines(context: Format.Program.Context): number {
457+
return "transform" in context
458+
? context.transform.filter((id) => id === "inline").length
459+
: 0;
460+
}
461+
462+
/**
463+
* A context's source ranges, without the rest, when it has nothing
464+
* but ranges, variables and transforms other than `inline` (below its
465+
* top)
466+
*/
467+
function rangeOf(
468+
context: Format.Program.Context,
469+
top = true,
470+
): Format.Program.Context.Code | Format.Program.Context.Gather | undefined {
471+
const {
472+
variables: _,
473+
transform: __,
474+
...rest
475+
} = context as Record<string, unknown>;
476+
if (!top && inlines(context) > 0) return undefined;
477+
if (Format.Program.Context.isCode(rest) && Object.keys(rest).length === 1) {
478+
return { code: rest.code };
479+
}
480+
if (Format.Program.Context.isGather(rest) && Object.keys(rest).length === 1) {
481+
const gather = rest.gather.map((c) => rangeOf(c, false));
482+
return gather.every((c) => c !== undefined)
483+
? { gather: gather as Format.Program.Context[] }
484+
: undefined;
485+
}
486+
return undefined;
487+
}
488+
410489
/**
411490
* Patch jump targets after all blocks have been generated
412491
*/

0 commit comments

Comments
 (0)