1#!/usr/bin/env python
2# Copyright (c) 2013 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Usage: mffr.py [-d] [-g *.h] [-g *.cc] REGEXP REPLACEMENT
7
8This tool performs a fast find-and-replace operation on files in
9the current git repository.
10
11The -d flag selects a default set of globs (C++ and Objective-C/C++
12source files). The -g flag adds a single glob to the list and may
13be used multiple times. If neither -d nor -g is specified, the tool
14searches all files (*.*).
15
16REGEXP uses full Python regexp syntax. REPLACEMENT can use
17back-references.
18"""
19
20from __future__ import print_function
21
22import optparse
23import os
24import re
25import subprocess
26import sys
27
28
29# We can't use shell=True because of the vast and sundry crazy characters we
30# try to pass through to git grep. depot_tools packages a git .bat around
31# a git.cmd around git.exe, which makes it impossible to escape the characters
32# properly. Instead, locate the git .exe up front here. We use cd / && pwd -W,
33# which first changes to the git install root. Inside git bash this "/" is where
34# it hosts a fake /usr, /bin, /etc, ..., but then we use -W to pwd to print the
35# Windows version of the path. Once we have the .exe directly, then we no longer
36# need to use shell=True to subprocess calls, so escaping becomes simply for
37# quotes for CreateProcess(), rather than |, <, >, etc. through multiple layers
38# of cmd.
39if sys.platform == 'win32':
40  _git = os.path.normpath(os.path.join(subprocess.check_output(
41      'git bash -c "cd / && pwd -W"', shell=True).strip(), 'bin\\git.exe'))
42else:
43  _git = 'git'
44
45
46def MultiFileFindReplace(original, replacement, file_globs):
47  """Implements fast multi-file find and replace.
48
49  Given an |original| string and a |replacement| string, find matching
50  files by running git grep on |original| in files matching any
51  pattern in |file_globs|.
52
53  Once files are found, |re.sub| is run to replace |original| with
54  |replacement|.  |replacement| may use capture group back-references.
55
56  Args:
57    original: '(#(include|import)\s*["<])chrome/browser/ui/browser.h([>"])'
58    replacement: '\1chrome/browser/ui/browser/browser.h\3'
59    file_globs: ['*.cc', '*.h', '*.m', '*.mm']
60
61  Returns the list of files modified.
62
63  Raises an exception on error.
64  """
65  # Posix extended regular expressions do not reliably support the "\s"
66  # shorthand.
67  posix_ere_original = re.sub(r"\\s", "[[:space:]]", original)
68  if sys.platform == 'win32':
69    posix_ere_original = posix_ere_original.replace('"', '""')
70  out, err = subprocess.Popen(
71      [_git, 'grep', '-E', '--name-only', posix_ere_original,
72       '--'] + file_globs,
73      stdout=subprocess.PIPE).communicate()
74  referees = out.splitlines()
75
76  for referee in referees:
77    with open(referee) as f:
78      original_contents = f.read()
79    contents = re.sub(original, replacement, original_contents)
80    if contents == original_contents:
81      raise Exception('No change in file %s although matched in grep' %
82                      referee)
83    with open(referee, 'wb') as f:
84      f.write(contents)
85
86  return referees
87
88
89def main():
90  parser = optparse.OptionParser(usage='''
91(1) %prog <options> REGEXP REPLACEMENT
92REGEXP uses full Python regexp syntax. REPLACEMENT can use back-references.
93
94(2) %prog <options> -i <file>
95<file> should contain a list (in Python syntax) of
96[REGEXP, REPLACEMENT, [GLOBS]] lists, e.g.:
97[
98  [r"(foo|bar)", r"\1baz", ["*.cc", "*.h"]],
99  ["54", "42"],
100]
101As shown above, [GLOBS] can be omitted for a given search-replace list, in which
102case the corresponding search-replace will use the globs specified on the
103command line.''')
104  parser.add_option('-d', action='store_true',
105                    dest='use_default_glob',
106                    help='Perform the change on C++ and Objective-C(++) source '
107                    'and header files.')
108  parser.add_option('-f', action='store_true',
109                    dest='force_unsafe_run',
110                    help='Perform the run even if there are uncommitted local '
111                    'changes.')
112  parser.add_option('-g', action='append',
113                    type='string',
114                    default=[],
115                    metavar="<glob>",
116                    dest='user_supplied_globs',
117                    help='Perform the change on the specified glob. Can be '
118                    'specified multiple times, in which case the globs are '
119                    'unioned.')
120  parser.add_option('-i', "--input_file",
121                    type='string',
122                    action='store',
123                    default='',
124                    metavar="<file>",
125                    dest='input_filename',
126                    help='Read arguments from <file> rather than the command '
127                    'line. NOTE: To be sure of regular expressions being '
128                    'interpreted correctly, use raw strings.')
129  opts, args = parser.parse_args()
130  if opts.use_default_glob and opts.user_supplied_globs:
131    print('"-d" and "-g" cannot be used together')
132    parser.print_help()
133    return 1
134
135  from_file = opts.input_filename != ""
136  if (from_file and len(args) != 0) or (not from_file and len(args) != 2):
137    parser.print_help()
138    return 1
139
140  if not opts.force_unsafe_run:
141    out, err = subprocess.Popen([_git, 'status', '--porcelain'],
142                                stdout=subprocess.PIPE).communicate()
143    if out:
144      print('ERROR: This tool does not print any confirmation prompts,')
145      print('so you should only run it with a clean staging area and cache')
146      print('so that reverting a bad find/replace is as easy as running')
147      print('  git checkout -- .')
148      print('')
149      print('To override this safeguard, pass the -f flag.')
150      return 1
151
152  global_file_globs = ['*.*']
153  if opts.use_default_glob:
154    global_file_globs = ['*.cc', '*.h', '*.m', '*.mm']
155  elif opts.user_supplied_globs:
156    global_file_globs = opts.user_supplied_globs
157
158  # Construct list of search-replace tasks.
159  search_replace_tasks = []
160  if opts.input_filename == '':
161    original = args[0]
162    replacement = args[1]
163    search_replace_tasks.append([original, replacement, global_file_globs])
164  else:
165    f = open(opts.input_filename)
166    search_replace_tasks = eval("".join(f.readlines()))
167    for task in search_replace_tasks:
168      if len(task) == 2:
169        task.append(global_file_globs)
170    f.close()
171
172  for (original, replacement, file_globs) in search_replace_tasks:
173    print('File globs:  %s' % file_globs)
174    print('Original:    %s' % original)
175    print('Replacement: %s' % replacement)
176    MultiFileFindReplace(original, replacement, file_globs)
177  return 0
178
179
180if __name__ == '__main__':
181  sys.exit(main())
182