| #!/usr/bin/env python |
| # Copyright (c) 2013 The Chromium Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| """Applies edits generated by a clang tool that was run on Chromium code. |
| |
| Synopsis: |
| |
| cat run_tool.out | extract_edits.py | apply_edits.py <build dir> <filters...> |
| |
| For example - to apply edits only to WTF sources: |
| |
| ... | apply_edits.py out/gn third_party/WebKit/Source/wtf |
| |
| In addition to filters specified on the command line, the tool also skips edits |
| that apply to files that are not covered by git. |
| """ |
| |
| import argparse |
| import collections |
| import functools |
| import multiprocessing |
| import os |
| import os.path |
| import subprocess |
| import sys |
| |
| script_dir = os.path.dirname(os.path.realpath(__file__)) |
| tool_dir = os.path.abspath(os.path.join(script_dir, '../pylib')) |
| sys.path.insert(0, tool_dir) |
| |
| from clang import compile_db |
| |
| Edit = collections.namedtuple('Edit', |
| ('edit_type', 'offset', 'length', 'replacement')) |
| |
| |
| def _GetFilesFromGit(paths=None): |
| """Gets the list of files in the git repository. |
| |
| Args: |
| paths: Prefix filter for the returned paths. May contain multiple entries. |
| """ |
| args = [] |
| if sys.platform == 'win32': |
| args.append('git.bat') |
| else: |
| args.append('git') |
| args.append('ls-files') |
| if paths: |
| args.extend(paths) |
| command = subprocess.Popen(args, stdout=subprocess.PIPE) |
| output, _ = command.communicate() |
| return [os.path.realpath(p) for p in output.splitlines()] |
| |
| |
| def _ParseEditsFromStdin(build_directory): |
| """Extracts generated list of edits from the tool's stdout. |
| |
| The expected format is documented at the top of this file. |
| |
| Args: |
| build_directory: Directory that contains the compile database. Used to |
| normalize the filenames. |
| stdout: The stdout from running the clang tool. |
| |
| Returns: |
| A dictionary mapping filenames to the associated edits. |
| """ |
| path_to_resolved_path = {} |
| |
| def _ResolvePath(path): |
| if path in path_to_resolved_path: |
| return path_to_resolved_path[path] |
| |
| if not os.path.isfile(path): |
| resolved_path = os.path.realpath(os.path.join(build_directory, path)) |
| else: |
| resolved_path = path |
| |
| if not os.path.isfile(resolved_path): |
| sys.stderr.write('Edit applies to a non-existent file: %s\n' % path) |
| resolved_path = None |
| |
| path_to_resolved_path[path] = resolved_path |
| return resolved_path |
| |
| edits = collections.defaultdict(list) |
| for line in sys.stdin: |
| line = line.rstrip('\n\r') |
| try: |
| edit_type, path, offset, length, replacement = line.split(':::', 4) |
| replacement = replacement.replace('\0', '\n') |
| path = _ResolvePath(path) |
| if not path: |
| continue |
| edits[path].append(Edit(edit_type, int(offset), int(length), replacement)) |
| except ValueError: |
| sys.stderr.write('Unable to parse edit: %s\n' % line) |
| return edits |
| |
| |
| def _ApplyEditsToSingleFile(filename, edits): |
| # Sort the edits and iterate through them in reverse order. Sorting allows |
| # duplicate edits to be quickly skipped, while reversing means that |
| # subsequent edits don't need to have their offsets updated with each edit |
| # applied. |
| edit_count = 0 |
| error_count = 0 |
| edits.sort() |
| last_edit = None |
| with open(filename, 'rb+') as f: |
| contents = bytearray(f.read()) |
| for edit in reversed(edits): |
| if edit == last_edit: |
| continue |
| if (last_edit is not None and edit.edit_type == last_edit.edit_type and |
| edit.offset == last_edit.offset and edit.length == last_edit.length): |
| sys.stderr.write( |
| 'Conflicting edit: %s at offset %d, length %d: "%s" != "%s"\n' % |
| (filename, edit.offset, edit.length, edit.replacement, |
| last_edit.replacement)) |
| error_count += 1 |
| continue |
| |
| last_edit = edit |
| contents[edit.offset:edit.offset + edit.length] = edit.replacement |
| if not edit.replacement: |
| _ExtendDeletionIfElementIsInList(contents, edit.offset) |
| edit_count += 1 |
| f.seek(0) |
| f.truncate() |
| f.write(contents) |
| return (edit_count, error_count) |
| |
| |
| def _ApplyEdits(edits): |
| """Apply the generated edits. |
| |
| Args: |
| edits: A dict mapping filenames to Edit instances that apply to that file. |
| """ |
| edit_count = 0 |
| error_count = 0 |
| done_files = 0 |
| for k, v in edits.iteritems(): |
| tmp_edit_count, tmp_error_count = _ApplyEditsToSingleFile(k, v) |
| edit_count += tmp_edit_count |
| error_count += tmp_error_count |
| done_files += 1 |
| percentage = (float(done_files) / len(edits)) * 100 |
| sys.stdout.write('Applied %d edits (%d errors) to %d files [%.2f%%]\r' % |
| (edit_count, error_count, done_files, percentage)) |
| |
| sys.stdout.write('\n') |
| return -error_count |
| |
| |
| _WHITESPACE_BYTES = frozenset((ord('\t'), ord('\n'), ord('\r'), ord(' '))) |
| |
| |
| def _ExtendDeletionIfElementIsInList(contents, offset): |
| """Extends the range of a deletion if the deleted element was part of a list. |
| |
| This rewriter helper makes it easy for refactoring tools to remove elements |
| from a list. Even if a matcher callback knows that it is removing an element |
| from a list, it may not have enough information to accurately remove the list |
| element; for example, another matcher callback may end up removing an adjacent |
| list element, or all the list elements may end up being removed. |
| |
| With this helper, refactoring tools can simply remove the list element and not |
| worry about having to include the comma in the replacement. |
| |
| Args: |
| contents: A bytearray with the deletion already applied. |
| offset: The offset in the bytearray where the deleted range used to be. |
| """ |
| char_before = char_after = None |
| left_trim_count = 0 |
| for byte in reversed(contents[:offset]): |
| left_trim_count += 1 |
| if byte in _WHITESPACE_BYTES: |
| continue |
| if byte in (ord(','), ord(':'), ord('('), ord('{')): |
| char_before = chr(byte) |
| break |
| |
| right_trim_count = 0 |
| for byte in contents[offset:]: |
| right_trim_count += 1 |
| if byte in _WHITESPACE_BYTES: |
| continue |
| if byte == ord(','): |
| char_after = chr(byte) |
| break |
| |
| if char_before: |
| if char_after: |
| del contents[offset:offset + right_trim_count] |
| elif char_before in (',', ':'): |
| del contents[offset - left_trim_count:offset] |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser() |
| parser.add_argument( |
| '-p', |
| required=True, |
| help='path to the build dir (dir that edit paths are relative to)') |
| parser.add_argument( |
| 'path_filter', |
| nargs='*', |
| help='optional paths to filter what files the tool is run on') |
| args = parser.parse_args() |
| |
| filenames = set(_GetFilesFromGit(args.path_filter)) |
| edits = _ParseEditsFromStdin(args.p) |
| return _ApplyEdits( |
| {k: v for k, v in edits.iteritems() if os.path.realpath(k) in filenames}) |
| |
| |
| if __name__ == '__main__': |
| sys.exit(main()) |