NanoAssembler.kt
src/jvmMain/kotlin/afield/lang/nano/NanoAssembler.kt · 405 lines
package afield.lang.nano
Turns Nano assembly source text into a bytecode ByteArray: one pass over the lines emitting
bytes, then a patch pass that fills in the labels the first pass left as placeholders.
NanoVM.kt has the machine and the opcode map; this is the text side of the same instruction
set. The listing below is the whole language: one directive, no macros, and operands that are
registers, integer literals or labels.
Syntax
; line comment (rest of line)
label: ; defines a label at the current address
; Data directive – place raw bytes into the program stream
.data 0x1A 0x2B 42 ...
; Instructions
nop
hlt
ret
inc r0
dec r1
not r2
neg r3
clr r0
out r1 ; print reg value
rng r2 ; fill with random byte
mov r0, r1 ; r0 = r1
add r0, r1 ; r0 += r1
sub r0, r1
mul r0, r1
and r0, r1
or r0, r1
xor r0, r1
cmp r0, r1 ; flags = r0 <=> r1 (no reg change)
ldr r0, r1 ; r0 = the RAM byte at address r1 (load indirect)
str r0, r1 ; the RAM byte at address r0 = r1 (store indirect)
div r0, r1 ; r0 = r0 / r1 (integer; r1==0 → r0=0xFF)
ldi r0, 42 ; r0 = 42 (immediate, 0–255)
ldi r0, 0xFF
lda r0, 5 ; r0 = RAM[5] (absolute load)
sta r0, 5 ; RAM[5] = r0 (absolute store)
addi r0, -3 ; r0 += -3 (signed immediate, -128..127)
ldrr r0, -2 ; r0 = RAM[PC + (-2)] (PC-relative load)
strr r0, 4 ; RAM[PC + 4] = r0 (PC-relative store)
shl r0, 2 ; r0 <<= 2
shr r0, 1 ; r0 >>= 1
jmp target ; unconditional (relative, or label)
jz target
jnz target
jgt target
jlt target
jge target
jle target
jc target
jnc target
call target ; relative call
jmpa target ; absolute jump (0–255)
calla target ; absolute call
Labels can appear on their own line (start:) or before a mnemonic on the same line
(start: ldi r0, 0). A label used by a relative branch resolves to the
signed 8-bit offset from the byte following the branch (PC+2 for the two-byte forms like
jmp), and an offset that will not fit in −128..+127 is an error on that line. jmpa and
calla take the absolute address instead, so there the error is an address past 255.
Results and errors
Every bad register, number, mnemonic, duplicate label and out-of-range branch goes into a list
that assembly keeps filling, so one run reports all of them with the line each came from rather
than stopping at the first. assemble hands back AssemblyResult, which is either the bytes
or that list. assembleWithSourceMap hands back the same bytes plus a program-counter-to-line
array, which is what NanoInterface.kt and NanoVisualization.kt use to mark the source line a
running program is on. The throwing wrapper a live-coding box calls is the top-level
nanoAssemble in NanoInterface.kt.
class NanoAssembler
The assembler. It keeps no state between calls, so one instance can assemble any number of programs.
class NanoAssembler {
data class AssemblyError(val line: Int, val message: String)
One assembly failure: the 1-based source line it was found on, and what was wrong.
sealed class AssemblyResult
The bytecode, or every error the source produced.
data class Ok(val bytes: ByteArray) : AssemblyResult()
data class Err(val errors: List<AssemblyError>) : AssemblyResult()
data class AssemblyDebugInfo
Bytecode plus the source map: pcToLine is one entry per emitted byte, holding the 1-based
line that byte came from, so a program counter indexes straight into it.
data class AssemblyDebugInfo(
val bytes: ByteArray,
val pcToLine: IntArray
)
sealed class AssemblyDebugResult
AssemblyDebugInfo, or every error the source produced.
data class Ok(val info: AssemblyDebugInfo) : AssemblyDebugResult()
data class Err(val errors: List<AssemblyError>) : AssemblyDebugResult()
fun assemble(source: String): AssemblyResult
Assemble source into bytecode, or into the list of everything wrong with it.
fun assemble(source: String): AssemblyResult {
return when (val result = assembleInternal(source, captureSourceMap = false)) {
is InternalAssemblyResult.Ok -> AssemblyResult.Ok(result.bytes)
is InternalAssemblyResult.Err -> AssemblyResult.Err(result.errors)
}
}
fun assembleWithSourceMap(source: String): AssemblyDebugResult
Assemble source and keep the byte-to-line map, for a caller that has to point at the line
a program counter is sitting on.
body — 235 lines, from line 127
fun assembleWithSourceMap(source: String): AssemblyDebugResult {
return when (val result = assembleInternal(source, captureSourceMap = true)) {
is InternalAssemblyResult.Ok ->
AssemblyDebugResult.Ok(
AssemblyDebugInfo(
bytes = result.bytes,
pcToLine = result.pcToLine ?: IntArray(0)
)
)
is InternalAssemblyResult.Err -> AssemblyDebugResult.Err(result.errors)
}
}
private sealed class InternalAssemblyResult {
data class Ok(val bytes: ByteArray, val pcToLine: IntArray?) : InternalAssemblyResult()
data class Err(val errors: List<AssemblyError>) : InternalAssemblyResult()
}
private fun assembleInternal(source: String, captureSourceMap: Boolean): InternalAssemblyResult {
val errors = mutableListOf<AssemblyError>()
val bytes = mutableListOf<Int>() // output bytes (0–255)
val pcToLine = if (captureSourceMap) mutableListOf<Int>() else null
val labels = mutableMapOf<String, Int>() // label name → address
// Patches: list of (address-of-offset-byte, instruction-end-pc, label, lineNo, isAbsolute)
data class Patch(val offsetAddr: Int, val instrEndPc: Int, val label: String, val lineNo: Int, val absolute: Boolean)
val patches = mutableListOf<Patch>()
val lines = source.lines()
for ((lineIdx, rawLine) in lines.withIndex()) {
val lineNo = lineIdx + 1
// strip comment
val noComment = rawLine.substringBefore(';').trim()
if (noComment.isBlank()) continue
// parse optional leading label "name:"
var rest = noComment
val labelMatch = Regex("""^([A-Za-z_]\w*)\s*:\s*""").find(rest)
if (labelMatch != null) {
val name = labelMatch.groupValues[1]
if (labels.containsKey(name)) {
errors += AssemblyError(lineNo, "Duplicate label '$name'")
} else {
labels[name] = bytes.size
}
rest = rest.removePrefix(labelMatch.value).trim()
if (rest.isBlank()) continue
}
val tokens = tokenize(rest)
if (tokens.isEmpty()) continue
val mnemonic = tokens[0].lowercase()
fun arg(i: Int): String? = tokens.getOrNull(i + 1)?.trimEnd(',')
fun parseReg(s: String?, argName: String = "register"): Int {
if (s == null) { errors += AssemblyError(lineNo, "Missing $argName"); return 0 }
val clean = s.lowercase().trimEnd(',')
return when (clean) {
"r0" -> 0; "r1" -> 1; "r2" -> 2; "r3" -> 3
else -> { errors += AssemblyError(lineNo, "Unknown register '$s'"); 0 }
}
}
fun parseImm(s: String?, signed: Boolean = false, argName: String = "immediate"): Int {
if (s == null) { errors += AssemblyError(lineNo, "Missing $argName"); return 0 }
val n = parseNumber(s.trimEnd(','))
if (n == null) { errors += AssemblyError(lineNo, "Bad number '$s'"); return 0 }
if (signed) {
if (n < -128 || n > 127) errors += AssemblyError(lineNo, "Signed immediate out of range: $n")
return n and 0xFF
} else {
if (n < 0 || n > 255) errors += AssemblyError(lineNo, "Immediate out of range: $n")
return n and 0xFF
}
}
fun emitByte(b: Int) {
bytes.add(b and 0xFF)
pcToLine?.add(lineNo)
}
fun emitBranchRel(op: Int, labelOrNum: String?) {
if (labelOrNum == null) { errors += AssemblyError(lineNo, "Missing branch target"); return }
emitByte(op)
val instrEnd = bytes.size + 1 // address after the offset byte
val n = parseNumber(labelOrNum)
if (n != null) {
// numeric literal: treat as absolute target, compute relative
val rel = n - instrEnd
if (rel < -128 || rel > 127) errors += AssemblyError(lineNo, "Branch offset out of range: $rel")
emitByte(rel and 0xFF)
} else {
// label reference: patch later
patches += Patch(bytes.size, instrEnd, labelOrNum, lineNo, false)
emitByte(0) // placeholder
}
}
fun emitAbsTarget(op: Int, labelOrNum: String?) {
if (labelOrNum == null) { errors += AssemblyError(lineNo, "Missing target"); return }
emitByte(op)
val n = parseNumber(labelOrNum)
if (n != null) {
if (n < 0 || n > 255) errors += AssemblyError(lineNo, "Absolute address out of range: $n")
emitByte(n and 0xFF)
} else {
patches += Patch(bytes.size, bytes.size + 1, labelOrNum, lineNo, true)
emitByte(0)
}
}
when (mnemonic) {
"nop" -> emitByte(Opcode.NOP)
"hlt" -> emitByte(Opcode.HLT)
"ret" -> emitByte(Opcode.RET)
"inc" -> emitByte(Opcode.oneReg(Opcode.INC_BASE, parseReg(arg(0))))
"dec" -> emitByte(Opcode.oneReg(Opcode.DEC_BASE, parseReg(arg(0))))
"not" -> emitByte(Opcode.oneReg(Opcode.NOT_BASE, parseReg(arg(0))))
"neg" -> emitByte(Opcode.oneReg(Opcode.NEG_BASE, parseReg(arg(0))))
"clr" -> emitByte(Opcode.oneReg(Opcode.CLR_BASE, parseReg(arg(0))))
"out" -> emitByte(Opcode.oneReg(Opcode.OUT_BASE, parseReg(arg(0))))
"rng" -> emitByte(Opcode.oneReg(Opcode.RNG_BASE, parseReg(arg(0))))
"mov" -> emitByte(Opcode.twoReg(Opcode.MOV_BASE, parseReg(arg(0)), parseReg(arg(1))))
"add" -> emitByte(Opcode.twoReg(Opcode.ADD_BASE, parseReg(arg(0)), parseReg(arg(1))))
"sub" -> emitByte(Opcode.twoReg(Opcode.SUB_BASE, parseReg(arg(0)), parseReg(arg(1))))
"mul" -> emitByte(Opcode.twoReg(Opcode.MUL_BASE, parseReg(arg(0)), parseReg(arg(1))))
"and" -> emitByte(Opcode.twoReg(Opcode.AND_BASE, parseReg(arg(0)), parseReg(arg(1))))
"or" -> emitByte(Opcode.twoReg(Opcode.OR_BASE, parseReg(arg(0)), parseReg(arg(1))))
"xor" -> emitByte(Opcode.twoReg(Opcode.XOR_BASE, parseReg(arg(0)), parseReg(arg(1))))
"cmp" -> emitByte(Opcode.twoReg(Opcode.CMP_BASE, parseReg(arg(0)), parseReg(arg(1))))
"ldr" -> emitByte(Opcode.twoReg(Opcode.LDR_BASE, parseReg(arg(0)), parseReg(arg(1))))
"str" -> emitByte(Opcode.twoReg(Opcode.STR_BASE, parseReg(arg(0)), parseReg(arg(1))))
"div" -> emitByte(Opcode.twoReg(Opcode.DIV_BASE, parseReg(arg(0)), parseReg(arg(1))))
"ldi" -> {
val r = parseReg(arg(0))
val imm = parseImm(arg(1))
emitByte(Opcode.oneReg(Opcode.LDI_BASE, r)); emitByte(imm)
}
"lda" -> {
val r = parseReg(arg(0))
val addr = parseImm(arg(1), argName = "address")
emitByte(Opcode.oneReg(Opcode.LDA_BASE, r)); emitByte(addr)
}
"sta" -> {
val r = parseReg(arg(0))
val addr = parseImm(arg(1), argName = "address")
emitByte(Opcode.oneReg(Opcode.STA_BASE, r)); emitByte(addr)
}
"addi" -> {
val r = parseReg(arg(0))
val imm = parseImm(arg(1), signed = true)
emitByte(Opcode.oneReg(Opcode.ADDI_BASE, r)); emitByte(imm)
}
"ldrr" -> {
val r = parseReg(arg(0))
val rel = parseImm(arg(1), signed = true, argName = "relative offset")
emitByte(Opcode.oneReg(Opcode.LDRR_BASE, r)); emitByte(rel)
}
"strr" -> {
val r = parseReg(arg(0))
val rel = parseImm(arg(1), signed = true, argName = "relative offset")
emitByte(Opcode.oneReg(Opcode.STRR_BASE, r)); emitByte(rel)
}
"shl" -> {
val r = parseReg(arg(0))
val imm = parseImm(arg(1), argName = "shift count")
emitByte(Opcode.oneReg(Opcode.SHL_BASE, r)); emitByte(imm)
}
"shr" -> {
val r = parseReg(arg(0))
val imm = parseImm(arg(1), argName = "shift count")
emitByte(Opcode.oneReg(Opcode.SHR_BASE, r)); emitByte(imm)
}
"jmp" -> emitBranchRel(Opcode.JMP, arg(0))
"jz" -> emitBranchRel(Opcode.JZ, arg(0))
"jnz" -> emitBranchRel(Opcode.JNZ, arg(0))
"jgt" -> emitBranchRel(Opcode.JGT, arg(0))
"jlt" -> emitBranchRel(Opcode.JLT, arg(0))
"jge" -> emitBranchRel(Opcode.JGE, arg(0))
"jle" -> emitBranchRel(Opcode.JLE, arg(0))
"jc" -> emitBranchRel(Opcode.JC, arg(0))
"jnc" -> emitBranchRel(Opcode.JNC, arg(0))
"call" -> emitBranchRel(Opcode.CALL, arg(0))
"jmpa" -> emitAbsTarget(Opcode.JMPA, arg(0))
"calla"-> emitAbsTarget(Opcode.CALLA, arg(0))
".data" -> {
// emit raw bytes
for (i in 1 until tokens.size) {
val n = parseNumber(tokens[i])
if (n == null) {
errors += AssemblyError(lineNo, "Bad .data value '${tokens[i]}'")
} else {
emitByte(n and 0xFF)
}
}
}
else -> errors += AssemblyError(lineNo, "Unknown mnemonic '$mnemonic'")
}
}
// Resolve label patches
for (p in patches) {
val target = labels[p.label]
if (target == null) {
errors += AssemblyError(p.lineNo, "Undefined label '${p.label}'")
continue
}
if (p.absolute) {
if (target > 255) errors += AssemblyError(p.lineNo, "Label '${p.label}' address too large for absolute branch")
bytes[p.offsetAddr] = target and 0xFF
} else {
val rel = target - p.instrEndPc
if (rel < -128 || rel > 127) {
errors += AssemblyError(p.lineNo, "Branch to '${p.label}' out of range (offset $rel)")
}
bytes[p.offsetAddr] = rel and 0xFF
}
}
return if (errors.isEmpty()) {
InternalAssemblyResult.Ok(
bytes = ByteArray(bytes.size) { bytes[it].toByte() },
pcToLine = pcToLine?.let { IntArray(it.size) { index -> it[index] } }
)
} else {
InternalAssemblyResult.Err(errors)
}
}
Utilities
private fun tokenize(s: String): List<String> {
val result = mutableListOf<String>()
val buf = StringBuilder()
for (c in s) {
if (c == ',') { if (buf.isNotEmpty()) result += buf.toString().also { buf.clear() } }
else if (c.isWhitespace()) { if (buf.isNotEmpty()) result += buf.toString().also { buf.clear() } }
else buf.append(c)
}
if (buf.isNotEmpty()) result += buf.toString()
return result
}
fun parseNumber(s: String): Int?
Parse an integer literal: decimal, 0x hex, or 0b binary. Returns null on failure.
fun parseNumber(s: String): Int? {
val clean = s.trim().lowercase()
return try {
when {
clean.startsWith("0x") -> clean.removePrefix("0x").toInt(16)
clean.startsWith("0b") -> clean.removePrefix("0b").toInt(2)
clean.startsWith("-0x") -> -clean.removePrefix("-0x").toInt(16)
clean.startsWith("-0b") -> -clean.removePrefix("-0b").toInt(2)
else -> clean.toInt()
}
} catch (_: NumberFormatException) { null }
}
fun extractLabels(source: String): List<String>
Returns all label names defined in source.
Useful for completion.
fun extractLabels(source: String): List<String> {
val labels = mutableListOf<String>()
for (rawLine in source.lines()) {
val noComment = rawLine.substringBefore(';').trim()
val m = Regex("""^([A-Za-z_]\w*)\s*:""").find(noComment)
if (m != null) labels += m.groupValues[1]
}
return labels
}
}