#!/usr/bin/env python3 """Insert the missing cast where a generic method loses its type parameter. Almost every remaining error in the fork has one shape: a value whose static type came out erased is used where the enclosing method's type parameter is expected. Object cannot be converted to T Comparable cannot be converted to T Class cannot be converted to Class The cast that is already there was written to the erased supertype, so the fix is to retype it. This is only safe when the line contains exactly one candidate cast, which is the condition checked here; anything ambiguous is reported and left alone. python fix_generic_casts.py [--apply] """ import argparse import os import re ERROR = re.compile(r'^(?P[A-Za-z]:.*?\.java):(?P\d+): error: (?P.*)$') # javac prefixes most of these with "incompatible types: ", some not at all. PREFIX = re.compile(r'^(?:incompatible types:\s*)?') MISMATCH = re.compile(r'^(?P[\w.]+(?:<.*?>)?(?:\[\])?) cannot be converted to (?P\w+)$') GENERIC_MISMATCH = re.compile(r'^Class cannot be converted to Class<(?P\w+)>$') # A method head, used to confirm that a return really has the type we are about # to cast it to. The type is whatever follows the last '>' of the pre-name # segment, so a declaration like "public T getTile()" # reports T and "private static > String getName()" reports # String. Reading it any other way gets generics-bearing declarations wrong. SIGNATURE = re.compile(r'^[ \t]{0,8}(?:(?:public|private|protected|static|final|abstract|' r'synchronized|native|default|strictfp)\s+)*' r'(?P
[\w$.<>,\[\] ]+?)\s+(?P\w+)\s*\([^;]*\)'
                       r'\s*(?:throws\s+[\w., ]+)?\s*\{\s*$')

# if / while / for and friends fit the shape above but are not method heads.
KEYWORDS = frozenset((
    'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'try', 'catch',
    'finally', 'return', 'new', 'synchronized', 'assert', 'throw', 'super',
    'this', 'null', 'true', 'false',
))


def enclosing_return_type(lines, index):
    # The window is generous on purpose: a method body can run long before the
    # value that needs the cast, and missing the declaration would skip a fix
    # that is perfectly safe.
    for back in range(index, max(-1, index - 60), -1):
        sig = SIGNATURE.match(lines[back])
        if sig and sig.group('name') not in KEYWORDS:
            return sig.group('pre').split('>')[-1].strip()
    return None

# Bare single letters and short names are the type variables javac prints.
TYPE_VAR = re.compile(r'^[A-Z]\w{0,2}$')


def candidates(line, from_type):
    """Every `(Type)` cast on the line matching the type javac complained about."""
    plain = from_type.split('<')[0]
    found = []
    for m in re.finditer(r'\(([^()]*)\)', line):
        text = m.group(1).strip()
        if text == plain or text == from_type:
            found.append(m)
    return found


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('errors')
    parser.add_argument('--apply', action='store_true')
    args = parser.parse_args()

    per_file = {}
    skipped = []
    current = None
    with open(args.errors, encoding='utf-8', errors='replace') as handle:
        for raw in handle:
            m = ERROR.match(raw.rstrip('\n').rstrip('\r'))
            if not m:
                continue
            current = (m.group('file'), int(m.group('line')), m.group('msg'))
            msg = PREFIX.sub('', current[2])

            generic = GENERIC_MISMATCH.match(msg)
            if generic:
                to = generic.group('to')
                per_file.setdefault(current[0], []).append(
                    (current[1], to, None, msg))
                continue

            mm = MISMATCH.match(msg)
            if not mm:
                continue
            to = mm.group('to')
            if not TYPE_VAR.match(to):
                continue
            per_file.setdefault(current[0], []).append(
                (current[1], to, mm.group('from'), msg))

    fixed = 0
    files_changed = 0

    for path, items in sorted(per_file.items()):
        if not os.path.isfile(path):
            continue
        with open(path, encoding='utf-8', errors='replace') as handle:
            lines = handle.read().split('\n')

        touched = False
        for item in items:
            lineno, to, from_type, message = item
            if lineno - 1 >= len(lines):
                continue
            line = lines[lineno - 1]

            if from_type is None:
                # Class -> Class: the capture comes out of a getter
                # such as getRawType(), so the cast belongs on the right hand side.
                spots = [m for m in re.finditer(r'=\s*([^=].*?);\s*$', line)]
                if len(spots) != 1:
                    skipped.append('%s:%d %s (assignments=%d)'
                                   % (os.path.basename(path), lineno, message, len(spots)))
                    continue
                m = spots[0]
                lines[lineno - 1] = (line[:m.start(1)]
                                     + '(Class<%s>) ' % to
                                     + line[m.start(1):])
                touched = True
                fixed += 1
                continue

            spots = candidates(line, from_type)
            replacement = '(%s)' % to

            if len(spots) == 1:
                m = spots[0]
                lines[lineno - 1] = line[:m.start()] + replacement + line[m.end():]
                touched = True
                fixed += 1
                continue

            # No cast on the line at all: the value was simply used raw. Insert
            # one immediately after the assignment or the return, which is where
            # the target type is known. A return is only safe when the method
            # actually returns that type variable: casting a String to T would
            # introduce a new error rather than fix the old one.
            anchor = re.search(r'(?<=return )|(?<==\s)(?!=)', line)
            if anchor is None:
                skipped.append('%s:%d %s (no anchor)'
                               % (os.path.basename(path), lineno, message))
                continue
            cut = anchor.end()
            if line[:cut].rstrip().endswith('return'):
                enclosing = enclosing_return_type(lines, lineno - 1)
                if enclosing != to:
                    skipped.append('%s:%d %s (method returns %s, not %s)'
                                   % (os.path.basename(path), lineno, message,
                                      enclosing or '?', to))
                    continue
            lines[lineno - 1] = line[:cut] + replacement + ' ' + line[cut:]
            touched = True
            fixed += 1

        if touched:
            files_changed += 1
            if args.apply:
                with open(path, 'w', encoding='utf-8', newline='\n') as handle:
                    handle.write('\n'.join(lines))

    print('casts rewritten: %d in %d files' % (fixed, files_changed))
    if skipped:
        print('left alone: %d' % len(skipped))
        for line in skipped:
            print('  %s' % line)
    if not args.apply:
        print('dry run, pass --apply to write')
    return 0


if __name__ == '__main__':
    raise SystemExit(main())