#!/usr/bin/env python3 """Search and replace with prompting across a set of input files.""" import os import re import sys import subprocess from dataclasses import dataclass from typing import List, Optional, Tuple # Global state. @dataclass class Context: # ---- Command line ---- # when true, make all changes w/o prompting force: bool = False # when true, don't print proposed changes quiet: bool = False # name of file containing file names to modify file_list_fname: str = "" # when true, print but don't make changes query: bool = False # when this decrements to 0, stop querying and quit query_limit: int = -1 # number of context lines to provide context_lines: int = 2 # when true, use "git ls-files" to get the files git_ls_files: bool = False source_regexp: str = "" replace_string: str = "" # ---- Computed as we run ---- errors: int = 0 modified_files: int = 0 quit: bool = False def usage_and_exit(prog: str) -> None: msg = f"""usage: {prog} [options] source-regexp replace-string [file [file [...]]] This script will interactively prompt to replace 'source-regexp' in the listed files with 'replace-string'. The latter can refer to parenthesized elements in the 'source-regexp' using \\1, \\2, etc. It can also use \\n to insert a newline into the replacement. The source is Python regexp syntax. Example source/replace pairs: '\\bfoo\\b(?!\\()' 'foo()' - turn uses of 'foo' into calls 'foo->([a-zA-Z0-9_]+)\\(' '\\1(foo, ' - method to function options: -list= Use as list of files (one per line) to modify. -git-ls-files Use the output of "git ls-files" as the list to modify. -f Replace without asking. -F Same as -f, but do not print the changes. -q= Query mode; print (but do not make) first changes. -Q Print (but do not make) all changes. -c= Change the number of context lines. Default is 2. -- Terminate options (allows patterns starting with '-'). """ print(msg, end="") sys.exit(2) def parse_args(argv: List[str]) -> Tuple[Context, List[str]]: ctx = Context() i = 0 # process options files_after_opts: List[str] = [] while i < len(argv): arg = argv[i] if not arg.startswith("-") or arg == "-": break i += 1 if arg == "--": break if arg == "-f": ctx.force = True continue if arg == "-F": ctx.force = True ctx.quiet = True continue if arg.startswith("-list="): ctx.file_list_fname = arg.split("=", 1)[1] continue if arg.startswith("-q="): try: ctx.query_limit = int(arg.split("=", 1)[1], 10) except ValueError: print(f"invalid -q value: {arg}", file=sys.stderr) sys.exit(2) ctx.query = True continue if arg == "-Q": ctx.query = True ctx.query_limit = -1 continue if arg.startswith("-c="): try: ctx.context_lines = int(arg.split("=", 1)[1], 10) except ValueError: print(f"invalid -c value: {arg}", file=sys.stderr) sys.exit(2) continue if arg == "-git-ls-files": ctx.git_ls_files = True continue print(f"unknown option: {arg}", file=sys.stderr) sys.exit(2) # Remaining args from i onwards rest = argv[i:] if len(rest) < 2: usage_and_exit(os.path.basename(sys.argv[0])) ctx.source_regexp = rest[0] ctx.replace_string = rest[1] files_after_opts = rest[2:] return ctx, files_after_opts def is_text_file(path: str) -> bool: try: with open(path, "rb") as f: chunk = f.read(8192) except OSError: return False if b"\x00" in chunk: return False return True def print_change_line(line: str) -> None: # Expect line to include trailing newline; we ignore it but print a '$' at end. content = line[:-1] if line.endswith("\n") else line out_parts: List[str] = [] for ch in content: c = ord(ch) if c == 9: # tab out_parts.append("\\t") elif 32 <= c <= 126: out_parts.append(ch) elif c == 10: # newline out_parts.append("$\n+") else: out_parts.append(f"\\x{c:02X}") out_parts.append("$\n") sys.stdout.write("".join(out_parts)) def replacement_text(replace_string: str, groups: Tuple[Optional[str], ...]) -> str: ret = replace_string # Replace up to 9 backreferences \1..\9 for i in range(9): g = groups[i] if i < len(groups) else None if g is not None: ref = f"\\{i+1}" ret = ret.replace(ref, g) # Replace "\n" with a newline. ret = ret.replace("\\n", "\n") return ret def do_replacements_in_file(ctx: Context, fname: str, pattern: re.Pattern) -> None: if os.path.isdir(fname): print(f"skipping directory: {fname}") return if not os.path.isfile(fname): print(f"not a file: {fname}") ctx.errors += 1 return if not is_text_file(fname): print(f"skipping non-text file: {fname}") return if not os.access(fname, os.W_OK): print(f"skipping non-writable file: {fname}") return if os.environ.get("REPLACE_ACROSS_FILES_PRINT_FILES_ONLY"): print(fname) return # read the file try: with open(fname, "r", newline="") as f: lines = f.readlines() except OSError as e: print(f"cannot read {fname}: {e}") ctx.errors += 1 return replacements = 0 quit_this_file = False force_this_file = ctx.force first_proposal = True for i in range(len(lines)): if quit_this_file: break original = lines[i] # Perform a trial replacement across the entire line def repl(m: re.Match) -> str: return replacement_text(ctx.replace_string, m.groups()) replaced = pattern.sub(repl, original) if replaced != original: if not ctx.quiet: if first_proposal: print(f"--- {fname}") first_proposal = False print(f"@@ {i+1} @@") # context before j = max(0, i - ctx.context_lines) while j < i: print_change_line(" " + lines[j]) j += 1 # proposed change print_change_line("-" + original) print_change_line("+" + replaced) # context after j = i + 1 upper = min(len(lines) - 1, i + ctx.context_lines) while j < len(lines) and j <= upper: print_change_line(" " + lines[j]) j += 1 if ctx.query: if ctx.query_limit != -1: ctx.query_limit -= 1 if ctx.query_limit == 0: quit_this_file = True ctx.quit = True continue # do not modify if force_this_file: lines[i] = replaced replacements += 1 else: while True: try: # autoflush before prompt sys.stdout.write("replace (y/n/Y/N/q/!/?)? [y] ") sys.stdout.flush() response = sys.stdin.readline() except Exception: response = "" if not response: print("\ninput EOF, bailing; you cannot pipe in with xargs...") sys.exit(2) response = response.rstrip("\n") if response == "?": help_txt = """commands: y - make the proposed change n - do not make the proposed change Y - make this and all future proposed changes in this file N - make no more changes in this file q - quit this program, making only previously indicated changes ! - make this and all future proposed changes ? - print this command reference """ print(help_txt, end="") elif response == "" or response == "y": lines[i] = replaced replacements += 1 break elif response == "n": break elif response == "Y": lines[i] = replaced replacements += 1 force_this_file = True break elif response == "N": quit_this_file = True break elif response == "q": quit_this_file = True ctx.quit = True break elif response == "!": lines[i] = replaced replacements += 1 force_this_file = True ctx.force = True break if replacements == 0: return try: with open(fname, "w", newline="") as f: f.writelines(lines) except OSError as e: print(f"cannot write {fname}: {e}", file=sys.stderr) sys.exit(1) print(f"{fname:}: modified {replacements} lines") ctx.modified_files += 1 def main() -> None: ctx, files_from_args = parse_args(sys.argv[1:]) # compile pattern try: pattern = re.compile(ctx.source_regexp) except re.error as e: print(f"invalid source-regexp: {e}", file=sys.stderr) sys.exit(2) # process -list file if ctx.file_list_fname: try: with open(ctx.file_list_fname, "r", newline="") as f: for line in f: if ctx.quit: break fname = line.rstrip("\n") if not fname: continue do_replacements_in_file(ctx, fname, pattern) except OSError as e: print(f"cannot read {ctx.file_list_fname}: {e}", file=sys.stderr) sys.exit(1) # process -git-ls-files if ctx.git_ls_files and not ctx.quit: try: proc = subprocess.run( ["git", "ls-files"], check=False, capture_output=True, text=True ) files = proc.stdout.splitlines() except Exception: files = [] for fname in files: if ctx.quit: break do_replacements_in_file(ctx, fname, pattern) # now process files from argv for fname in files_from_args: if ctx.quit: break do_replacements_in_file(ctx, fname, pattern) if ctx.errors: print(f"failed to read {ctx.errors} files from the list") sys.exit(4) if ctx.modified_files: print(f"modified {ctx.modified_files} files") sys.exit(0) if __name__ == "__main__": main()