1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-09 17:28:00 +00:00

W0110 inline fallback warning + docs refresh

W0110: when a function marked `inline` has a body shape the IR
lowerer can't splice (conditional early return, loops, nested
control flow, empty void body), the analyzer now emits a
warning at the declaration site so the declined hint is
visible instead of silently falling back to a regular JSR.

Implementation:
  - New `W0110` error code in `src/errors/diagnostic.rs` (warning level).
  - New `pub fn can_inline_fun(return_type, body) -> bool` in
    `src/ir/lowering.rs`, extracted from the existing capture
    logic so the analyzer and the IR lowerer share the same
    eligibility rules and can never drift.
  - New `check_inline_declinability` analyzer pass called from
    the tail of `analyze_program`, mirroring the existing
    `check_sprite_scanline_budget` / `check_unreachable_states`
    passes. Emits W0110 with help + note text pointing at the
    two accepted body shapes.
  - `capture_inline_bodies` now defers to `can_inline_fun`
    instead of duplicating the match pattern, so the two sides
    stay in lockstep by construction.

Four regression tests in `src/analyzer/tests.rs` cover the
conditional-return and while-loop declines plus the two
accepted shapes (single-return expression, void sequence).

Example source cleanups: `wrap52` in `examples/war/deck.ne`
and `abs_diff` in both `examples/arrays_and_functions.ne` and
`examples/loop_break_continue.ne` drop the `inline` keyword.
All three were dead hints — the `inline` was being silently
declined before this change, so removing it is source-only;
the three ROMs are byte-identical, all 32 emulator goldens
still match.

Docs refresh
  - `docs/language-guide.md`: rewrote the Inline Functions section
    (real behaviour + W0110), added W0105/W0106/W0107/W0108/W0109/
    W0110 to the warnings table, added the `debug.sprite_overflow*`
    builtins + sprite-per-scanline mitigations section to the
    Debug Mode docs, added a `cycle_sprites` statement entry and
    cross-referenced it from `draw`.
  - `docs/nes-reference.md`: fleshed out the "NEScript Memory
    Usage" block with the full ZP + high-RAM layout, including
    the new `$07EF` / `$07FC` / `$07FD` slots for sprite cycling
    and the debug sprite-overflow telemetry.
  - `docs/future-work.md`: documented all four debug query
    builtins in the "What ships today" block; updated the open
    "OAM allocation strategy" question to reference the shipped
    `cycle_sprites` path and ask about an automatic-flicker
    game attribute as a follow-up.
  - `docs/architecture.md`: updated the `ir/` and `optimizer/`
    module summaries to describe real inline splicing (now
    in lowering, not the optimizer).
  - `README.md`: reframed the `inline` bullet from "hint" to
    "real splicing for single-return / void-body shapes";
    expanded the debug-support bullet to mention the four
    query builtins and their stripping in release builds; added
    a new bullet for the three-layer sprite-per-scanline
    mitigations; bumped the test count from 497 → 694; updated
    the war.ne entry to mention the seven compiler bugs are all
    fixed and point readers at `git log` (instead of the
    deleted COMPILER_BUGS.md).
  - `examples/README.md`: same `git log`-pointing rewrite for
    the war.ne entry.

Deletions
  - `examples/war/COMPILER_BUGS.md` is removed. All seven
    catalogued bugs are fixed; the file's historical value
    lives in `git log` now. Every source-code comment and doc
    reference to the file has been updated to either point at
    `git log` or just describe the bug in place.

Test count: 616 unit + 75 integration + 3 doctests = 694 total.
Clippy / fmt clean. 32/32 emulator goldens match.

https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
This commit is contained in:
Claude 2026-04-15 23:19:07 +00:00
parent 5e5bed39a5
commit 548787ac8a
No known key found for this signature in database
18 changed files with 487 additions and 858 deletions

View file

@ -688,6 +688,15 @@ impl Analyzer {
// coordinates are skipped because the static analysis
// can't know where the sprite will land at runtime.
self.check_sprite_scanline_budget(program);
// Check every `inline fun` declaration against the
// IR lowerer's inline-eligibility rules. Functions
// whose body shape isn't splicable compile as regular
// out-of-line calls — which is correct, but silent:
// users who mark a helper `inline` to avoid call
// overhead might not realize the hint was declined.
// W0110 makes the fallback visible.
self.check_inline_declinability(program);
}
/// Qualify `name` under the current scope prefix. If no prefix
@ -1148,6 +1157,47 @@ impl Analyzer {
}
}
/// Walk every `inline fun` declaration and check whether the
/// IR lowerer will actually inline its body. Functions that
/// won't inline (conditional returns, loops, transitions,
/// empty void bodies, etc.) compile as regular out-of-line
/// calls — correct but silent. W0110 surfaces the fallback
/// at the declaration site with help text pointing at the
/// two body shapes the inliner does accept.
///
/// Defers to [`crate::ir::lowering::can_inline_fun`] so the
/// two sides (warning + actual capture) can never drift.
fn check_inline_declinability(&mut self, program: &Program) {
for fun in &program.functions {
if !fun.is_inline {
continue;
}
if crate::ir::can_inline_fun(fun.return_type.as_ref(), &fun.body) {
continue;
}
self.diagnostics.push(
Diagnostic::warning(
ErrorCode::W0110,
format!(
"`inline fun {}` cannot be inlined; falling back to a regular call",
fun.name
),
fun.span,
)
.with_help(
"the inliner accepts two body shapes: a single `return <expr>` (for \
functions with a return type) or a sequence of plain statements with no \
control flow (for void functions). Rewrite the body to fit one of those, \
or remove the `inline` keyword if the JSR overhead is acceptable",
)
.with_note(
"rejected body shapes include conditional early returns, if/while/for/loop \
blocks, transitions, breaks, continues, and nested function definitions",
),
);
}
}
fn register_const(&mut self, c: &ConstDecl) {
if self.symbols.contains_key(&c.name) {
self.diagnostics.push(Diagnostic::error(

View file

@ -2103,10 +2103,11 @@ fn analyze_accepts_function_with_exactly_4_params() {
#[test]
fn analyze_allows_same_local_name_in_two_functions() {
// Regression test for COMPILER_BUGS.md §3: function-body
// `var` declarations used to live in a flat global namespace,
// so two functions both declaring `var i` collided on E0501.
// They should now coexist in their own per-function scopes.
// Regression test for the function-local scope-qualification
// fix on the War bug-cleanup branch (see `git log`): function-
// body `var` declarations used to live in a flat global
// namespace, so two functions both declaring `var i` collided
// on E0501. They now coexist in per-function scopes.
analyze_ok(
r#"
game "T" { mapper: NROM }
@ -2132,11 +2133,12 @@ fn analyze_allows_same_local_name_in_two_functions() {
#[test]
fn analyze_allows_same_param_name_in_two_functions() {
// Regression test for COMPILER_BUGS.md §1b: parameters
// Regression test for the scope-qualified parameter fix on
// the War bug-cleanup branch (see `git log`): parameters
// across different functions used to share VarIds because
// the IR lowerer's `var_map` was global. Both declaration
// (analyzer) and lowering (IR) should now give each
// function's parameters their own independent entries.
// (analyzer) and lowering (IR) now give each function's
// parameters their own independent entries.
analyze_ok(
r#"
game "T" { mapper: NROM }
@ -2477,3 +2479,145 @@ fn analyze_accepts_cycle_sprites_statement() {
"#,
);
}
#[test]
fn analyze_inline_fun_with_conditional_return_trips_w0110() {
// A function marked `inline` whose body has an early
// conditional return can't be inlined by the simple
// substitution machinery — it compiles as a regular
// `JSR` call, and W0110 must fire at the declaration.
let (prog, diags) = parser::parse(
r#"
game "T" { mapper: NROM }
inline fun wrap(v: u8) -> u8 {
if v >= 52 {
return v - 52
}
return v
}
var x: u8 = 0
on frame {
x = wrap(60)
wait_frame
}
start Main
"#,
);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let result = analyze(&prog.unwrap());
let w0110: Vec<_> = result
.diagnostics
.iter()
.filter(|d| d.code == ErrorCode::W0110)
.collect();
assert_eq!(
w0110.len(),
1,
"expected exactly one W0110, got: {:?}",
result.diagnostics
);
assert!(
w0110[0].message.contains("wrap"),
"W0110 should name the declined function, got: {}",
w0110[0].message
);
assert!(
w0110[0].help.is_some() && w0110[0].note.is_some(),
"W0110 should carry both help and note text"
);
}
#[test]
fn analyze_inline_fun_with_single_return_expression_is_accepted() {
// A body that is exactly `return <expr>` is the canonical
// inlinable shape — no W0110 should fire.
let (prog, diags) = parser::parse(
r#"
game "T" { mapper: NROM }
inline fun card_rank(card: u8) -> u8 {
return card >> 4
}
var x: u8 = 0
on frame {
x = card_rank(0x93)
wait_frame
}
start Main
"#,
);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let result = analyze(&prog.unwrap());
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0110),
"did not expect W0110 for single-return inline, got: {:?}",
result.diagnostics
);
}
#[test]
fn analyze_inline_void_fun_is_accepted() {
// A void body with nothing but assigns is inlinable.
let (prog, diags) = parser::parse(
r#"
game "T" { mapper: NROM }
var a: u8 = 0
var b: u8 = 0
inline fun set_pair(x: u8, y: u8) {
a = x
b = y
}
on frame {
set_pair(5, 6)
wait_frame
}
start Main
"#,
);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let result = analyze(&prog.unwrap());
assert!(
!result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0110),
"did not expect W0110 for void inline, got: {:?}",
result.diagnostics
);
}
#[test]
fn analyze_inline_fun_with_loop_body_trips_w0110() {
// A `while` loop inside a marked-inline body is another
// disqualifying shape.
let (prog, diags) = parser::parse(
r#"
game "T" { mapper: NROM }
var sum: u8 = 0
inline fun accumulate(n: u8) {
var i: u8 = 0
while i < n {
sum += i
i += 1
}
}
on frame {
accumulate(5)
wait_frame
}
start Main
"#,
);
assert!(diags.is_empty(), "parse errors: {diags:?}");
let result = analyze(&prog.unwrap());
assert!(
result
.diagnostics
.iter()
.any(|d| d.code == ErrorCode::W0110),
"expected W0110 for loop-body inline, got: {:?}",
result.diagnostics
);
}

View file

@ -242,9 +242,11 @@ impl<'a> IrCodeGen<'a> {
// Before this change, parameters lived in `$04-$07` for the
// duration of the function body, so any call nested inside
// a function's body silently corrupted the caller's
// parameters — see COMPILER_BUGS.md §2. The per-function
// RAM slots + prologue spill fix that class of bug at the
// cost of 4 LDA/STA pairs per function entry.
// parameters (fixed on the War bug-cleanup branch; see
// `git log` for the original reproduction and root cause).
// The per-function RAM slots + prologue spill fix that
// class of bug at the cost of 4 LDA/STA pairs per function
// entry.
//
// Locals are laid out linearly across every function:
// NEScript forbids recursion (E0402) and enforces a
@ -3906,7 +3908,8 @@ mod more_tests {
#[test]
fn gen_function_prologue_spills_params_to_local_ram() {
// Regression test for COMPILER_BUGS.md §2: without the
// Regression test for the param-slot clobbering bug fixed
// on the War bug-cleanup branch (see `git log`): without the
// param-spill prologue, a function's parameters live only
// in the transport slots $04-$07. The first nested call
// inside the body would overwrite those slots with its
@ -3969,7 +3972,7 @@ fn gen_function_prologue_spills_params_to_local_ram() {
assert!(
saw_lda_zp4 && saw_sta_abs,
"caller function should open with `LDA $04 / STA <absolute>` \
as the param-spill prologue the fix for COMPILER_BUGS.md §2 \
is not in effect"
as the param-spill prologue the param-clobbering fix is \
not in effect"
);
}

View file

@ -45,6 +45,7 @@ pub enum ErrorCode {
W0107, // `fast` variable rarely accessed (wastes zero-page slot)
W0108, // array elements past byte 255 unreachable via 8-bit X index
W0109, // too many literal-coord sprite draws on one scanline (NES 8/scanline limit)
W0110, // `inline fun` declined — body shape not splicable, fell back to regular call
}
impl fmt::Display for ErrorCode {
@ -74,6 +75,7 @@ impl fmt::Display for ErrorCode {
Self::W0107 => "W0107",
Self::W0108 => "W0108",
Self::W0109 => "W0109",
Self::W0110 => "W0110",
};
write!(f, "{code}")
}
@ -90,7 +92,8 @@ impl ErrorCode {
| Self::W0106
| Self::W0107
| Self::W0108
| Self::W0109 => Level::Warning,
| Self::W0109
| Self::W0110 => Level::Warning,
_ => Level::Error,
}
}

View file

@ -43,7 +43,8 @@ struct LoweringContext {
/// registered as substitutions for the parameter names,
/// and the body is lowered into the caller's current block
/// in place of a `Call` op. See `try_inline_call_expr` /
/// `try_inline_call_stmt` below and `COMPILER_BUGS.md` §5.
/// `try_inline_call_stmt` below; the feature was added on
/// the War bug-cleanup branch (see `git log`).
inline_bodies: HashMap<String, CapturedInline>,
/// Substitution stack for nested inline expansions. The top
/// frame is the active substitution map — `Expr::Ident(name)`
@ -237,11 +238,18 @@ impl LoweringContext {
if !fun.is_inline {
continue;
}
// Defer to the shared `can_inline_fun` helper so the
// analyzer's W0110 check and this capture pass agree
// on exactly which bodies are splicable — any drift
// between the two would either swallow real bugs
// (lower inlines a body the analyzer thinks it
// wouldn't) or emit false-positive warnings (analyzer
// warns on something the lowerer actually inlines).
if !can_inline_fun(fun.return_type.as_ref(), &fun.body) {
continue;
}
// Single-return-expression shape.
if fun.return_type.is_some()
&& fun.body.statements.len() == 1
&& matches!(fun.body.statements[0], Statement::Return(Some(_), _))
{
if fun.return_type.is_some() && fun.body.statements.len() == 1 {
if let Statement::Return(Some(expr), _) = &fun.body.statements[0] {
self.inline_bodies.insert(
fun.name.clone(),
@ -253,25 +261,16 @@ impl LoweringContext {
continue;
}
}
// Void multi-statement shape: no return type, and
// every body statement must be a shape we know how
// to splice. Only assigns, statement-context calls,
// draws, scroll, set_palette, and load_background
// are accepted — anything with nested control flow
// is too complex to inline without a full CFG
// clone.
if fun.return_type.is_none()
&& !fun.body.statements.is_empty()
&& fun.body.statements.iter().all(is_splicable_void_stmt)
{
self.inline_bodies.insert(
fun.name.clone(),
CapturedInline {
params: fun.params.clone(),
body: InlineBody::Void(fun.body.statements.clone()),
},
);
}
// Void multi-statement shape: no return type, every
// body statement is splicable. The helper already
// checked both conditions so we just clone.
self.inline_bodies.insert(
fun.name.clone(),
CapturedInline {
params: fun.params.clone(),
body: InlineBody::Void(fun.body.statements.clone()),
},
);
}
}
@ -626,7 +625,9 @@ impl LoweringContext {
// true and, worse, `widen(t)` returning a stale `hi` temp ID
// that collides with a later `fresh_temp()` allocation —
// producing 16-bit IR ops where the destination temp is
// *also* one of the source temps. See COMPILER_BUGS.md §6.
// *also* one of the source temps (the `wide_hi` leak bug
// fixed on the War cleanup branch; see `git log` for the
// full reproduction).
self.wide_hi.clear();
self.current_blocks = Vec::new();
self.current_locals = Vec::new();
@ -724,9 +725,9 @@ impl LoweringContext {
fn lower_handler(&mut self, name: &str, scope_prefix: &str, block: &Block, state: &StateDecl) {
self.next_temp = 0;
// Same per-function reset as `lower_function`. See the
// commentary there and COMPILER_BUGS.md §6 for why this is
// critical — without it, state-handler bodies pick up wide
// temp pairs left over from the previous function and emit
// commentary there for why this is critical — without it,
// state-handler bodies pick up wide temp pairs left over
// from the previous function and emit
// catastrophically wrong 16-bit IR ops.
self.wide_hi.clear();
self.current_blocks = Vec::new();
@ -1837,6 +1838,40 @@ fn is_splicable_void_stmt(stmt: &Statement) -> bool {
)
}
/// True if an `inline fun` with the given return type and body
/// matches one of the shapes [`LoweringContext::capture_inline_bodies`]
/// can splice into a caller. Two shapes are recognised:
///
/// 1. **Single-return expression**: the function has a declared
/// return type and its body is exactly `{ return <expr> }`.
/// 2. **Void multi-statement**: the function has no return type
/// and every body statement passes [`is_splicable_void_stmt`].
///
/// Anything else (conditional early returns, loops, nested
/// control flow, multiple returns, an empty void body) falls back
/// to a regular `JSR` call at every site. The analyzer calls this
/// to emit `W0110` when a declared-inline function won't actually
/// be inlined, and the IR lowerer calls the same logic when it
/// decides which bodies to capture — keeping both sides in sync.
#[must_use]
pub fn can_inline_fun(return_type: Option<&NesType>, body: &Block) -> bool {
// Single-return expression shape.
if return_type.is_some()
&& body.statements.len() == 1
&& matches!(body.statements[0], Statement::Return(Some(_), _))
{
return true;
}
// Void multi-statement shape.
if return_type.is_none()
&& !body.statements.is_empty()
&& body.statements.iter().all(is_splicable_void_stmt)
{
return true;
}
false
}
fn type_size(t: &NesType) -> u16 {
match t {
NesType::U8 | NesType::I8 | NesType::Bool => 1,

View file

@ -2,7 +2,7 @@ mod lowering;
#[cfg(test)]
mod tests;
pub use lowering::{lower, RAW_ASM_PREFIX};
pub use lowering::{can_inline_fun, lower, RAW_ASM_PREFIX};
use crate::lexer::Span;
use std::fmt;

View file

@ -743,7 +743,8 @@ fn lower_modulo_emits_mod_op_not_load_imm_zero() {
#[test]
fn wide_hi_does_not_leak_between_functions() {
// Regression test for COMPILER_BUGS.md §6: the IR lowerer's
// Regression test for the `wide_hi` leak bug fixed on the
// War bug-cleanup branch (see `git log`): the IR lowerer's
// `wide_hi` map used to persist across function boundaries
// even though `next_temp` resets to 0 per function. A
// function whose body had no u16 ops would inherit stale
@ -790,7 +791,7 @@ fn wide_hi_does_not_leak_between_functions() {
{
// The dest of a 16-bit compare must never alias one
// of its operand high bytes — that's the symptom of
// bug #6 from war/COMPILER_BUGS.md.
// the `wide_hi` leak bug.
if dest == b_hi || dest == a_hi {
wide_eq_dest_aliases += 1;
}
@ -804,7 +805,8 @@ fn wide_hi_does_not_leak_between_functions() {
#[test]
fn inline_fun_expression_body_emits_no_call_at_use_site() {
// Regression test for COMPILER_BUGS.md §5: `inline fun`
// Regression test for the real-inlining feature added on
// the War bug-cleanup branch (see `git log`): `inline fun`
// with a single-return-expression body should be spliced
// at every call site instead of emitting a Call op. The
// lowered frame handler should contain zero Call ops