diff --git a/utils_maintenance/code_clean.py b/utils_maintenance/code_clean.py new file mode 100755 index 0000000000000000000000000000000000000000..a0372a4ea61da019d3b1695dc523a3217849f2fa --- /dev/null +++ b/utils_maintenance/code_clean.py @@ -0,0 +1,546 @@ +#!/usr/bin/env python3 +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +# <pep8-80 compliant> + +""" +Example: + ./source/tools/utils/code_clean.py /src/cmake_debug --match ".*/editmesh_.*" + +Note: currently this is limited to paths in "source/" and "intern/", +we could change this if it's needed. +""" + +import re +import subprocess +import sys +import os + +USE_MULTIPROCESS = False + +VERBOSE = True + +# Print the output of the compiler (_very_ noisy, only useful for troubleshooting compiler issues). +VERBOSE_COMPILER = False + + +# ----------------------------------------------------------------------------- +# General Utilities + +# Note that we could use a hash, however there is no advantage, compare it's contents. +def file_as_bytes(filename): + with open(filename, 'rb') as fh: + return fh.read() + + +def line_from_span(text, start, end): + while start > 0 and text[start - 1] != '\n': + start -= 1 + while end < len(text) and text[end] != '\n': + end += 1 + return text[start:end] + + +# ----------------------------------------------------------------------------- +# Execution Wrappers + +def run(args, *, quiet): + if VERBOSE_COMPILER and not quiet: + out = sys.stdout + else: + out = subprocess.DEVNULL + + import shlex + p = subprocess.Popen(shlex.split(args), stdout=out, stderr=out) + p.wait() + return p.returncode + + +# ----------------------------------------------------------------------------- +# Build System Access + +def cmake_cache_var(cmake_dir, var): + cache_file = open(os.path.join(cmake_dir, "CMakeCache.txt"), encoding='utf-8') + lines = [l_strip for l in cache_file for l_strip in (l.strip(),) + if l_strip if not l_strip.startswith("//") if not l_strip.startswith("#")] + cache_file.close() + + for l in lines: + if l.split(":")[0] == var: + return l.split("=", 1)[-1] + return None + + +RE_CFILE_SEARCH = re.compile(r"\s\-c\s([\S]+)") + + +def process_commands(cmake_dir, data): + compiler_c = cmake_cache_var(cmake_dir, "CMAKE_C_COMPILER") + compiler_cxx = cmake_cache_var(cmake_dir, "CMAKE_CXX_COMPILER") + file_args = [] + + for l in data: + if ( + (compiler_c in l) or + (compiler_cxx in l) + ): + # Extract: + # -c SOME_FILE + c_file_search = re.search(RE_CFILE_SEARCH, l) + if c_file_search is not None: + c_file = c_file_search.group(1) + file_args.append((c_file, l)) + else: + # could print, NO C FILE FOUND? + pass + + file_args.sort() + + return file_args + + +def find_build_args_ninja(build_dir): + cmake_dir = build_dir + make_exe = "ninja" + process = subprocess.Popen( + [make_exe, "-t", "commands"], + stdout=subprocess.PIPE, + cwd=build_dir, + ) + while process.poll(): + time.sleep(1) + + out = process.stdout.read() + process.stdout.close() + # print("done!", len(out), "bytes") + data = out.decode("utf-8", errors="ignore").split("\n") + return process_commands(cmake_dir, data) + + +def find_build_args_make(build_dir): + make_exe = "make" + process = subprocess.Popen( + [make_exe, "--always-make", "--dry-run", "--keep-going", "VERBOSE=1"], + stdout=subprocess.PIPE, + cwd=build_dir, + ) + while process.poll(): + time.sleep(1) + + out = process.stdout.read() + process.stdout.close() + + # print("done!", len(out), "bytes") + data = out.decode("utf-8", errors="ignore").split("\n") + return process_commands(cmake_dir, data) + + +# ----------------------------------------------------------------------------- +# Create Edit Lists + +# Create an edit list from a file, in the format: +# +# [((start_index, end_index), text_to_replace), ...] +# +# Note that edits should not overlap, in the _very_ rare case overlapping edits are needed, +# this could be run multiple times on the same code-base. +# +# Although this seems like it's not a common use-case. + +def edit_list_from_file__sizeof_fixed_array(_source, data): + edits = [] + + for match in re.finditer(r"sizeof\(([a-zA-Z_]+)\) \* (\d+) \* (\d+)", data): + edits.append(( + match.span(), + 'sizeof(%s[%s][%s])' % (match.group(1), match.group(2), match.group(3)), + '__ALWAYS_FAIL__', + )) + + for match in re.finditer(r"sizeof\(([a-zA-Z_]+)\) \* (\d+)", data): + edits.append(( + match.span(), + 'sizeof(%s[%s])' % (match.group(1), match.group(2)), + '__ALWAYS_FAIL__', + )) + + for match in re.finditer(r"\b(\d+) \* sizeof\(([a-zA-Z_]+)\)", data): + edits.append(( + match.span(), + 'sizeof(%s[%s])' % (match.group(2), match.group(1)), + '__ALWAYS_FAIL__', + )) + return edits + + +def edit_list_from_file__use_const(_source, data): + edits = [] + + # Replace: + # float abc[3] = {0, 1, 2}; + # With: + # const float abc[3] = {0, 1, 2}; + + for match in re.finditer(r"(\(|, | )([a-zA-Z_0-9]+ [a-zA-Z_0-9]+\[)\b([^\n]+ = )", data): + edits.append(( + match.span(), + '%s const %s%s' % (match.group(1), match.group(2), match.group(3)), + '__ALWAYS_FAIL__', + )) + + # Replace: + # float abc[3] + # With: + # const float abc[3] + for match in re.finditer(r"(\(|, )([a-zA-Z_0-9]+ [a-zA-Z_0-9]+\[)", data): + edits.append(( + match.span(), + '%s const %s' % (match.group(1), match.group(2)), + '__ALWAYS_FAIL__', + )) + + return edits + + +def edit_list_from_file__use_const_vars(_source, data): + edits = [] + + # Replace: + # float abc[3] = {0, 1, 2}; + # With: + # const float abc[3] = {0, 1, 2}; + + # for match in re.finditer(r"( [a-zA-Z0-9_]+ [a-zA-Z0-9_]+ = [A-Z][A-Z_0-9_]*;)", data): + # edits.append(( + # match.span(), + # 'const %s' % (match.group(1).lstrip()), + # '__ALWAYS_FAIL__', + # )) + + for match in re.finditer(r"( [a-zA-Z0-9_]+ [a-zA-Z0-9_]+ = .*;)", data): + edits.append(( + match.span(), + 'const %s' % (match.group(1).lstrip()), + '__ALWAYS_FAIL__', + )) + + return edits + + +def edit_list_from_file__return_parens(_source, data): + edits = [] + + # Remove `return (NULL);` + for match in re.finditer(r"return \(([a-zA-Z_0-9]+)\);", data): + edits.append(( + match.span(), + 'return %s;' % (match.group(1)), + 'return __ALWAYS_FAIL__;', + )) + return edits + + +def edit_list_from_file__use_streq_macro(_source, data): + edits = [] + + # Replace: + # strcmp(a, b) == 0 + # With: + # STREQ(a, b) + for match in re.finditer(r"\bstrcmp\((.*)\) == 0", data): + edits.append(( + match.span(), + 'STREQ(%s)' % (match.group(1)), + '__ALWAYS_FAIL__', + )) + for match in re.finditer(r"!strcmp\((.*)\)", data): + edits.append(( + match.span(), + 'STREQ(%s)' % (match.group(1)), + '__ALWAYS_FAIL__', + )) + + # Replace: + # strcmp(a, b) != 0 + # With: + # !STREQ(a, b) + for match in re.finditer(r"\bstrcmp\((.*)\) != 0", data): + edits.append(( + match.span(), + '!STREQ(%s)' % (match.group(1)), + '__ALWAYS_FAIL__', + )) + for match in re.finditer(r"\bstrcmp\((.*)\)", data): + edits.append(( + match.span(), + '!STREQ(%s)' % (match.group(1)), + '__ALWAYS_FAIL__', + )) + + return edits + + +def edit_list_from_file__use_array_size_macro(_source, data): + edits = [] + + # Replace: + # sizeof(foo) / sizeof(*foo) + # With: + # ARRAY_SIZE(foo) + # + # Note that this replacement is only valid in some cases, + # so only apply with validation that binary output matches. + for match in re.finditer(r"\bsizeof\((.*)\) / sizeof\([^\)]+\)", data): + edits.append(( + match.span(), + 'ARRAY_SIZE(%s)' % match.group(1), + '__ALWAYS_FAIL__', + )) + + return edits + + +def test_edit(source, output, output_bytes, build_args, data, data_test, keep_edits=True, expect_failure=False): + """ + Return true if `data_test` has the same object output as `data`. + """ + if os.path.exists(output): + os.remove(output) + + with open(source, 'w', encoding='utf-8') as fh: + fh.write(data_test) + + ret = run(build_args, quiet=expect_failure) + if ret == 0: + output_bytes_test = file_as_bytes(output) + if (output_bytes is None) or (file_as_bytes(output) == output_bytes): + if not keep_edits: + with open(source, 'w', encoding='utf-8') as fh: + fh.write(data) + return True + else: + print("Changed code, skip...", hex(hash(output_bytes)), hex(hash(output_bytes_test))) + else: + if not expect_failure: + print("Failed to compile, skip...") + + with open(source, 'w', encoding='utf-8') as fh: + fh.write(data) + return False + + +# ----------------------------------------------------------------------------- +# Accept / Reject Edits + +def apply_edit(data, text_to_replace, start, end, *, verbose): + if verbose: + line_before = line_from_span(data, start, end) + + data = data[:start] + text_to_replace + data[end:] + + if verbose: + end += len(text_to_replace) - (end - start) + line_after = line_from_span(data, start, end) + + print("") + print("Testing edit:") + print(line_before) + print(line_after) + + return data + + +def wash_source_with_edits(arg_group): + (source, output, build_args, skip_test) = arg_group + # build_args = build_args + " -Werror=duplicate-decl-specifier" + with open(source, 'r', encoding='utf-8') as fh: + data = fh.read() + edits = edit_list_from_file__use_const_vars(source, data) + edits.sort(reverse=True) + if not edits: + return + + if skip_test: + # Just apply all edits. + for (start, end), text, text_always_fail in edits: + data = apply_edit(data, text, start, end, verbose=VERBOSE) + with open(source, 'w', encoding='utf-8') as fh: + fh.write(data) + return + + test_edit( + source, output, None, build_args, data, data, + keep_edits=False, + ) + if not os.path.exists(output): + raise Exception("Failed to produce output file: " + output) + output_bytes = file_as_bytes(output) + + for (start, end), text, text_always_fail in edits: + data_test = apply_edit(data, text, start, end, verbose=VERBOSE) + if test_edit( + source, output, output_bytes, build_args, data, data_test, + keep_edits=False, + ): + # This worked, check if the change would fail if replaced with 'text_always_fail'. + data_test_always_fail = apply_edit(data, text_always_fail, start, end, verbose=False) + if test_edit( + source, output, output_bytes, build_args, data, data_test_always_fail, + expect_failure=True, keep_edits=False, + ): + print("Edit at", (start, end), "doesn't fail, assumed to be ifdef'd out, continuing") + continue + + # Apply the edit. + data = data_test + with open(source, 'w', encoding='utf-8') as fh: + fh.write(data) + + +# ----------------------------------------------------------------------------- +# Edit Source Code From Args + +def header_clean_all(build_dir, regex_list, skip_test=False): + # currently only supports ninja or makefiles + build_file_ninja = os.path.join(build_dir, "build.ninja") + build_file_make = os.path.join(build_dir, "Makefile") + if os.path.exists(build_file_ninja): + print("Using Ninja") + args = find_build_args_ninja(build_dir) + elif os.path.exists(build_file_make): + print("Using Make") + args = find_build_args_make(build_dir) + else: + sys.stderr.write( + "Can't find Ninja or Makefile (%r or %r), aborting" % + (build_file_ninja, build_file_make) + ) + return + # needed for when arguments are referenced relatively + os.chdir(build_dir) + + # Weak, but we probably don't want to handle extern. + # this limit could be removed. + source_paths = ( + os.path.join("intern", "ghost"), + os.path.join("intern", "guardedalloc"), + os.path.join("source"), + ) + + def output_from_build_args(build_args): + import shlex + build_args = shlex.split(build_args) + i = build_args.index("-o") + return build_args[i + 1] + + def test_path(c): + for source_path in source_paths: + index = c.rfind(source_path) + print(c) + if index != -1: + # Remove first part of the path, we don't want to match + # against paths in Blender's repo. + print(source_path) + c_strip = c[index:] + for regex in regex_list: + if regex.match(c_strip) is not None: + return True + return False + + # Filter out build args. + args_orig_len = len(args) + args = [ + (c, build_args) + for (c, build_args) in args + if test_path(c) + ] + print("Operating on %d of %d files..." % (len(args), args_orig_len)) + for (c, build_args) in args: + print(" ", c) + del args_orig_len + + if USE_MULTIPROCESS: + args = [ + (c, output_from_build_args(build_args), build_args, skip_test) + for (c, build_args) in args + ] + import multiprocessing + job_total = multiprocessing.cpu_count() + pool = multiprocessing.Pool(processes=job_total * 2) + pool.map(wash_source_with_edits, args) + else: + # now we have commands + for i, (c, build_args) in enumerate(args): + wash_source_with_edits( + (c, output_from_build_args(build_args), build_args, skip_test) + ) + + print("\n" "Exit without errors") + + +def create_parser(): + import argparse + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawTextHelpFormatter, + ) + parser.add_argument( + "build_dir", + help="list of files or directories to check", + ) + parser.add_argument( + "--match", + nargs='+', + required=True, + metavar="REGEX", + help="Match file paths against this expression", + ) + parser.add_argument( + "--skip-test", + dest="skip_test", + default=False, + action='store_true', + help=( + "Perform all edits without testing if they perform functional changes. " + "Use to quickly preview edits, or to perform edits which are manually checked (default=False)" + ), + required=False, + ) + return parser + + +def main(): + parser = create_parser() + args = parser.parse_args() + + build_dir = args.build_dir + regex_list = [] + + for i, expr in enumerate(args.match): + try: + regex_list.append(re.compile(expr)) + except Exception as ex: + print(f"Error in expression: {expr}\n {ex}") + return 1 + + return header_clean_all(build_dir, regex_list, args.skip_test) + + +if __name__ == "__main__": + sys.exit(main())