#!/usr/bin/env python3 """Repair FernFlower's stale local variable references, scoped to the method. The decompiler renames conflicting locals only sometimes: a declaration ends up as `variantlist` while its uses stay `variantlist1`, and javac reports "cannot find symbol ... variable variantlist1". Renaming is confined to the enclosing method. That matters because the suffixed name is often a real, correctly declared variable in a *different* method of the same class, and a whole-file rename would corrupt it. The method is found by its declaration line and the closing brace at the same indentation, which is reliable for well-formed decompiled output. python fix_stale_locals.py [--apply] """ import argparse import collections import os import re ERROR = re.compile(r'^(?P[A-Za-z]:.*?\.java):(?P\d+): error: (?P.*)$') SYMBOL = re.compile(r'^ {2}symbol:\s*variable (?P\w+)\s*$') DECL = re.compile(r'^[ \t]*(?:final\s+)?[A-Za-z_$][\w$.<>,\[\] ]*\s+(?P[A-Za-z_$]\w*)\s*(?==|;|,|\))') METHOD = re.compile(r'^[ \t]{0,8}(?:(?:public|private|protected|static|final|abstract|synchronized|native|default|strictfp)\s+)*' r'[\w$.<>,\[\] ]+\s+\w+\s*\([^;]*\)\s*(?:throws\s+[\w., ]+)?\s*\{\s*$') def method_regions(lines): """Returns [(start_index, end_index_exclusive, indent)] for each method.""" regions = [] for i, line in enumerate(lines): if not METHOD.match(line): continue indent = len(line) - len(line.lstrip()) close = ' ' * indent + '}' for j in range(i + 1, len(lines)): if lines[j].rstrip() == close: regions.append((i, j + 1, indent)) break return regions def region_of(regions, index): for start, end, _ in regions: if start <= index < end: return start, end return None def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('errors') parser.add_argument('--apply', action='store_true') args = parser.parse_args() missing = collections.defaultdict(set) current = None with open(args.errors, encoding='utf-8', errors='replace') as handle: for raw in handle: line = raw.rstrip('\n').rstrip('\r') m = ERROR.match(line) if m: current = m.group('file') continue s = SYMBOL.match(line) if s and current: missing[current].add(s.group('name')) files_changed = 0 renames = 0 skipped = [] for path in sorted(missing): if not os.path.isfile(path): continue with open(path, encoding='utf-8', errors='replace') as handle: text = handle.read() lines = text.split('\n') regions = method_regions(lines) # for each stale name, which regions hold the clean declaration edits = collections.defaultdict(list) # old name -> list of (start, end) for name in sorted(missing[path]): pattern = re.compile(r'\b%s\b' % re.escape(name)) longer = re.compile(r'\b%s\d+\b' % re.escape(name)) shorter = name.rstrip('0123456789') shorter_pattern = re.compile(r'\b%s\b' % re.escape(shorter)) if shorter != name else None hits = 0 for start, end, _indent in regions: chunk = '\n'.join(lines[start:end]) if not pattern.search(chunk): continue # Either direction may be the stale one: sometimes the use kept # the short name while the declaration gained a suffix. target = None if shorter_pattern is not None and shorter_pattern.search(chunk): target = shorter else: seen = {m.group(0) for m in longer.finditer(chunk)} if len(seen) == 1: target = seen.pop() if target is None: continue edits[name].append((start, end, target)) hits += 1 if not hits: skipped.append('%s: nothing in scope matches %s' % (os.path.basename(path), name)) else: renames += hits if not edits: continue # rebuild from the last region so earlier edits keep their positions replacements = [] for name, spans in edits.items(): for start, end, target in spans: chunk = '\n'.join(lines[start:end]) chunk = re.sub(r'\b%s\b' % re.escape(name), target, chunk) replacements.append((start, end, chunk)) replacements.sort(key=lambda t: t[0], reverse=True) for start, end, chunk in replacements: lines[start:end] = chunk.split('\n') files_changed += 1 if args.apply: with open(path, 'w', encoding='utf-8', newline='\n') as handle: handle.write('\n'.join(lines)) print('methods fixed: %d across %d files' % (renames, files_changed)) if skipped: print('unresolved %d:' % len(skipped)) for line in sorted(set(skipped)): print(' %s' % line) if not args.apply: print('dry run, pass --apply to write') return 0 if __name__ == '__main__': raise SystemExit(main())