clean_asm

Strip ELF metadata from assembly output, keeping only labels and instructions.

#!/usr/bin/env python3
"""Strip ELF metadata from assembly, keeping only labels and instructions."""

import glob
import re
import sys
from pathlib import Path

def _detect_comment_prefix(lines):
    for line in lines:
        if line.lstrip().startswith("//"):
            return "//"
    return "#"


def _strip_comment(line, comment="//"):
    if comment in line:
        return line[: line.index(comment)]
    return line


def _is_label_line(line, comment="#"):
    text = _strip_comment(line, comment).strip()
    return text.endswith(":")


def _defined_label_name(line, comment="#"):
    text = _strip_comment(line, comment).strip()
    if not text.endswith(":"):
        return None
    return text[:-1].strip()


def _collect_referenced_labels(lines, comment="#"):
    refs = set()
    num_refs = set()
    for line in lines:
        text = _strip_comment(line, comment).strip()
        if not text or text.endswith(":") or text.startswith("."):
            continue
        parts = text.split(None, 1)
        if len(parts) < 2:
            continue
        operands = parts[1]
        # Numeric local label refs like 1f / 2b.
        for m in re.finditer(r"\b(\d+)[fb]\b", operands):
            num_refs.add(m.group(1))
        # Symbol refs in operands, e.g. .LBB0_1(%rip), foo, .Ltmp42
        for m in re.finditer(r"(?<![%$])([._A-Za-z][._A-Za-z0-9$]*)", operands):
            refs.add(m.group(1))
    return refs, num_refs


_DATA_DIRECTIVES = {
    ".byte", ".2byte", ".4byte", ".8byte",
    ".word", ".hword", ".short", ".long", ".quad", ".octa",
    ".ascii", ".asciz", ".string",
    ".float", ".double",
    ".zero", ".skip",
}

# Assembler mode directives that affect instruction encoding/parsing.
_MODE_DIRECTIVES = {
    ".set",
    ".option",
}


def _clean_lines(lines):
    comment = _detect_comment_prefix(lines)
    refs, num_refs = _collect_referenced_labels(lines, comment)
    out = []
    for line in lines:
        stripped = line.strip()
        # Skip empty lines between blocks (we'll add our own)
        if not stripped:
            if out and out[-1] != "\n":
                out.append("\n")
            continue
        # Skip directives (lines starting with .), but keep data directives.
        if stripped.startswith(".") and not _is_label_line(line, comment):
            directive = stripped.split()[0].rstrip(",")
            if directive not in _DATA_DIRECTIVES and directive not in _MODE_DIRECTIVES:
                continue
        if _is_label_line(line, comment):
            label = _defined_label_name(line, comment)
            if label is None:
                continue
            # Keep global/non-local labels.
            if not label.startswith("."):
                out.append(line)
                continue
            # Keep numeric local labels only if referenced (e.g. 1: with 1f/1b).
            if label.isdigit():
                if label in num_refs:
                    out.append(line)
                continue
            # Keep local assembler labels only when used by an instruction.
            if label in refs:
                out.append(line)
            continue
        # Skip inline comments that are the whole line
        if stripped.startswith(comment):
            continue
        # Strip trailing comments
        if comment in line:
            line = line[: line.index(comment)].rstrip() + "\n"
        out.append(line)

    # Remove trailing blank lines
    while out and out[-1] == "\n":
        out.pop()

    return "".join(out) + "\n"


def clean(path, dry_run=False):
    with open(path, encoding="utf-8") as f:
        original = f.read()
    cleaned = _clean_lines(original.splitlines(keepends=True))
    if dry_run:
        if cleaned != original:
            print(f"Would update {path}")
        else:
            print(f"No change {path}")
        return
    with open(path, "w", encoding="utf-8") as f:
        f.write(cleaned)


def clean_stream(dry_run=False):
    original = sys.stdin.read()
    cleaned = _clean_lines(original.splitlines(keepends=True))
    if dry_run:
        if cleaned != original:
            print("Would update stdin")
        else:
            print("No change stdin")
        return
    sys.stdout.write(cleaned)


def _iter_input_files(patterns):
    seen = set()
    for pattern in patterns:
        if pattern == "-":
            yield "-"
            continue
        matches = glob.glob(pattern)
        if not matches:
            matches = [pattern]
        for entry in matches:
            p = Path(entry)
            # Only clean files ending with .s; skip everything else.
            if p.suffix != ".s" or not p.is_file():
                print(f"Skipping {entry}: not a .s file", file=sys.stderr)
                continue
            resolved = str(p.resolve())
            if resolved in seen:
                continue
            seen.add(resolved)
            yield str(p)


if __name__ == "__main__":
    args = sys.argv[1:]
    dry_run = False
    if "--dry-run" in args:
        dry_run = True
        args = [a for a in args if a != "--dry-run"]
    if len(args) < 1:
        print(
            f"Usage: {sys.argv[0]} [--dry-run] <file.s|glob> [more files/globs...]",
            file=sys.stderr,
        )
        sys.exit(1)
    had_inputs = False
    for input_path in _iter_input_files(args):
        had_inputs = True
        if input_path == "-":
            clean_stream(dry_run=dry_run)
        else:
            clean(input_path, dry_run=dry_run)
    if not had_inputs:
        print(
            f"Usage: {sys.argv[0]} [--dry-run] <file.s|glob|- > [more files/globs/-...]",
            file=sys.stderr,
        )
        sys.exit(1)
download clean_asm