""" Instrumentiert AutoLISP-Funktionen (.lsp) automatisch mit den ssg_dbg.lsp- Routinen (dbgf, dbg, dbgreturn, dbgopen, dbgclose) - so wie es beim manuellen Debuggen (siehe z.B. Lisp/KreiselInsert.lsp::ils-eckrad-insert) bereits gemacht wird. Fuer eine gegebene Funktion (--method) wird eingefuegt: - (dbgf "funktionsname") direkt als erste Form im Rumpf - (dbg 'parameter) je Eingabeparameter (die Locals nach "/" NICHT) - (dbgreturn ) die letzte Form des Rumpfs wird eingeklammert, damit ihr Rueckgabe- wert mitgeloggt wird Mit --recursive wird zusaetzlich jede Funktion instrumentiert, die im Rumpf (auch verschachtelt, aber NICHT innerhalb von 'quotierten Listen) aufgerufen wird - vorausgesetzt, ihr (defun ...) laesst sich in einem der --search-dir- Verzeichnisse finden (Bibliotheks-/AutoLISP-Builtins wie princ, command, strcat etc. haben dort kein defun und werden dadurch automatisch NICHT angefasst). Der Aufrufgraph wird rekursiv weiterverfolgt (mit --max-depth begrenzt), einmal besuchte Funktionen werden nicht doppelt instrumentiert. Mit --add-open DATEI METHODE wird in der angegebenen Methode zusaetzlich - (dbgopen "" "") als erste Rumpf-Form - (dbgclose) vor jedem (exit)-Aufruf im Rumpf UND am Ende des Rumpfs (falls die letzte Form nicht bereits (exit) ist) eingefuegt - fuer den Einstiegspunkt (typischerweise ein c:BEFEHL-Kommando), der die .dbg-Datei fuer einen ganzen Testlauf oeffnen/schliessen soll. --add-open ist unabhaengig von --method/--recursive nutzbar und mehrfach angebbar. Aufruf-Beispiele: python dbg2lsp.py tests/test_mubea.lsp --method mubea:build-separator-one python dbg2lsp.py tests/test_mubea.lsp --method mubea:build-kreisel --recursive python dbg2lsp.py tests/test_mubea.lsp --add-open tests/test_mubea.lsp c:TEST_MUBEA python dbg2lsp.py tests/test_mubea.lsp --method mubea:build-kreisel --recursive \\ --add-open tests/test_mubea.lsp c:TEST_MUBEA --dry-run Details, Grenzen und weitere Beispiele: siehe doc/dbg2lsp.md """ import argparse import difflib import os import sys # --------------------------------------------------------------------------- # ssg_dbg.lsp-Funktionen, die NIE instrumentiert oder in den Aufrufgraphen # aufgenommen werden duerfen - sonst wuerde --recursive versuchen, dbgf # selbst mit dbgf zu instrumentieren, sobald es auf eine bereits (manuell # oder durch einen frueheren Lauf) instrumentierte Funktion trifft. # --------------------------------------------------------------------------- DBG_FRAMEWORK_NAMES = { "dbgf", "dbg", "dbgp", "dbgmsg", "dbgreturn", "dbgopen", "dbgclose", "dbgflush", "dbgon", "dbgoff", "dbg-flush", "dbg-bufwrite", "dbg-tabs", "dbg-tostring", "dbg-list-tostring", } DEFAULT_DBG_ENVVAR = "DXFM_LOG" # --------------------------------------------------------------------------- # Zeichenweises Scannen mit String-/Kommentar-Bewusstsein (identische Logik # wie beim manuellen Klammer-Balance-Check waehrend der Mubea-Instrumentierung) # --------------------------------------------------------------------------- class LispSyntaxError(Exception): pass def _skip_string(text, i): """i zeigt auf das oeffnende '\"'. Gibt den Index NACH dem schliessenden '\"' zurueck.""" n = len(text) i += 1 while i < n: c = text[i] if c == "\\": i += 2 continue if c == '"': return i + 1 i += 1 raise LispSyntaxError("Nicht geschlossener String ab Position %d" % i) def _skip_line_comment(text, i): nl = text.find("\n", i) return len(text) if nl == -1 else nl + 1 def find_matching_paren(text, open_idx): """open_idx zeigt auf '('. Gibt den Index der zugehoerigen ')' zurueck.""" n = len(text) if text[open_idx] != "(": raise LispSyntaxError("Position %d ist keine '('" % open_idx) depth = 0 i = open_idx while i < n: c = text[i] if c == '"': i = _skip_string(text, i) continue if c == ";": i = _skip_line_comment(text, i) continue if c == "(": depth += 1 elif c == ")": depth -= 1 if depth == 0: return i i += 1 raise LispSyntaxError("Keine schliessende Klammer fuer '(' ab Position %d gefunden" % open_idx) def _skip_ws_and_comments(text, i): n = len(text) while i < n: c = text[i] if c in " \t\r\n": i += 1 continue if c == ";": i = _skip_line_comment(text, i) continue break return i _TOKEN_STOP_CHARS = set(" \t\r\n()\";'") def read_next_token(text, i, limit): """Liest ab i (nach Whitespace/Kommentaren) das naechste Token (Symbol/Zahl) bis zum naechsten Trennzeichen. Gibt (token, end_index) oder (None, i) zurueck, wenn an Position i eine '(' / '"' / EOF folgt (kein einfaches Token).""" i = _skip_ws_and_comments(text, i) if i >= limit: return None, i if text[i] in "(\"'": return None, i start = i while i < limit and text[i] not in _TOKEN_STOP_CHARS: i += 1 if i == start: return None, i return text[start:i], i def iter_top_level_spans(text, start, end): """Liefert (form_start, form_end) fuer jede balancierte Top-Level-Form zwischen start und end (end exklusiv, typischerweise die Position der schliessenden ')' des umgebenden defun). Reine Atome (Symbole/Zahlen ohne umschliessende Klammer) werden ebenfalls als Form erkannt.""" spans = [] i = start while i < end: i = _skip_ws_and_comments(text, i) if i >= end: break c = text[i] if c == "(": close = find_matching_paren(text, i) spans.append((i, close + 1)) i = close + 1 elif c == "'": # Quotiertes Atom/Liste - als eine Form ab dem Apostroph behandeln. j = i + 1 if j < end and text[j] == "(": close = find_matching_paren(text, j) spans.append((i, close + 1)) i = close + 1 else: tok, tend = read_next_token(text, j, end) spans.append((i, tend)) i = tend elif c == '"': send = _skip_string(text, i) spans.append((i, send)) i = send else: tok, tend = read_next_token(text, i, end) if tok is None: i += 1 else: spans.append((i, tend)) i = tend return spans # --------------------------------------------------------------------------- # (defun ...) - Erkennung # --------------------------------------------------------------------------- class DefunInfo: def __init__(self, name, path, defun_start, defun_end, arglist_start, arglist_end, body_start, body_end, params, locals_): self.name = name self.path = path self.defun_start = defun_start self.defun_end = defun_end # Position NACH der schliessenden ')' des defun self.arglist_start = arglist_start self.arglist_end = arglist_end self.body_start = body_start self.body_end = body_end # Position der schliessenden ')' des defun (exklusiv) self.params = params self.locals = locals_ def __repr__(self): return "DefunInfo(%r in %s)" % (self.name, self.path) def _parse_arglist(text, arglist_start, arglist_end): """arglist_start zeigt auf '(' der Parameterliste, arglist_end auf die zugehoerige ')'. Gibt (params, locals) zurueck - locals ist alles nach einem "/"-Trenner (leer, falls keiner vorhanden).""" params = [] locals_ = [] seen_slash = False i = arglist_start + 1 while i < arglist_end: tok, tend = read_next_token(text, i, arglist_end) if tok is None: # z.B. eine verschachtelte Liste als Defaultwert (in AutoLISP # eigentlich nicht ueblich) - ueberspringen statt abzubrechen. i = _skip_ws_and_comments(text, i) if i < arglist_end and text[i] == "(": i = find_matching_paren(text, i) + 1 continue i += 1 continue if tok == "/": seen_slash = True elif seen_slash: locals_.append(tok) else: params.append(tok) i = tend return params, locals_ def find_defuns_in_text(text, path): """Findet alle (defun NAME (...) ...) Top-Level-Definitionen im Text.""" defuns = [] i = 0 n = len(text) while i < n: c = text[i] if c == '"': i = _skip_string(text, i) continue if c == ";": i = _skip_line_comment(text, i) continue if c == "(": close = find_matching_paren(text, i) head_tok, head_end = read_next_token(text, i + 1, close) if head_tok is not None and head_tok.lower() == "defun": name_tok, name_end = read_next_token(text, head_end, close) if name_tok: al_start = _skip_ws_and_comments(text, name_end) if al_start < close and text[al_start] == "(": al_end = find_matching_paren(text, al_start) params, locals_ = _parse_arglist(text, al_start, al_end) body_start = al_end + 1 defuns.append(DefunInfo( name=name_tok, path=path, defun_start=i, defun_end=close + 1, arglist_start=al_start, arglist_end=al_end, body_start=body_start, body_end=close, params=params, locals_=locals_, )) i = close + 1 continue i += 1 return defuns def build_defun_index(paths): """path -> Dateitext (einmal eingelesen) und name.lower() -> DefunInfo (erster Treffer gewinnt; mehrfach definierte Namen sind in dieser Code-Basis nicht vorgesehen).""" file_texts = {} index = {} for path in paths: with open(path, "r", encoding="utf-8") as f: text = f.read() file_texts[path] = text for d in find_defuns_in_text(text, path): index.setdefault(d.name.lower(), d) return file_texts, index # --------------------------------------------------------------------------- # Aufruf-Erkennung (fuer --recursive und --add-open/exit-Suche) # --------------------------------------------------------------------------- def scan_calls(text, start, end): """Liefert eine Liste von (head_name, call_start, call_end) fuer jede NICHT quotierte Aufruf-Form (...) im Bereich [start, end) - beliebig verschachtelt, aber nicht innerhalb von 'quotierten Listen (deren Inhalt ist reine Datenliste, dort kann nichts "aufgerufen" werden).""" calls = [] def walk(s, e): i = s while i < e: c = text[i] if c == '"': i = _skip_string(text, i) continue if c == ";": i = _skip_line_comment(text, i) continue if c == "'": j = i + 1 if j < e and text[j] == "(": # quotierte Liste: reine Daten, nicht weiter absteigen close = find_matching_paren(text, j) i = close + 1 continue i = j continue if c == "(": close = find_matching_paren(text, i) head_tok, head_end = read_next_token(text, i + 1, close) if head_tok and head_tok[0] not in "0123456789.\"'": calls.append((head_tok, i, close + 1)) walk(i + 1, close) i = close + 1 continue i += 1 walk(start, end) return calls # --------------------------------------------------------------------------- # Hilfsfunktionen fuer Einfuegetexte # --------------------------------------------------------------------------- def _line_indent(text, pos): """Fuehrender Whitespace der Zeile, in der pos liegt (fuer die Einrueckung neu eingefuegter Debug-Zeilen).""" nl = text.rfind("\n", 0, pos) line_start = nl + 1 i = line_start while i < pos and text[i] in " \t": i += 1 indent = text[line_start:i] return indent if indent else " " def _body_indent(text, body_start, body_end): """Einrueckung der ERSTEN echten Rumpf-Form. NICHT einfach _line_indent auf body_start anwenden: bei mehrzeiligen Parameterlisten (z.B. "(defun foo (a b /\n c d)" - "/"-Locals auf eigener Zeile) liegt body_start mitten in der Fortsetzungszeile der Parameterliste, deren Ausrichtung nichts mit der eigentlichen Rumpf-Einrueckung zu tun hat.""" first = _skip_ws_and_comments(text, body_start) if first >= body_end: return " " return _line_indent(text, first) def _already_has_call(text, body_start, body_end, head_name): spans = iter_top_level_spans(text, body_start, body_end) if not spans: return False fs, fe = spans[0] if text[fs] != "(": return False tok, _ = read_next_token(text, fs + 1, fe) return bool(tok) and tok.lower() == head_name.lower() # --------------------------------------------------------------------------- # Edit-Sammlung: (position, prioritaet, text) - reine Einfuegungen, angewandt # in absteigender Position (und bei Gleichstand nach Prioritaet), damit # fruehere Positionen gueltig bleiben. # --------------------------------------------------------------------------- class EditSet: def __init__(self): self.edits = {} # path -> list[(pos, prio, text)] def insert(self, path, pos, prio, text): self.edits.setdefault(path, []).append((pos, prio, text)) def apply(self, file_texts): """Gibt {path: neuer_text} fuer alle Dateien mit mindestens einer Einfuegung zurueck.""" result = {} for path, items in self.edits.items(): text = file_texts[path] # Bei gleicher Position: kleinere Prioritaet zuerst im Ergebnis- # text (prio dient als "vor/nach"-Reihenfolge an derselben Stelle). items_sorted = sorted(items, key=lambda t: (t[0], t[1])) out = [] last = 0 # Gruppieren nach Position, damit mehrere Einfuegungen an # derselben Stelle in Prioritaets-Reihenfolge zusammenkommen. grouped = {} order = [] for pos, prio, ins_text in items_sorted: if pos not in grouped: grouped[pos] = [] order.append(pos) grouped[pos].append(ins_text) for pos in order: out.append(text[last:pos]) out.extend(grouped[pos]) last = pos out.append(text[last:]) result[path] = "".join(out) return result # --------------------------------------------------------------------------- # Instrumentierung # --------------------------------------------------------------------------- def instrument_method(defun, file_texts, edits, force=False, log=print): """Fuegt (dbgf ...), (dbg 'param) je Parameter und (dbgreturn ...) um die letzte Rumpf-Form ein.""" text = file_texts[defun.path] if not force and _already_has_call(text, defun.body_start, defun.body_end, "dbgf"): log(" [skip] %s (%s) ist bereits instrumentiert (dbgf vorhanden) - --force zum Erzwingen" % (defun.name, defun.path)) return False indent = _body_indent(text, defun.body_start, defun.body_end) lines = ['(dbgf "%s")' % defun.name] for p in defun.params: lines.append("(dbg '%s)" % p) # Kein Trailing-Newline/-Indent: body_start hat im Original bereits sein # eigenes "\n" vor der ersten Rumpf-Form, das bleibt unveraendert # stehen und schliesst direkt an die letzte hier eingefuegte Zeile an. insertion = "\n" + "\n".join(indent + l for l in lines) edits.insert(defun.path, defun.body_start, 10, insertion) spans = iter_top_level_spans(text, defun.body_start, defun.body_end) if spans: last_start, last_end = spans[-1] edits.insert(defun.path, last_start, 20, "(dbgreturn ") edits.insert(defun.path, last_end, 21, ")") else: log(" [warn] %s (%s) hat einen leeren Rumpf - kein dbgreturn eingefuegt" % (defun.name, defun.path)) log(" [ok] %s (%s): dbgf + %d x dbg + dbgreturn" % (defun.name, defun.path, len(defun.params))) return True def instrument_add_open(defun, file_texts, edits, dbg_file, dbg_envvar, force=False, log=print): """Fuegt (dbgopen "dbg_file" "dbg_envvar") als erste Rumpf-Form ein sowie (dbgclose) vor jedem (exit)-Aufruf und (sofern die letzte Rumpf-Form nicht selbst ein (exit) ist) am Ende des Rumpfs.""" text = file_texts[defun.path] if not force and _already_has_call(text, defun.body_start, defun.body_end, "dbgopen"): log(" [skip] %s (%s) hat bereits (dbgopen ...) - --force zum Erzwingen" % (defun.name, defun.path)) return False indent = _body_indent(text, defun.body_start, defun.body_end) # Kein Trailing-Newline/-Indent (siehe instrument_method): die naechste # Einfuegung an derselben Position (z.B. dbgf durch --method) oder das # Original selbst liefert das fuehrende "\n" bereits mit. open_line = "\n" + indent + '(dbgopen "%s" "%s")' % (dbg_file, dbg_envvar) edits.insert(defun.path, defun.body_start, 5, open_line) calls = scan_calls(text, defun.body_start, defun.body_end) exit_calls = [c for c in calls if c[0].lower() == "exit"] for _, cstart, _cend in exit_calls: cindent = _line_indent(text, cstart) edits.insert(defun.path, cstart, 15, "(dbgclose)\n" + cindent) spans = iter_top_level_spans(text, defun.body_start, defun.body_end) last_is_exit = False if spans: last_start, last_end = spans[-1] tok, _ = read_next_token(text, last_start + 1, last_end) if text[last_start] == "(" else (None, 0) last_is_exit = bool(tok) and tok.lower() == "exit" if not last_is_exit: edits.insert(defun.path, defun.body_end, 25, indent + "(dbgclose)\n") log(" [ok] %s (%s): dbgopen + dbgclose (%d exit-Stellen + Rumpfende)" % (defun.name, defun.path, len(exit_calls))) return True # --------------------------------------------------------------------------- # Rekursion ueber den Aufrufgraphen # --------------------------------------------------------------------------- def instrument_recursive(start_defun, index, file_texts, edits, max_depth, exclude, force, log): visited = set() def recurse(defun, depth): key = defun.name.lower() if key in visited: return visited.add(key) instrument_method(defun, file_texts, edits, force=force, log=log) if depth >= max_depth: log(" [info] max-depth (%d) bei %s erreicht - keine weiteren Aufrufe verfolgt" % (max_depth, defun.name)) return text = file_texts[defun.path] calls = scan_calls(text, defun.body_start, defun.body_end) seen_here = set() for head, _s, _e in calls: head_l = head.lower() if head_l in seen_here: continue seen_here.add(head_l) if head_l in DBG_FRAMEWORK_NAMES or head_l in exclude: continue callee = index.get(head_l) if callee is None or callee.name.lower() == key: continue recurse(callee, depth + 1) recurse(start_defun, 0) # --------------------------------------------------------------------------- # Pfad-/Umgebungshilfen # --------------------------------------------------------------------------- def find_repo_root(start_path): cur = os.path.abspath(start_path) if os.path.isfile(cur): cur = os.path.dirname(cur) while True: if os.path.isdir(os.path.join(cur, ".git")) or os.path.isdir(os.path.join(cur, "Lisp")): return cur parent = os.path.dirname(cur) if parent == cur: return None cur = parent def resolve_file(path, repo_root): if os.path.isfile(path): return os.path.abspath(path) if repo_root: candidate = os.path.join(repo_root, path) if os.path.isfile(candidate): return os.path.abspath(candidate) return None def collect_lsp_files(dirs): paths = [] seen = set() for d in dirs: if not os.path.isdir(d): continue for root, _dirnames, filenames in os.walk(d): for fn in filenames: if fn.lower().endswith(".lsp"): full = os.path.abspath(os.path.join(root, fn)) if full not in seen: seen.add(full) paths.append(full) return paths def dbg_file_name_for(method_name): """Leitet einen .dbg-Dateinamen aus einem Methodennamen ab, z.B. "c:TEST_MUBEA" -> "test_mubea.dbg", "mubea:build-kreisel" -> "build-kreisel.dbg".""" base = method_name.split(":")[-1] return base.lower() + ".dbg" # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def main(argv=None): parser = argparse.ArgumentParser( prog="dbg2lsp.py", description="Instrumentiert AutoLISP-Funktionen mit ssg_dbg.lsp (dbgf/dbg/dbgreturn/dbgopen/dbgclose).", ) parser.add_argument("file", help="Anker-.lsp-Datei (Verzeichnis wird immer mit durchsucht)") parser.add_argument("--method", action="append", default=[], metavar="NAME", help="Funktionsname, der mit dbgf/dbg/dbgreturn instrumentiert wird. Mehrfach angebbar.") parser.add_argument("--recursive", action="store_true", help="Auch alle im Rumpf aufgerufenen projekteigenen Funktionen instrumentieren (rekursiv)") parser.add_argument("--search-dir", action="append", default=[], metavar="DIR", help="Weiteres Verzeichnis nach .lsp-Dateien durchsuchen (fuer --recursive). " "Default: Lisp/ und das Verzeichnis von FILE.") parser.add_argument("--max-depth", type=int, default=3, help="Maximale Rekursionstiefe fuer --recursive (Default: 3)") parser.add_argument("--exclude", action="append", default=[], metavar="NAME", help="Funktionsname von --recursive ausschliessen (mehrfach angebbar)") parser.add_argument("--add-open", nargs=2, action="append", default=[], metavar=("DATEI", "METHODE"), help="(dbgopen ...)/(dbgclose) in METHODE (aus DATEI) einfuegen. Mehrfach angebbar.") parser.add_argument("--dbg-file", default=None, help="Dateiname fuer (dbgopen ...) (Default: aus dem --add-open-Methodennamen abgeleitet, z.B. test_mubea.dbg)") parser.add_argument("--dbg-envvar", default=DEFAULT_DBG_ENVVAR, help='Umgebungsvariable fuer (dbgopen ...) (Default: "%s")' % DEFAULT_DBG_ENVVAR) parser.add_argument("--force", action="store_true", help="Auch instrumentieren, wenn bereits (dbgf ...)/(dbgopen ...) vorhanden zu sein scheint") parser.add_argument("--dry-run", action="store_true", help="Nur ein Diff anzeigen, keine Dateien schreiben") args = parser.parse_args(argv) repo_root = find_repo_root(args.file) or find_repo_root(".") main_file = resolve_file(args.file, repo_root) if main_file is None: print("FEHLER: Datei nicht gefunden: %s" % args.file, file=sys.stderr) return 1 search_dirs = [os.path.dirname(main_file)] if repo_root: search_dirs.append(os.path.join(repo_root, "Lisp")) for d in args.search_dir: resolved = d if os.path.isdir(d) else os.path.join(repo_root or ".", d) search_dirs.append(resolved) for f, _m in args.add_open: resolved = resolve_file(f, repo_root) if resolved: search_dirs.append(os.path.dirname(resolved)) lsp_files = set(collect_lsp_files(search_dirs)) lsp_files.add(main_file) lsp_files = sorted(lsp_files) file_texts, index = build_defun_index(lsp_files) if not args.method and not args.add_open: print("FEHLER: mindestens --method oder --add-open angeben.", file=sys.stderr) return 1 edits = EditSet() exclude = {n.lower() for n in args.exclude} touched_any = False for method_name in args.method: defun = index.get(method_name.lower()) if defun is None: print("FEHLER: Funktion '%s' nicht gefunden (durchsucht: %s)" % (method_name, ", ".join(lsp_files)), file=sys.stderr) continue print("== --method %s ==" % method_name) if args.recursive: instrument_recursive(defun, index, file_texts, edits, max_depth=args.max_depth, exclude=exclude, force=args.force, log=print) else: instrument_method(defun, file_texts, edits, force=args.force, log=print) touched_any = True for file_arg, method_name in args.add_open: resolved = resolve_file(file_arg, repo_root) if resolved is None: print("FEHLER: Datei nicht gefunden: %s" % file_arg, file=sys.stderr) continue # Falls die Datei noch nicht im Index ist (z.B. ausserhalb der # Search-Dirs), jetzt einlesen und mit aufnehmen. if resolved not in file_texts: with open(resolved, "r", encoding="utf-8") as f: file_texts[resolved] = f.read() for d in find_defuns_in_text(file_texts[resolved], resolved): index.setdefault(d.name.lower(), d) defun = index.get(method_name.lower()) if defun is None or defun.path != resolved: # ggf. gleichnamige Funktion aus anderer Datei ignorieren - # explizit in der genannten Datei suchen. local_defuns = find_defuns_in_text(file_texts[resolved], resolved) defun = next((d for d in local_defuns if d.name.lower() == method_name.lower()), None) if defun is None: print("FEHLER: Funktion '%s' nicht in %s gefunden" % (method_name, file_arg), file=sys.stderr) continue print("== --add-open %s %s ==" % (file_arg, method_name)) dbg_file = args.dbg_file or dbg_file_name_for(method_name) instrument_add_open(defun, file_texts, edits, dbg_file=dbg_file, dbg_envvar=args.dbg_envvar, force=args.force, log=print) touched_any = True if not touched_any: return 1 changed = edits.apply(file_texts) if not changed: print("Keine Aenderungen vorgenommen.") return 0 for path, new_text in changed.items(): old_text = file_texts[path] if args.dry_run: rel = os.path.relpath(path, repo_root) if repo_root else path diff = difflib.unified_diff( old_text.splitlines(keepends=True), new_text.splitlines(keepends=True), fromfile=rel, tofile=rel, ) sys.stdout.writelines(diff) else: with open(path, "w", encoding="utf-8", newline="") as f: f.write(new_text) print("Geschrieben: %s" % path) return 0 if __name__ == "__main__": sys.exit(main())