#!/usr/bin/env python3 # 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. """Usage: mffr.py [-d] [-g *.h] [-g *.cc] REGEXP REPLACEMENT This tool performs a fast find-and-replace operation on files in the current git repository. The -d flag selects a default set of globs (C++ and Objective-C/C++ source files). The -g flag adds a single glob to the list and may be used multiple times. If neither -d nor -g is specified, the tool searches all files (*.*). REGEXP uses full Python regexp syntax. REPLACEMENT can use back-references. """ from __future__ import print_function import optparse import os import re import subprocess import sys # We can't use shell=True because of the vast and sundry crazy characters we # try to pass through to git grep. depot_tools packages a git .bat around # a git.cmd around git.exe, which makes it impossible to escape the characters # properly. Instead, locate the git .exe up front here. We use cd / && pwd -W, # which first changes to the git install root. Inside git bash this "/" is where # it hosts a fake /usr, /bin, /etc, ..., but then we use -W to pwd to print the # Windows version of the path. Once we have the .exe directly, then we no longer # need to use shell=True to subprocess calls, so escaping becomes simply for # quotes for CreateProcess(), rather than |, <, >, etc. through multiple layers # of cmd. if sys.platform == 'win32': _git = os.path.normpath( os.path.join( subprocess.check_output('git bash -c "cd / && pwd -W"', shell=True).decode('utf-8').strip(), 'bin\\git.exe')) else: _git = 'git' def MultiFileFindReplace(original, replacement, file_globs): """Implements fast multi-file find and replace. Given an |original| string and a |replacement| string, find matching files by running git grep on |original| in files matching any pattern in |file_globs|. Once files are found, |re.sub| is run to replace |original| with |replacement|. |replacement| may use capture group back-references. Args: original: '(#(include|import)\s*["<])chrome/browser/ui/browser.h([>"])' replacement: '\1chrome/browser/ui/browser/browser.h\3' file_globs: ['*.cc', '*.h', '*.m', '*.mm'] Returns the list of files modified. Raises an exception on error. """ # Posix extended regular expressions do not reliably support the "\s" # shorthand. posix_ere_original = re.sub(r"\\s", "[[:space:]]", original) if sys.platform == 'win32': posix_ere_original = posix_ere_original.replace('"', '""') out, err = subprocess.Popen( [_git, 'grep', '-E', '--name-only', posix_ere_original, '--'] + file_globs, stdout=subprocess.PIPE).communicate() referees = out.splitlines() for referee in referees: with open(referee, encoding='utf-8') as f: original_contents = f.read() contents = re.sub(original, replacement, original_contents) if contents == original_contents: raise Exception('No change in file %s although matched in grep' % referee) with open(referee, mode='w', encoding='utf-8', newline='\n') as f: f.write(contents) return referees def main(): parser = optparse.OptionParser(usage=''' (1) %prog REGEXP REPLACEMENT REGEXP uses full Python regexp syntax. REPLACEMENT can use back-references. (2) %prog -i should contain a list (in Python syntax) of [REGEXP, REPLACEMENT, [GLOBS]] lists, e.g.: [ [r"(foo|bar)", r"\1baz", ["*.cc", "*.h"]], ["54", "42"], ] As shown above, [GLOBS] can be omitted for a given search-replace list, in which case the corresponding search-replace will use the globs specified on the command line.''') parser.add_option('-d', action='store_true', dest='use_default_glob', help='Perform the change on C++ and Objective-C(++) source ' 'and header files.') parser.add_option('-f', action='store_true', dest='force_unsafe_run', help='Perform the run even if there are uncommitted local ' 'changes.') parser.add_option('-g', action='append', type='string', default=[], metavar="", dest='user_supplied_globs', help='Perform the change on the specified glob. Can be ' 'specified multiple times, in which case the globs are ' 'unioned.') parser.add_option('-i', "--input_file", type='string', action='store', default='', metavar="", dest='input_filename', help='Read arguments from rather than the command ' 'line. NOTE: To be sure of regular expressions being ' 'interpreted correctly, use raw strings.') opts, args = parser.parse_args() if opts.use_default_glob and opts.user_supplied_globs: print('"-d" and "-g" cannot be used together') parser.print_help() return 1 from_file = opts.input_filename != "" if (from_file and len(args) != 0) or (not from_file and len(args) != 2): parser.print_help() return 1 if not opts.force_unsafe_run: out, err = subprocess.Popen([_git, 'status', '--porcelain'], stdout=subprocess.PIPE).communicate() if out: print('ERROR: This tool does not print any confirmation prompts,') print('so you should only run it with a clean staging area and cache') print('so that reverting a bad find/replace is as easy as running') print(' git checkout -- .') print('') print('To override this safeguard, pass the -f flag.') return 1 global_file_globs = ['*.*'] if opts.use_default_glob: global_file_globs = ['*.cc', '*.h', '*.m', '*.mm'] elif opts.user_supplied_globs: global_file_globs = opts.user_supplied_globs # Construct list of search-replace tasks. search_replace_tasks = [] if opts.input_filename == '': original = args[0] replacement = args[1] search_replace_tasks.append([original, replacement, global_file_globs]) else: f = open(opts.input_filename) search_replace_tasks = eval("".join(f.readlines())) for task in search_replace_tasks: if len(task) == 2: task.append(global_file_globs) f.close() for (original, replacement, file_globs) in search_replace_tasks: print('File globs: %s' % file_globs) print('Original: %s' % original) print('Replacement: %s' % replacement) MultiFileFindReplace(original, replacement, file_globs) return 0 if __name__ == '__main__': sys.exit(main())