1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-18 22:57:43 +00:00

Inline assembly: asm { ... } blocks

- Lexer: after \`asm\` keyword, next \`{\` triggers raw-text capture of
  the body until the matching \`}\`, emitted as a new \`AsmBody\` token
- Parser: \`asm { ... }\` produces \`Statement::InlineAsm(body, span)\`
- Analyzer: treats inline asm as opaque (no checks)
- IR: new \`IrOp::InlineAsm(String)\` variant that passes the body
  through the optimizer unchanged
- \`src/asm/inline_parser.rs\`: minimal 6502 mnemonic parser supporting
  every addressing mode we emit elsewhere (immediate, ZP/ABS with X/Y,
  indirect, indirect-X/Y, labels, branches, implied, accumulator)
- Both IR and AST codegen splice parsed instructions inline
- Integration test covers a mix of implied + immediate + ZP + A modes

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
This commit is contained in:
Claude 2026-04-12 11:16:18 +00:00
parent f8743cf95e
commit 121b0b1968
No known key found for this signature in database
13 changed files with 519 additions and 4 deletions

View file

@ -765,6 +765,11 @@ impl Analyzer {
self.walk_expr_reads(cond);
self.check_expr_type(cond, &NesType::Bool);
}
Statement::InlineAsm(_, _) => {
// Inline assembly is treated as an opaque block. The
// codegen parses and validates the body; analysis has
// nothing to check.
}
}
}
@ -1001,7 +1006,8 @@ fn collect_calls_stmt(stmt: &Statement, calls: &mut Vec<String>) {
| Statement::Break(_)
| Statement::Continue(_)
| Statement::LoadBackground(_, _)
| Statement::SetPalette(_, _) => {}
| Statement::SetPalette(_, _)
| Statement::InlineAsm(_, _) => {}
}
}

381
src/asm/inline_parser.rs Normal file
View file

@ -0,0 +1,381 @@
//! Minimal 6502 assembly parser used by `asm { ... }` inline blocks.
//!
//! Supports the addressing modes we actually emit in codegen:
//! - Implied / Accumulator: `CLC`, `LSR A`
//! - Immediate: `LDA #$10`, `LDA #42`, `LDA #%00001111`
//! - Zero page (+ X/Y): `STA $02`, `LDA $10,X`, `LDX $20,Y`
//! - Absolute (+ X/Y): `STA $2000`, `LDA $0200,X`, `LDA $0100,Y`
//! - Indirect: `JMP ($FFFC)`
//! - Indirect (X/Y): `LDA ($10,X)`, `STA ($10),Y`
//! - Labels: `foo:` on a line by itself, `JMP foo`, `BNE loop`
//!
//! This is not a full 6502 assembler — it only accepts the subset
//! needed by hand-written inline blocks. Unknown mnemonics, unsupported
//! addressing modes, or syntax errors return a `String` message.
use super::{AddressingMode, Instruction, Opcode};
/// Parse a block of inline assembly text into a list of `Instruction`s.
///
/// Each line is either:
/// - blank / a `; comment`
/// - a label `name:`
/// - a mnemonic with an optional operand
///
/// On error, returns the first problem with a line number.
pub fn parse_inline(body: &str) -> Result<Vec<Instruction>, String> {
let mut out = Vec::new();
for (lineno, raw) in body.lines().enumerate() {
let line = strip_comment(raw).trim();
if line.is_empty() {
continue;
}
// Label: `name:`
if let Some(name) = line.strip_suffix(':') {
let name = name.trim();
if name.is_empty() || !is_valid_ident(name) {
return Err(format!("line {}: invalid label `{line}`", lineno + 1));
}
out.push(Instruction::new(
Opcode::NOP,
AddressingMode::Label(name.to_string()),
));
continue;
}
let (mnemonic, rest) = split_mnemonic(line);
let opcode = parse_opcode(mnemonic)
.ok_or_else(|| format!("line {}: unknown mnemonic `{mnemonic}`", lineno + 1))?;
let mode =
parse_operand(opcode, rest.trim()).map_err(|e| format!("line {}: {e}", lineno + 1))?;
out.push(Instruction::new(opcode, mode));
}
Ok(out)
}
fn strip_comment(line: &str) -> &str {
match line.find(';') {
Some(i) => &line[..i],
None => line,
}
}
fn split_mnemonic(line: &str) -> (&str, &str) {
match line.find(|c: char| c.is_whitespace()) {
Some(i) => (&line[..i], &line[i..]),
None => (line, ""),
}
}
fn is_valid_ident(s: &str) -> bool {
s.chars()
.next()
.is_some_and(|c| c == '_' || c.is_ascii_alphabetic())
&& s.chars().all(|c| c == '_' || c.is_ascii_alphanumeric())
}
fn parse_opcode(mnemonic: &str) -> Option<Opcode> {
let m = mnemonic.to_ascii_uppercase();
Some(match m.as_str() {
"LDA" => Opcode::LDA,
"LDX" => Opcode::LDX,
"LDY" => Opcode::LDY,
"STA" => Opcode::STA,
"STX" => Opcode::STX,
"STY" => Opcode::STY,
"ADC" => Opcode::ADC,
"SBC" => Opcode::SBC,
"AND" => Opcode::AND,
"ORA" => Opcode::ORA,
"EOR" => Opcode::EOR,
"ASL" => Opcode::ASL,
"LSR" => Opcode::LSR,
"ROL" => Opcode::ROL,
"ROR" => Opcode::ROR,
"INC" => Opcode::INC,
"DEC" => Opcode::DEC,
"INX" => Opcode::INX,
"INY" => Opcode::INY,
"DEX" => Opcode::DEX,
"DEY" => Opcode::DEY,
"CMP" => Opcode::CMP,
"CPX" => Opcode::CPX,
"CPY" => Opcode::CPY,
"BIT" => Opcode::BIT,
"JMP" => Opcode::JMP,
"JSR" => Opcode::JSR,
"RTS" => Opcode::RTS,
"RTI" => Opcode::RTI,
"BEQ" => Opcode::BEQ,
"BNE" => Opcode::BNE,
"BCC" => Opcode::BCC,
"BCS" => Opcode::BCS,
"BMI" => Opcode::BMI,
"BPL" => Opcode::BPL,
"BVC" => Opcode::BVC,
"BVS" => Opcode::BVS,
"CLC" => Opcode::CLC,
"SEC" => Opcode::SEC,
"CLI" => Opcode::CLI,
"SEI" => Opcode::SEI,
"CLV" => Opcode::CLV,
"CLD" => Opcode::CLD,
"SED" => Opcode::SED,
"PHA" => Opcode::PHA,
"PLA" => Opcode::PLA,
"PHP" => Opcode::PHP,
"PLP" => Opcode::PLP,
"TAX" => Opcode::TAX,
"TAY" => Opcode::TAY,
"TXA" => Opcode::TXA,
"TYA" => Opcode::TYA,
"TSX" => Opcode::TSX,
"TXS" => Opcode::TXS,
"NOP" => Opcode::NOP,
"BRK" => Opcode::BRK,
_ => return None,
})
}
fn parse_operand(opcode: Opcode, operand: &str) -> Result<AddressingMode, String> {
// No operand → implied (or accumulator for some shifts)
if operand.is_empty() {
return Ok(AddressingMode::Implied);
}
// Explicit accumulator (e.g. `LSR A`)
if operand.eq_ignore_ascii_case("A") {
return Ok(AddressingMode::Accumulator);
}
// Immediate: `#...`
if let Some(rest) = operand.strip_prefix('#') {
let v = parse_u8(rest.trim())?;
return Ok(AddressingMode::Immediate(v));
}
// Indirect: `(addr)`, `(addr,X)`, `(addr),Y`
if operand.starts_with('(') {
// `(addr),Y` — outer ,Y after the closing paren
if let Some(inner) = operand
.strip_suffix(",Y")
.or_else(|| operand.strip_suffix(",y"))
{
let inside = inner
.strip_prefix('(')
.and_then(|s| s.strip_suffix(')'))
.ok_or_else(|| format!("malformed indirect operand `{operand}`"))?;
let addr = parse_u8(inside.trim())?;
return Ok(AddressingMode::IndirectY(addr));
}
// `(addr,X)` or `(addr)` — both end with `)`
if let Some(rest) = operand.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
if let Some(inside) = rest.strip_suffix(",X").or_else(|| rest.strip_suffix(",x")) {
let addr = parse_u8(inside.trim())?;
return Ok(AddressingMode::IndirectX(addr));
}
let addr = parse_u16(rest.trim())?;
return Ok(AddressingMode::Indirect(addr));
}
return Err(format!("malformed indirect operand `{operand}`"));
}
// `addr,X` / `addr,Y`
if let Some((base, reg)) = split_index(operand) {
let is_zp = looks_like_zero_page(base);
let (abs_mode, zp_mode) = match reg {
'X' | 'x' => (
AddressingMode::AbsoluteX as fn(u16) -> AddressingMode,
AddressingMode::ZeroPageX as fn(u8) -> AddressingMode,
),
'Y' | 'y' => (
AddressingMode::AbsoluteY as fn(u16) -> AddressingMode,
AddressingMode::ZeroPageY as fn(u8) -> AddressingMode,
),
_ => return Err(format!("unknown index register `{reg}`")),
};
if is_zp {
let v = parse_u8(base)?;
return Ok(zp_mode(v));
}
let v = parse_u16(base)?;
return Ok(abs_mode(v));
}
// Branch instructions take a label by name.
if is_branch(opcode) && is_valid_ident(operand) {
return Ok(AddressingMode::LabelRelative(operand.to_string()));
}
// Plain label: JMP foo, JSR foo
if matches!(opcode, Opcode::JMP | Opcode::JSR) && is_valid_ident(operand) {
return Ok(AddressingMode::Label(operand.to_string()));
}
// Plain address: ZeroPage if it fits, else Absolute
if looks_like_zero_page(operand) {
let v = parse_u8(operand)?;
return Ok(AddressingMode::ZeroPage(v));
}
let v = parse_u16(operand)?;
Ok(AddressingMode::Absolute(v))
}
fn split_index(operand: &str) -> Option<(&str, char)> {
let bytes = operand.as_bytes();
if bytes.len() >= 2 && bytes[bytes.len() - 2] == b',' {
let reg = bytes[bytes.len() - 1] as char;
if matches!(reg, 'X' | 'x' | 'Y' | 'y') {
return Some((operand[..operand.len() - 2].trim_end(), reg));
}
}
None
}
/// True if the operand is a numeric literal that fits in 8 bits.
fn looks_like_zero_page(operand: &str) -> bool {
parse_u16(operand).is_ok_and(|v| v <= 0xFF) && parse_u8(operand).is_ok()
}
fn parse_u8(s: &str) -> Result<u8, String> {
let v = parse_u16(s)?;
u8::try_from(v).map_err(|_| format!("value {v} out of u8 range"))
}
fn parse_u16(s: &str) -> Result<u16, String> {
let s = s.trim();
let (negative, s) = if let Some(rest) = s.strip_prefix('-') {
(true, rest)
} else {
(false, s)
};
let v: u16 = if let Some(hex) = s.strip_prefix('$') {
u16::from_str_radix(hex, 16).map_err(|e| format!("bad hex `{s}`: {e}"))?
} else if let Some(bin) = s.strip_prefix('%') {
u16::from_str_radix(bin, 2).map_err(|e| format!("bad binary `{s}`: {e}"))?
} else {
s.parse().map_err(|e| format!("bad number `{s}`: {e}"))?
};
Ok(if negative { v.wrapping_neg() } else { v })
}
fn is_branch(opcode: Opcode) -> bool {
matches!(
opcode,
Opcode::BEQ
| Opcode::BNE
| Opcode::BCC
| Opcode::BCS
| Opcode::BMI
| Opcode::BPL
| Opcode::BVC
| Opcode::BVS
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_lda_immediate_hex() {
let insts = parse_inline("LDA #$10").unwrap();
assert_eq!(insts.len(), 1);
assert_eq!(insts[0].opcode, Opcode::LDA);
assert_eq!(insts[0].mode, AddressingMode::Immediate(0x10));
}
#[test]
fn parse_lda_immediate_decimal() {
let insts = parse_inline("LDA #42").unwrap();
assert_eq!(insts[0].mode, AddressingMode::Immediate(42));
}
#[test]
fn parse_sta_zero_page() {
let insts = parse_inline("STA $10").unwrap();
assert_eq!(insts[0].mode, AddressingMode::ZeroPage(0x10));
}
#[test]
fn parse_sta_absolute() {
let insts = parse_inline("STA $2007").unwrap();
assert_eq!(insts[0].mode, AddressingMode::Absolute(0x2007));
}
#[test]
fn parse_lda_absolute_x() {
let insts = parse_inline("LDA $2000,X").unwrap();
assert_eq!(insts[0].mode, AddressingMode::AbsoluteX(0x2000));
}
#[test]
fn parse_lda_zero_page_x() {
let insts = parse_inline("LDA $10,X").unwrap();
assert_eq!(insts[0].mode, AddressingMode::ZeroPageX(0x10));
}
#[test]
fn parse_lda_indirect_y() {
let insts = parse_inline("LDA ($10),Y").unwrap();
assert_eq!(insts[0].mode, AddressingMode::IndirectY(0x10));
}
#[test]
fn parse_jmp_indirect() {
let insts = parse_inline("JMP ($FFFC)").unwrap();
assert_eq!(insts[0].mode, AddressingMode::Indirect(0xFFFC));
}
#[test]
fn parse_implied() {
let insts = parse_inline("CLC").unwrap();
assert_eq!(insts[0].mode, AddressingMode::Implied);
}
#[test]
fn parse_accumulator() {
let insts = parse_inline("LSR A").unwrap();
assert_eq!(insts[0].mode, AddressingMode::Accumulator);
}
#[test]
fn parse_label_and_branch() {
let insts = parse_inline(
r"
LDA #0
loop:
INC $10
BNE loop
RTS
",
)
.unwrap();
// LDA, label, INC, BNE, RTS
assert_eq!(insts.len(), 5);
assert_eq!(insts[1].mode, AddressingMode::Label("loop".into()));
assert_eq!(insts[3].mode, AddressingMode::LabelRelative("loop".into()));
}
#[test]
fn parse_jmp_label() {
let insts = parse_inline("JMP main").unwrap();
assert_eq!(insts[0].mode, AddressingMode::Label("main".into()));
}
#[test]
fn parse_comments_and_blanks() {
let insts = parse_inline(
r"
; this is a comment
LDA #$00 ; inline comment
",
)
.unwrap();
assert_eq!(insts.len(), 1);
}
#[test]
fn parse_unknown_mnemonic_errors() {
let err = parse_inline("WTF $10").unwrap_err();
assert!(err.contains("unknown mnemonic"));
}
#[test]
fn parse_binary_immediate() {
let insts = parse_inline("LDA #%00001111").unwrap();
assert_eq!(insts[0].mode, AddressingMode::Immediate(0x0F));
}
}

View file

@ -1,7 +1,9 @@
mod inline_parser;
mod opcodes;
#[cfg(test)]
mod tests;
pub use inline_parser::parse_inline;
pub use opcodes::{AddressingMode, Instruction, Opcode};
use std::collections::HashMap;

View file

@ -488,6 +488,20 @@ impl<'a> IrCodeGen<'a> {
self.emit_label(&pass_label);
}
}
IrOp::InlineAsm(body) => {
// Parse the asm body with the shared inline parser and
// splice the resulting instructions directly into our
// output stream. Parse errors are emitted as `BRK` so
// the ROM still links — codegen is too late to return
// user-facing diagnostics cleanly.
match crate::asm::parse_inline(body) {
Ok(parsed) => self.instructions.extend(parsed),
Err(msg) => {
eprintln!("inline asm error: {msg}");
self.emit(BRK, AM::Implied);
}
}
}
IrOp::SourceLoc(_) => {
// No code for source location markers
}

View file

@ -348,6 +348,13 @@ impl CodeGen {
self.emit_label(&pass_label);
}
}
Statement::InlineAsm(body, _) => match crate::asm::parse_inline(body) {
Ok(parsed) => self.instructions.extend(parsed),
Err(msg) => {
eprintln!("inline asm error: {msg}");
self.emit(BRK, AM::Implied);
}
},
}
}

View file

@ -326,6 +326,9 @@ impl LoweringContext {
let t = self.lower_expr(cond);
self.emit(IrOp::DebugAssert(t));
}
Statement::InlineAsm(body, _) => {
self.emit(IrOp::InlineAsm(body.clone()));
}
}
}

View file

@ -137,6 +137,8 @@ pub enum IrOp {
/// Debug: runtime assertion — if `cond` is zero, halt with debug marker.
/// Stripped in release mode by the codegen.
DebugAssert(IrTemp),
/// Raw 6502 assembly text; parsed and emitted by the codegen.
InlineAsm(String),
// Source mapping
SourceLoc(Span),

View file

@ -11,6 +11,10 @@ pub struct Lexer<'a> {
pos: usize,
file_id: u16,
diagnostics: Vec<Diagnostic>,
/// When true, the next `{` that would normally be lexed as
/// `LBrace` triggers raw-text capture until the matching `}`. Set
/// right after emitting `KwAsm`.
asm_body_pending: bool,
}
impl<'a> Lexer<'a> {
@ -20,6 +24,7 @@ impl<'a> Lexer<'a> {
pos: 0,
file_id,
diagnostics: Vec::new(),
asm_body_pending: false,
}
}
@ -88,6 +93,19 @@ impl<'a> Lexer<'a> {
let start = self.pos;
let ch = self.advance();
// If we're right after `asm` and see `{`, consume the entire
// body until the matching `}` as a single `AsmBody` token.
if self.asm_body_pending && ch == b'{' {
self.asm_body_pending = false;
return Some(self.capture_asm_body(start));
}
// Any non-`{` token clears the pending flag so we don't get
// confused by things like `asm ;` (which is a syntax error
// the parser will complain about).
if self.asm_body_pending {
self.asm_body_pending = false;
}
match ch {
b'(' => Some(self.make_token(TokenKind::LParen, start)),
b')' => Some(self.make_token(TokenKind::RParen, start)),
@ -440,7 +458,10 @@ impl<'a> Lexer<'a> {
"load_background" => TokenKind::KwLoadBackground,
"set_palette" => TokenKind::KwSetPalette,
"scroll" => TokenKind::KwScroll,
"asm" => TokenKind::KwAsm,
"asm" => {
self.asm_body_pending = true;
TokenKind::KwAsm
}
"raw" => TokenKind::KwRaw,
"bank" => TokenKind::KwBank,
"loop" => TokenKind::KwLoop,
@ -460,6 +481,33 @@ impl<'a> Lexer<'a> {
}
}
impl Lexer<'_> {
/// Capture everything between the `{` we just consumed and the
/// matching `}` as an `AsmBody` token. 6502 assembly has no
/// `{`/`}` in its syntax, so we can simply scan for the next `}`.
fn capture_asm_body(&mut self, start: usize) -> Token {
let body_start = self.pos;
while self.pos < self.source.len() && self.source[self.pos] != b'}' {
self.pos += 1;
}
let body = String::from_utf8_lossy(&self.source[body_start..self.pos]).into_owned();
if self.pos < self.source.len() {
// Consume the closing brace
self.pos += 1;
} else {
self.diagnostics.push(Diagnostic::error(
ErrorCode::E0101,
"unterminated `asm {` block",
self.span(start, self.pos),
));
}
Token {
kind: TokenKind::AsmBody(body),
span: self.span(start, self.pos),
}
}
}
/// Convenience function for lexing a source string.
pub fn lex(source: &str) -> (Vec<Token>, Vec<Diagnostic>) {
Lexer::new(source, 0).lex()

View file

@ -125,6 +125,11 @@ pub enum TokenKind {
ShiftLeftAssign,
ShiftRightAssign,
// Raw text from `asm { ... }` or `raw asm { ... }` blocks.
// The body is captured verbatim (including newlines) so the
// inline-asm parser can tokenize its own mnemonics.
AsmBody(String),
// Special
Eof,
}
@ -217,6 +222,7 @@ impl std::fmt::Display for TokenKind {
Self::CaretAssign => write!(f, "^="),
Self::ShiftLeftAssign => write!(f, "<<="),
Self::ShiftRightAssign => write!(f, ">>="),
Self::AsmBody(_) => write!(f, "<asm body>"),
Self::Eof => write!(f, "EOF"),
}
}

View file

@ -391,7 +391,11 @@ fn collect_source_temps(op: &IrOp, used: &mut HashSet<IrTemp>) {
IrOp::DebugAssert(cond) => {
used.insert(*cond);
}
IrOp::ReadInput(_, _) | IrOp::WaitFrame | IrOp::Transition(_) | IrOp::SourceLoc(_) => {}
IrOp::ReadInput(_, _)
| IrOp::WaitFrame
| IrOp::Transition(_)
| IrOp::InlineAsm(_)
| IrOp::SourceLoc(_) => {}
}
}
@ -427,6 +431,7 @@ fn op_dest(op: &IrOp) -> Option<IrTemp> {
| IrOp::Scroll(_, _)
| IrOp::DebugLog(_)
| IrOp::DebugAssert(_)
| IrOp::InlineAsm(_)
| IrOp::SourceLoc(_) => None,
}
}

View file

@ -245,6 +245,9 @@ pub enum Statement {
/// debug.assert(cond) — runtime check, halts on failure.
/// Stripped in release mode.
DebugAssert(Expr, Span),
/// Inline 6502 assembly captured verbatim. The body is parsed by
/// the codegen stage using `asm::parse_inline`.
InlineAsm(String, Span),
}
impl Statement {
@ -266,7 +269,8 @@ impl Statement {
| Self::SetPalette(_, s)
| Self::Scroll(_, _, s)
| Self::DebugLog(_, s)
| Self::DebugAssert(_, s) => *s,
| Self::DebugAssert(_, s)
| Self::InlineAsm(_, s) => *s,
}
}
}

View file

@ -820,6 +820,22 @@ impl Parser {
Ok(Statement::Scroll(x, y, span))
}
TokenKind::KwDebug => self.parse_debug_statement(),
TokenKind::KwAsm => {
let span = self.current_span();
self.advance(); // KwAsm
// The lexer emits an AsmBody token after `asm` when it
// sees the opening brace. Consume it here.
if let TokenKind::AsmBody(body) = self.peek().clone() {
self.advance();
Ok(Statement::InlineAsm(body, span))
} else {
Err(Diagnostic::error(
ErrorCode::E0201,
"expected `{` after `asm`",
self.current_span(),
))
}
}
TokenKind::Ident(_) => self.parse_assign_or_call(),
_ => Err(Diagnostic::error(
ErrorCode::E0201,