mirror of
https://github.com/imjasonh/nescript
synced 2026-07-13 11:07:31 +00:00
analyzer: add four new warning diagnostics
Extends the analyzer with the warnings listed under "Error message
polish" in docs/future-work.md:
- W0102 (existing): now also fires for `while true { ... }` and
`loop { if cond { continue } }`. `continue` is explicitly not
counted as an exit — the loop still spins forever.
- W0105 (new): palette declarations whose sub-palette first-bytes
disagree. The NES mirrors $3F10/$3F14/$3F18/$3F1C onto $3F00/
$3F04/$3F08/$3F0C, so a 32-byte sequential write silently
overwrites the background universal colour; the grouped
`universal:` form auto-fixes this so only flat `colors: [...]`
declarations can trip it.
- W0106 (new): a call at statement position whose callee has a
declared return type — the value is silently dropped.
- W0107 (new): `fast` variables with fewer than three observed
reads+writes, which don't justify holding a scarce zero-page
slot. Leading-underscore names are exempt.
All four are warnings only — no IR, codegen, or runtime changes,
so every committed example ROM rebuilds byte-identical and no
emulator goldens flip. Tests added in src/analyzer/tests.rs.
One legitimate W0106 surfaces on examples/platformer.ne (the
`resolve_enemy_hit` helper uses early-return for control flow
but its return value is discarded by the caller); fixing it
would shift the ROM and flip goldens, so it is left in place
as an informational hint rather than a hard fix.
https://claude.ai/code/session_01MaNVcDmK9gsspRkdxowQAM
This commit is contained in:
parent
68184b7296
commit
d2cfce595b
3 changed files with 556 additions and 2 deletions
|
|
@ -1148,3 +1148,359 @@ fn analyze_does_not_reserve_zero_page_without_palette_or_bg() {
|
|||
.expect("x should be allocated");
|
||||
assert_eq!(x.address, 0x10);
|
||||
}
|
||||
|
||||
// ── W0102 extended: `while true` + continue-only loops ──────
|
||||
|
||||
#[test]
|
||||
fn analyze_while_true_without_exit_warns() {
|
||||
// `while true { x = x + 1 }` — no break/return/wait_frame,
|
||||
// so the same W0102 that fires on bare `loop { ... }` must
|
||||
// also fire here.
|
||||
let errors = analyze_errors(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
var x: u8 = 0
|
||||
on frame {
|
||||
while true { x = x + 1 }
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
errors.contains(&ErrorCode::W0102),
|
||||
"`while true` with no exit should produce W0102, got: {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_while_true_with_wait_frame_ok() {
|
||||
// `while true { wait_frame }` yields control to the NMI each
|
||||
// iteration, so the NES actually makes progress — no warning.
|
||||
let result = analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
on frame {
|
||||
while true { wait_frame }
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
!result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code == ErrorCode::W0102),
|
||||
"`while true` + wait_frame should NOT warn, got: {:?}",
|
||||
result.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_while_true_with_break_ok() {
|
||||
// A reachable `break` satisfies W0102 just like it does for
|
||||
// bare `loop`.
|
||||
let result = analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
var x: u8 = 0
|
||||
on frame {
|
||||
while true {
|
||||
x = x + 1
|
||||
if x == 10 { break }
|
||||
}
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
!result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code == ErrorCode::W0102),
|
||||
"`while true` + break should NOT warn, got: {:?}",
|
||||
result.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_loop_with_only_continue_still_warns() {
|
||||
// `continue` is *not* an exit — the loop still spins forever.
|
||||
// W0102 must still fire here.
|
||||
let errors = analyze_errors(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
var x: u8 = 0
|
||||
on frame {
|
||||
loop {
|
||||
if x == 0 { continue }
|
||||
}
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
errors.contains(&ErrorCode::W0102),
|
||||
"`loop` whose only exit is `continue` should still produce W0102, got: {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── W0105: palette universal-byte consistency ───────────────
|
||||
|
||||
#[test]
|
||||
fn analyze_palette_consistent_universals_ok() {
|
||||
// Flat-form palette where every sub-palette's first byte is
|
||||
// the same universal colour ($0F = black). No W0105.
|
||||
let result = analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
palette Consistent {
|
||||
colors: [
|
||||
0x0F, 0x11, 0x12, 0x13,
|
||||
0x0F, 0x21, 0x22, 0x23,
|
||||
0x0F, 0x31, 0x32, 0x33,
|
||||
0x0F, 0x01, 0x02, 0x03,
|
||||
0x0F, 0x05, 0x06, 0x07,
|
||||
0x0F, 0x15, 0x16, 0x17,
|
||||
0x0F, 0x25, 0x26, 0x27,
|
||||
0x0F, 0x35, 0x36, 0x37
|
||||
]
|
||||
}
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
!result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code == ErrorCode::W0105),
|
||||
"consistent palette should NOT warn, got: {:?}",
|
||||
result.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_palette_inconsistent_universals_warns() {
|
||||
// Flat-form palette whose sub-palette first bytes disagree
|
||||
// (index 0 = $0F, index 16 = $30, etc.) — the $3F10 mirror
|
||||
// will overwrite the background universal colour at runtime.
|
||||
let errors = analyze_errors(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
palette Broken {
|
||||
colors: [
|
||||
0x0F, 0x11, 0x12, 0x13,
|
||||
0x0F, 0x21, 0x22, 0x23,
|
||||
0x0F, 0x31, 0x32, 0x33,
|
||||
0x0F, 0x01, 0x02, 0x03,
|
||||
0x30, 0x05, 0x06, 0x07,
|
||||
0x0F, 0x15, 0x16, 0x17,
|
||||
0x0F, 0x25, 0x26, 0x27,
|
||||
0x0F, 0x35, 0x36, 0x37
|
||||
]
|
||||
}
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
errors.contains(&ErrorCode::W0105),
|
||||
"inconsistent palette universals should produce W0105, got: {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_grouped_palette_is_always_consistent() {
|
||||
// The grouped form uses the `universal:` field to drive
|
||||
// every sub-palette's first byte, so it can never trip
|
||||
// W0105 — even when the sub-palette bodies differ wildly.
|
||||
let result = analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
palette Grouped {
|
||||
universal: 0x0F
|
||||
bg0: [0x11, 0x12, 0x13]
|
||||
bg1: [0x21, 0x22, 0x23]
|
||||
bg2: [0x31, 0x32, 0x33]
|
||||
bg3: [0x01, 0x02, 0x03]
|
||||
sp0: [0x05, 0x06, 0x07]
|
||||
sp1: [0x15, 0x16, 0x17]
|
||||
sp2: [0x25, 0x26, 0x27]
|
||||
sp3: [0x35, 0x36, 0x37]
|
||||
}
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
!result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code == ErrorCode::W0105),
|
||||
"grouped palette should never trip W0105, got: {:?}",
|
||||
result.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
// ── W0106: implicit drop of a function return value ─────────
|
||||
|
||||
#[test]
|
||||
fn analyze_discarded_non_void_return_warns() {
|
||||
// `double(x)` returns u8 but the caller drops the result.
|
||||
let errors = analyze_errors(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
var x: u8 = 0
|
||||
fun double(n: u8) -> u8 { return n + n }
|
||||
on frame { double(x) }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
errors.contains(&ErrorCode::W0106),
|
||||
"discarded non-void return should produce W0106, got: {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_discarded_void_call_ok() {
|
||||
// Void function at statement position is the happy path —
|
||||
// no discarded value, no warning.
|
||||
let result = analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
var x: u8 = 0
|
||||
fun bump() { x = x + 1 }
|
||||
on frame { bump() }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
!result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code == ErrorCode::W0106),
|
||||
"void call should NOT produce W0106, got: {:?}",
|
||||
result.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_non_void_return_used_as_rhs_ok() {
|
||||
// Same signature as the discarded case, but the return value
|
||||
// is consumed by an assignment — no warning.
|
||||
let result = analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
var x: u8 = 0
|
||||
var y: u8 = 0
|
||||
fun double(n: u8) -> u8 { return n + n }
|
||||
on frame { y = double(x) }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
!result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code == ErrorCode::W0106),
|
||||
"assigned return value should NOT produce W0106, got: {:?}",
|
||||
result.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
// ── W0107: `fast` variable slot under-use ───────────────────
|
||||
|
||||
#[test]
|
||||
fn analyze_fast_var_underused_warns() {
|
||||
// `counter` is declared `fast` but only one read (in the
|
||||
// `if` condition), so its access count is 1 — below the
|
||||
// threshold of 3. W0107 should fire.
|
||||
let errors = analyze_errors(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
fast var counter: u8 = 0
|
||||
on frame {
|
||||
if counter == 0 { wait_frame }
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
errors.contains(&ErrorCode::W0107),
|
||||
"under-used `fast` var should produce W0107, got: {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_fast_var_heavy_use_ok() {
|
||||
// Three-plus accesses (one init + one read + one write-back)
|
||||
// is enough to justify the slot — no W0107.
|
||||
let result = analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
fast var counter: u8 = 0
|
||||
on frame {
|
||||
counter = counter + 1
|
||||
if counter == 0 { wait_frame }
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
!result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code == ErrorCode::W0107),
|
||||
"hot `fast` var should NOT warn, got: {:?}",
|
||||
result.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_non_fast_var_never_warns() {
|
||||
// Only `fast` declarations are checked — a plain `var` with
|
||||
// the same (light) access pattern must not fire W0107.
|
||||
let result = analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
var counter: u8 = 0
|
||||
on frame {
|
||||
if counter == 0 { wait_frame }
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
!result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code == ErrorCode::W0107),
|
||||
"plain `var` should never trip W0107, got: {:?}",
|
||||
result.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_fast_var_underscore_exempt() {
|
||||
// Leading-underscore names are exempt from W0107, mirroring
|
||||
// the W0103 convention for deliberately-unused variables.
|
||||
let result = analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
fast var _reserved: u8 = 0
|
||||
on frame {
|
||||
if _reserved == 0 { wait_frame }
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
!result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code == ErrorCode::W0107),
|
||||
"underscore-prefixed `fast` var should be exempt, got: {:?}",
|
||||
result.diagnostics
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue