From adce930a178fc0c58592ccd8e11fa678d28e39f0 Mon Sep 17 00:00:00 2001 From: maddiebaka Date: Sun, 16 Aug 2026 06:52:30 -0400 Subject: [PATCH] Add z80fmt formatting tool --- z80fmt.py | 241 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100755 z80fmt.py diff --git a/z80fmt.py b/z80fmt.py new file mode 100755 index 0000000..5487d35 --- /dev/null +++ b/z80fmt.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +""" +z80fmt - column formatter for Z80 assembly source (z88dk z80asm flavour). + +Splits each line into label | mnemonic | operands | ;comment and pads the +first three into fixed-width columns with spaces. String and character +literals are parsed properly, so semicolons inside DEFM data are never +mistaken for comments, and EX AF,AF' does not open a quote. + +Usage: + z80fmt.py file.z80 [more.z80 ...] # write formatted source to stdout + z80fmt.py -i file.z80 # rewrite in place + z80fmt.py --check *.z80 # exit 1 if any file needs formatting + cat file.z80 | z80fmt.py # filter mode +""" + +import argparse +import sys + +MNEMONICS = { + "adc", "add", "and", "bit", "call", "ccf", "cp", "cpd", "cpdr", "cpi", + "cpir", "cpl", "daa", "dec", "di", "djnz", "ei", "ex", "exx", "halt", + "im", "in", "inc", "ind", "indr", "ini", "inir", "jp", "jr", "ld", + "ldd", "lddr", "ldi", "ldir", "neg", "nop", "or", "otdr", "otir", "out", + "outd", "outi", "pop", "push", "res", "ret", "reti", "retn", "rl", + "rla", "rlc", "rlca", "rld", "rr", "rra", "rrc", "rrca", "rrd", "rst", + "sbc", "scf", "set", "sla", "sll", "sli", "sra", "srl", "sub", "xor", + # Z180 extras + "in0", "out0", "mlt", "tst", "tstio", "slp", "otim", "otdm", "otimr", + "otdmr", +} + +DIRECTIVES = { + "org", "defb", "defw", "defm", "defs", "defc", "equ", "include", + "binary", "incbin", "public", "extern", "global", "module", "section", + "align", "defvars", "if", "ifdef", "ifndef", "else", "elif", "endif", + "end", "db", "dw", "ds", "dm", "dc", "byte", "word", "ascii", "asciz", + "asciiz", "macro", "endm", "rept", "endr", "local", "title", "line", + "defgroup", "defarray", +} + + +def split_comment(line): + """Return (code, comment). Quote-aware so ';' inside literals is kept.""" + quote = None + i, n = 0, len(line) + while i < n: + c = line[i] + if quote: + if c == "\\" and quote == '"' and i + 1 < n: + i += 2 + continue + if c == quote: + quote = None + elif c == '"': + quote = '"' + elif c == "'": + # A quote right after an identifier char is a register prime + # (EX AF,AF' / HL'), not the start of a character literal. + prev = line[i - 1] if i else "" + if not (prev.isalnum() or prev == "_"): + quote = "'" + elif c == ";": + return line[:i], line[i:] + i += 1 + return line, "" + + +def is_opcode(token): + base = token.rstrip(":").lstrip(".").lower() + return base in MNEMONICS or base in DIRECTIVES + + +def split_code(code): + """Return (label, mnemonic, operands) from the comment-stripped code.""" + if not code.strip(): + return "", "", "" + + label = "" + if code[0] not in " \t": + first = code.split(None, 1)[0] + rest = code.split(None, 1)[1] if len(code.split(None, 1)) > 1 else "" + if first.endswith(":") or not is_opcode(first): + label, code = first, rest + + rest = code.strip() + if not rest: + return label, "", "" + parts = rest.split(None, 1) + mnemonic = parts[0] + operands = parts[1].strip() if len(parts) > 1 else "" + return label, mnemonic, operands + + +def normalize_commas(operands, sep): + """Rewrite comma spacing outside of literals.""" + out, quote, i, n = [], None, 0, len(operands) + while i < n: + c = operands[i] + if quote: + out.append(c) + if c == "\\" and quote == '"' and i + 1 < n: + out.append(operands[i + 1]) + i += 2 + continue + if c == quote: + quote = None + elif c == '"': + quote = '"' + out.append(c) + elif c == "'": + prev = operands[i - 1] if i else "" + if not (prev.isalnum() or prev == "_"): + quote = "'" + out.append(c) + elif c == ",": + while out and out[-1] == " ": + out.pop() + out.append("," + sep) + j = i + 1 + while j < n and operands[j] in " \t": + j += 1 + i = j + continue + else: + out.append(c) + i += 1 + return "".join(out).rstrip() + + +def pad(text, width, gap=1): + return text.ljust(width) if len(text) < width else text + " " * gap + + +def format_line(line, opt): + raw = line.rstrip("\n").rstrip() + if not raw.strip(): + return [""] + + stripped = raw.lstrip() + + # Preprocessor lines pass through untouched. + if stripped.startswith("#"): + return [stripped] + + # Whole-line comments: keep column-0 banners at column 0, indent the + # rest to the mnemonic column. + if stripped.startswith(";"): + if raw[0] in " \t": + return [" " * opt.label_width + stripped] + return [stripped] + + code, comment = split_comment(raw) + label, mnemonic, operands = split_code(code) + + if opt.case == "upper": + mnemonic = mnemonic.upper() + elif opt.case == "lower": + mnemonic = mnemonic.lower() + + if opt.comma_space and operands: + operands = normalize_commas(operands, " ") + elif operands: + operands = normalize_commas(operands, "") + + lines = [] + has_code = bool(mnemonic or operands or comment) + + if label and has_code and opt.wrap_long_labels and len(label) >= opt.label_width: + lines.append(label) + label = "" + + if not has_code: + lines.append(label) + return lines + + out = pad(label, opt.label_width) if label else " " * opt.label_width + out += pad(mnemonic, opt.mnemonic_width) if mnemonic else " " * opt.mnemonic_width + + if comment: + out += pad(operands, opt.operand_width) if operands else " " * opt.operand_width + out += comment + else: + out += operands + + lines.append(out.rstrip()) + return lines + + +def format_text(text, opt): + out = [] + for line in text.splitlines(): + out.extend(format_line(line, opt)) + return "\n".join(out) + ("\n" if text.endswith("\n") or text else "") + + +def main(): + p = argparse.ArgumentParser(description="Format Z80 assembly into columns.") + p.add_argument("files", nargs="*", help="source files (default: stdin)") + p.add_argument("-i", "--in-place", action="store_true") + p.add_argument("--check", action="store_true", + help="exit 1 if any file would change; write nothing") + p.add_argument("--label-width", type=int, default=16) + p.add_argument("--mnemonic-width", type=int, default=8) + p.add_argument("--operand-width", type=int, default=24) + p.add_argument("--comma-space", action="store_true", + help="put one space after operand commas (default: none)") + p.add_argument("--case", choices=["keep", "upper", "lower"], default="keep", + help="case of mnemonics only; symbols are left alone") + p.add_argument("--wrap-long-labels", action="store_true", + help="put over-long labels on their own line") + opt = p.parse_args() + + if not opt.files: + sys.stdout.write(format_text(sys.stdin.read(), opt)) + return 0 + + changed = False + for path in opt.files: + with open(path, "r", encoding="utf-8", errors="surrogateescape") as fh: + original = fh.read() + formatted = format_text(original, opt) + if formatted != original: + changed = True + if opt.check: + print(f"would reformat: {path}", file=sys.stderr) + if opt.check: + continue + if opt.in_place: + if formatted != original: + with open(path, "w", encoding="utf-8", + errors="surrogateescape") as fh: + fh.write(formatted) + else: + sys.stdout.write(formatted) + + return 1 if (opt.check and changed) else 0 + + +if __name__ == "__main__": + sys.exit(main())