AUIS revival

Artifact [3d5234325e]
Login

Artifact 3d5234325e9b73599717ab4c732c83a05e2735e97f50e0ca621071db921a2b12:


#!/usr/bin/env python3
"""Convert AUIS K&R function definitions to ANSI C, safely.

Replaces the retired regex-inference approach (revival/tools/modernize)
with lookup against the class preprocessor's own signature knowledge.
See porting-assessment.md §14 for the plan this implements (milestone M3).

Pipeline, per .c file:

  1. fix-static-methods, fix-missing-static-decl (siblings in this dir)
  2. Class methods and class procedures (NAME__Method): the definition
     header is rewritten from the signature database built by
     `ansify --build-db` out of classpp's `-D` .desc output. Types come
     from the .ch — lookup, never inference. The implicit first
     parameter is supplied by convention: `struct CLASS *` for methods,
     `struct classheader *` for class procedures.
  3. File-local helpers: converted from their own K&R declaration
     block, which is authoritative for file-scope functions. The parser
     is strict: anything it cannot parse exactly is left untouched and
     reported, never guessed at. (cproto was evaluated for this job and
     rejected: its internal parser cannot read modern macOS SDK
     headers.)
  4. Compile gate: `make <base>.o` in the file's directory. On failure
     the original file is restored and the errors reported. A
     conversion that does not compile never survives.

Argument-count mismatches between a .ch signature and a .c definition
are NOT converted; they are reported as DRIFT — historically these are
real bugs (see CUI_GetHeaders, porting-assessment §12).

Usage:
    ansify --build-db                # regenerate build/desc/*.desc (needs build/bin/class)
    ansify [options] file.c ...
    ansify [options] --dir src/atk/eq

Options:
    --db DIR        signature database directory (default: <root>/build/desc)
    --dry-run       report what would change; write nothing
    --no-compile    skip the compile gate
    --no-helpers    convert only DB-backed class methods/classprocs
    --keep-backup   keep <file>.ansify-orig even on success
"""

import argparse
import filecmp
import os
import re
import shutil
import subprocess
import sys
import tempfile

TOOLDIR = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(TOOLDIR))   # revival/tools -> repo root

SKIP_DIRS = {
    'hp300', 'hp700', 'hp800', 'pmax_3', 'pmax_41', 'pmax_42', 'pmax_ul4',
    'rt_aos4', 'rt_mach', 'sun3_35', 'sun3_4', 'sun3_41', 'sun4_40',
    'sun4_41', 'sun4_51', 'vax_3', 'vax_43', 'aix_i386', 'aix_rt', 'aos_rt',
    'dec_mips', 'dec_mips_42', 'dec_vax', 'hp_68k', 'i386_bsd', 'i386_bsdi',
    'i386_Linux', 'i386_mach', 'mach_rt', 'sco_i386', 'sgi_mips', 'sun_68k',
    'sun_sparc', 'sun_sparc_mach', 'rs_aix3',
}


# ---------------------------------------------------------------- DB build

def build_db(dbdir):
    classbin = os.path.join(ROOT, 'build', 'bin', 'class')
    incdir = os.path.join(ROOT, 'build', 'include', 'atk')
    if not os.path.isfile(classbin):
        sys.exit(f"ansify: {classbin} not found -- build the tree first")
    os.makedirs(dbdir, exist_ok=True)

    chfiles = []
    for dirpath, dirnames, filenames in os.walk(os.path.join(ROOT, 'src')):
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
        for fn in filenames:
            if fn.endswith('.ch'):
                chfiles.append(os.path.join(dirpath, fn))

    ok, failed = 0, []
    seen_lower = {}  # lowercased classname -> (real classname, source .ch path)
    with tempfile.TemporaryDirectory() as tmp:
        for ch in sorted(chfiles):
            base = os.path.basename(ch)[:-3]
            for f in os.listdir(tmp):
                os.unlink(os.path.join(tmp, f))
            r = subprocess.run(
                [classbin, '-s', '-D', '-N',
                 '-I' + os.path.dirname(ch), '-I' + incdir, ch],
                cwd=tmp, capture_output=True, text=True)
            desc = os.path.join(tmp, base + '.desc')
            if r.returncode != 0 or not os.path.isfile(desc):
                failed.append((ch, (r.stderr or r.stdout).strip().split('\n')[-1] if (r.stderr or r.stdout) else 'no output'))
                continue
            with open(desc, errors='replace') as f:
                first = f.readline()
            m = re.match(r'(?:Class|Package):\s+(\w+)', first)
            classname = m.group(1) if m else base
            lower = classname.lower()
            prior = seen_lower.get(lower)
            if prior is not None:
                if prior[0] != classname:
                    # dbdir is written with one file per classname; on a
                    # case-insensitive filesystem two classes differing only
                    # in case collide onto the same .desc path and silently
                    # overwrite each other's signatures. Keep the
                    # first-processed one, report the loser instead of
                    # letting it clobber silently.
                    failed.append((ch, f"case-insensitive DB collision with "
                                        f"{prior[1]} -- class {classname!r} "
                                        f"vs {prior[0]!r} both map to "
                                        f"{lower}.desc on this filesystem"))
                else:
                    # Same classname declared in two different .ch files --
                    # e.g. a dead/hidden demo tree shadowing the real one
                    # (src/rdemo/hide/tscript.ch vs atk/typescript/tscript.ch,
                    # both "class typescript"). sorted(chfiles) means
                    # whichever sorts later would silently overwrite the
                    # correct entry with zero warning. Keep the
                    # first-processed one, report the loser instead.
                    failed.append((ch, f"duplicate class {classname!r} also "
                                        f"declared in {prior[1]} -- keeping "
                                        f"that one, this one ignored"))
                continue
            seen_lower[lower] = (classname, ch)
            shutil.copyfile(desc, os.path.join(dbdir, classname + '.desc'))
            ok += 1

    print(f"signature DB: {ok} classes -> {dbdir}, {len(failed)} failed")
    for ch, why in failed:
        print(f"  FAILED {os.path.relpath(ch, ROOT)}: {why}")
    return 0 if ok else 1


# ---------------------------------------------------------------- DB load

def split_args(argtext):
    """Split a .desc args string on top-level commas."""
    argtext = argtext.strip()
    if argtext in ('void', '', 'error'):
        return []
    parts, depth, cur = [], 0, ''
    for c in argtext:
        if c == '(':
            depth += 1
        elif c == ')':
            depth -= 1
        if c == ',' and depth == 0:
            parts.append(cur.strip())
            cur = ''
        else:
            cur += c
    if cur.strip():
        parts.append(cur.strip())
    return parts


def load_class_desc(dbdir, classname, cache={}):
    """Return {'method': {name: (ret, [argtypes])}, 'classproc': {...}} or None."""
    if classname in cache:
        return cache[classname]
    path = os.path.join(dbdir, classname + '.desc')
    if not os.path.isfile(path):
        cache[classname] = None
        return None
    entry = {'method': {}, 'classproc': {}}
    kind = name = ret = None
    args = []

    def flush():
        if kind and name:
            entry[kind][name] = (ret or 'void', args)

    for line in open(path, errors='replace'):
        m = re.match(r'^(Method|Class Procedure|Macro Method):\t(\w+)', line)
        if m:
            flush()
            kind = {'Method': 'method', 'Class Procedure': 'classproc',
                    'Macro Method': None}[m.group(1)]
            name, ret, args = m.group(2), None, []
            continue
        m = re.match(r'^\treturns:\t(.+)$', line)
        if m and kind:
            ret = m.group(1).strip()
            continue
        m = re.match(r'^\targs:\t+(.+)$', line)
        if m and kind:
            args = split_args(m.group(1))
    flush()
    cache[classname] = entry
    return entry


# ---------------------------------------------------------------- weaving

def tidy_type(t):
    t = re.sub(r'\s+', ' ', t).strip()
    t = t.replace('( *', '(*').replace('* )', '*)').replace('( )', '()')
    t = re.sub(r'\*\s+\*', '**', t)
    return t


def weave(typetext, name):
    """Combine a types-only string with a parameter name."""
    t = tidy_type(typetext)
    if '(*)' in t:
        return t.replace('(*)', f'(*{name})', 1)
    if '(' in t:
        return None          # unrecognized exotic type -- caller bails
    # classpp's own '-D' describe output renders an array-of-T parameter
    # type as 'T [ ]' (space between the brackets) rather than 'T[]', so
    # match loosely here instead of a literal endswith('[]').
    m = re.search(r'\[\s*\]\s*$', t)
    if m:
        return f"{t[:m.start()].strip()} {name}[]"
    if t.endswith('*'):
        return f"{t}{name}"
    return f"{t} {name}"


# ------------------------------------------------------- definition parsing

BARE_PARAMS = re.compile(r'^\s*[A-Za-z_]\w*(\s*,\s*[A-Za-z_]\w*)*\s*$')
HDR = re.compile(
    r'^(?P<ret>[A-Za-z_][\w \t]*?[\w*][ \t*]+)?'
    r'(?P<name>[A-Za-z_]\w*)\s*\('
    r'(?P<params>[^)]*)\)\s*(?:/\*.*?\*/\s*)?$')
TYPEONLY = re.compile(
    r'^(static\s+|register\s+)*[A-Za-z_][\w \t]*\**\s*(?:/\*.*?\*/\s*)?$')
DECL_LINE = re.compile(r'^\s*(register\s+)?[A-Za-z_][\w \t,*\[\]()]*;\s*(/\*.*?\*/\s*)?$')
BRACE_GLUED = re.compile(
    r'^(?P<decl>\s*(register\s+)?[A-Za-z_][\w \t,*\[\]()]*;)\s*\{\s*$')

RESERVED = {'if', 'while', 'for', 'switch', 'return', 'sizeof', 'do', 'else'}


def parse_decl_block(lines, start):
    """Consume K&R parameter declarations from lines[start:]; return
    (index_of_brace_line, decl_text, needs_brace_split) or None if this
    isn't a K&R body. needs_brace_split is True only when the returned
    line is a brace-glued last-parameter declaration (safe to rewrite to
    a bare '{' once the candidate is accepted, since its text was already
    captured into decls); False when the line was matched by the plain
    bare-brace check, which may have arbitrary trailing body content
    (e.g. a local variable declaration sharing the line with the brace)
    that must never be touched.

    Does not mutate `lines` -- a brace-glued line (last param declaration
    and the opening brace on one physical line) is only *identified* here;
    the caller rewrites it to a bare '{' itself, and only once it has
    committed to actually using this candidate (see convert_file). This
    candidate can still be rejected downstream (no DB signature, DRIFT,
    unhandled type, unparseable helper decls) -- mutating here unconditionally
    would silently destroy the glued declaration's text even on that
    rejection path, since nothing else in the file re-derives it.
    """
    i = start
    decls = []
    while i < len(lines):
        s = lines[i].strip()
        if s.startswith('{'):
            return i, '\n'.join(decls), False
        if s == '' or s.startswith('/*') or s.startswith('*'):
            i += 1
            continue
        if DECL_LINE.match(lines[i].rstrip('\n')):
            decls.append(s)
            i += 1
            continue
        bg = BRACE_GLUED.match(lines[i].rstrip('\n'))
        if bg:
            decls.append(bg.group('decl').strip())
            return i, '\n'.join(decls), True
        return None
    return None


def parse_local_decls(decl_text):
    """Parse a K&R declaration block into {name: woven_param} or None if
    anything is unparseable (strict: bail, don't guess)."""
    result = {}
    text = re.sub(r'/\*.*?\*/', '', decl_text, flags=re.DOTALL)
    for stmt in text.split(';'):
        stmt = stmt.strip()
        if not stmt:
            continue
        # function-pointer declarator: TYPE (*name)(...)
        fp = re.match(
            r'^(?:register\s+)?'
            r'(?P<t>(?:unsigned\s+|signed\s+|const\s+|long\s+|short\s+)*'
            r'(?:struct\s+\w+|union\s+\w+|enum\s+\w+|\w+))'
            r'\s*\(\s*\*\s*(?P<n>\w+)\s*\)\s*\(\s*(?P<a>[^)]*)\)$', stmt)
        if fp:
            result[fp.group('n')] = f"{fp.group('t')} (*{fp.group('n')})({fp.group('a').strip()})"
            continue
        m = re.match(
            r'^(?:register\s+)?'
            r'(?P<t>(?:unsigned\s+|signed\s+|const\s+|long\s+|short\s+)*'
            r'(?:struct\s+\w+|union\s+\w+|enum\s+\w+|\w+))'
            r'\s+(?P<rest>.+)$', stmt)
        if not m:
            return None
        base = re.sub(r'\s+', ' ', m.group('t'))
        for d in m.group('rest').split(','):
            d = d.strip()
            dm = re.match(r'^(?P<stars>\*+\s*)?(?P<n>\w+)\s*(?P<arr>\[[^\]]*\])?$', d)
            if not dm:
                return None
            stars = (dm.group('stars') or '').replace(' ', '')
            arr = dm.group('arr') or ''
            result[dm.group('n')] = f"{base} {stars}{dm.group('n')}{arr}"
    return result


# ---------------------------------------------------------------- conversion

class FileReport:
    def __init__(self):
        self.methods = []
        self.classprocs = []
        self.helpers = []
        self.drift = []          # (funcname, detail) -- .ch vs .c mismatch
        self.skipped = []        # (funcname, reason)


def convert_file(path, dbdir, do_helpers=True):
    """Return (new_text, report) -- new_text is None if nothing changed."""
    with open(path, errors='replace') as f:
        lines = f.read().split('\n')

    rep = FileReport()
    out = []
    i = 0
    n = len(lines)
    changed = False

    while i < n:
        line = lines[i]
        m = HDR.match(line)
        cand = None
        if m and m.group('name') not in RESERVED and BARE_PARAMS.match(m.group('params') or ''):
            ret = (m.group('ret') or '').strip()
            # two-line form: return type (possibly with static) on the previous line
            prev_type = ''
            if not ret and out and TYPEONLY.match(out[-1].strip()) and out[-1].strip():
                prev_type = out[-1].strip()
            body = parse_decl_block(lines, i + 1)
            if body is not None:
                cand = (m, ret, prev_type, body)

        if not cand:
            out.append(line)
            i += 1
            continue

        m, ret, prev_type, (brace_idx, decl_text, needs_brace_split) = cand
        fname = m.group('name')
        params = [p.strip() for p in m.group('params').split(',') if p.strip()]

        new_hdr = None
        clsm = re.match(r'^(\w+)__(\w+)$', fname)
        if clsm:
            cls, meth = clsm.group(1), clsm.group(2)
            desc = load_class_desc(dbdir, cls)
            sig = None
            kind = None
            if desc:
                if meth in desc['method']:
                    sig, kind = desc['method'][meth], 'method'
                elif meth in desc['classproc']:
                    sig, kind = desc['classproc'][meth], 'classproc'
            if sig is None:
                rep.skipped.append((fname, 'no signature in DB'))
            else:
                dbret, dbargs = sig
                if len(params) != len(dbargs) + 1:
                    rep.drift.append(
                        (fname, f'.c has {len(params)} params, .ch has {len(dbargs)}+1'))
                else:
                    first = (f'struct {cls} *{params[0]}' if kind == 'method'
                             else f'struct classheader *{params[0]}')
                    woven = [first]
                    bad = None
                    for t, nm in zip(dbargs, params[1:]):
                        w = weave(t, nm)
                        if w is None:
                            bad = t
                            break
                        woven.append(w)
                    if bad is not None:
                        rep.skipped.append((fname, f'unhandled type: {bad}'))
                    else:
                        new_hdr = f"{tidy_type(dbret)} {fname}({', '.join(woven)})"
                        (rep.methods if kind == 'method' else rep.classprocs).append(fname)
        elif do_helpers:
            local = parse_local_decls(decl_text)
            if local is None:
                rep.skipped.append((fname, 'unparseable K&R declarations'))
            else:
                woven = [local.get(nm, f'int {nm}') for nm in params]
                static_kw = ''
                base_ret = ret or prev_type or 'int'
                if base_ret.startswith('static'):
                    static_kw = ''
                new_hdr = f"{tidy_type(base_ret)} {fname}({', '.join(woven) if woven else 'void'})"
                rep.helpers.append(fname)

        if new_hdr is None:
            out.append(line)
            i += 1
            continue

        if prev_type:
            out.pop()           # the type-only line is folded into new_hdr
        out.append(new_hdr)
        # A brace-glued decl block (last param and '{' on one physical
        # line) is only rewritten to a bare '{' now that this candidate is
        # definitely being used -- see parse_decl_block's docstring for why
        # this can't happen speculatively.
        if needs_brace_split:
            lines[brace_idx] = '{'
        i = brace_idx           # skip the K&R decl block entirely
        changed = True

    if not changed:
        return None, rep
    return '\n'.join(out), rep


# ---------------------------------------------------------------- driver

def run_fix_tools(path, dry_run):
    for tool in ('fix-static-methods', 'fix-missing-static-decl'):
        t = os.path.join(TOOLDIR, tool)
        cmd = [sys.executable, t] + (['--dry-run'] if dry_run else []) + [path]
        r = subprocess.run(cmd, capture_output=True, text=True)
        outp = (r.stdout or '').strip()
        if outp and 'Total files' not in outp.split('\n')[0]:
            for ln in outp.split('\n'):
                if ln.startswith(('Modified', 'Would modify', '  SKIPPED')):
                    print(f"    [{tool}] {ln.strip()}")


def compile_gate(path):
    d = os.path.dirname(os.path.abspath(path))
    base = os.path.splitext(os.path.basename(path))[0]
    if not os.path.isfile(os.path.join(d, 'Makefile')):
        return None, 'no Makefile -- compile gate skipped'
    r = subprocess.run(['make', base + '.o'], cwd=d, capture_output=True, text=True)
    if r.returncode == 0:
        return True, ''
    return False, (r.stderr or r.stdout)


def process(path, dbdir, args):
    print(f"{os.path.relpath(path, ROOT) if path.startswith(ROOT) else path}:")
    backup = path + '.ansify-orig'
    shutil.copyfile(path, backup)

    try:
        if not args.dry_run:
            run_fix_tools(path, dry_run=False)
        new_text, rep = convert_file(path, dbdir, do_helpers=not args.no_helpers)

        for fn, why in rep.drift:
            print(f"    DRIFT   {fn}: {why}  << check for a real .ch-vs-.c bug")
        for fn, why in rep.skipped:
            print(f"    skipped {fn}: {why}")

        total = len(rep.methods) + len(rep.classprocs) + len(rep.helpers)

        # run_fix_tools() (fix-static-methods / fix-missing-static-decl) can
        # modify the file on disk even when convert_file finds nothing left
        # to convert. That's still a real on-disk change and needs the same
        # compile-gate-and-restore guarantee as any other change -- don't
        # take the early-return "nothing happened" path in that case.
        fix_tools_changed = (not args.dry_run) and not filecmp.cmp(path, backup, shallow=False)

        if new_text is None:
            if not fix_tools_changed:
                print("    no K&R definitions converted")
                os.unlink(backup)
                return True, rep
            print("    no K&R definitions converted (fix-static-methods/"
                  "fix-missing-static-decl modified the file -- gating anyway)")
        else:
            if args.dry_run:
                print(f"    would convert: {len(rep.methods)} methods, "
                      f"{len(rep.classprocs)} classprocs, {len(rep.helpers)} helpers")
                shutil.copyfile(backup, path)   # undo fix-tools edits too
                os.unlink(backup)
                return True, rep

            with open(path, 'w') as f:
                f.write(new_text)
            print(f"    converted: {len(rep.methods)} methods, "
                  f"{len(rep.classprocs)} classprocs, {len(rep.helpers)} helpers")

        if args.no_compile:
            ok = True
        else:
            ok, detail = compile_gate(path)
            if ok is None:
                print(f"    {detail}")
                ok = True
            elif not ok:
                print("    COMPILE FAILED -- restoring original:")
                for ln in detail.strip().split('\n')[:12]:
                    print(f"      {ln}")
                shutil.copyfile(backup, path)

        if ok and not args.keep_backup:
            os.unlink(backup)
        elif not ok:
            os.unlink(backup)
        return ok, rep
    except Exception:
        shutil.copyfile(backup, path)
        os.unlink(backup)
        raise


def main():
    ap = argparse.ArgumentParser(description=__doc__.split('\n')[0])
    ap.add_argument('--build-db', action='store_true')
    ap.add_argument('--db', default=os.path.join(ROOT, 'build', 'desc'))
    ap.add_argument('--dir')
    ap.add_argument('--dry-run', action='store_true')
    ap.add_argument('--no-compile', action='store_true')
    ap.add_argument('--no-helpers', action='store_true')
    ap.add_argument('--keep-backup', action='store_true')
    ap.add_argument('files', nargs='*')
    args = ap.parse_args()

    if args.build_db:
        sys.exit(build_db(args.db))

    targets = list(args.files)
    if args.dir:
        for dirpath, dirnames, filenames in os.walk(args.dir):
            dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
            for fn in sorted(filenames):
                if fn.endswith('.c'):
                    targets.append(os.path.join(dirpath, fn))
    if not targets:
        ap.print_help()
        sys.exit(1)
    if not os.path.isdir(args.db):
        sys.exit(f"ansify: signature DB {args.db} missing -- run: ansify --build-db")

    failures = drift_total = 0
    for path in targets:
        ok, rep = process(path, args.db, args)
        if not ok:
            failures += 1
        drift_total += len(rep.drift)

    print(f"\n{len(targets)} file(s); {failures} compile failure(s); "
          f"{drift_total} DRIFT finding(s)")
    sys.exit(1 if failures else 0)


if __name__ == '__main__':
    main()