tornavis/tools/utils_maintenance/code_clean.py

2203 lines
74 KiB
Python
Executable File

#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Example:
./tools/utils/code_clean.py /src/cmake_debug --match ".*/editmesh_.*" --fix=use_const_vars
Note: currently this is limited to paths in "source/" and "intern/",
we could change this if it's needed.
"""
import argparse
import re
import subprocess
import sys
import os
import string
from typing import (
Any,
Dict,
Generator,
List,
Optional,
Sequence,
Set,
Tuple,
Type,
)
# List of (source_file, all_arguments)
ProcessedCommands = List[Tuple[str, str]]
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
SOURCE_DIR = os.path.normpath(os.path.join(BASE_DIR, "..", ".."))
# (id: doc-string) pairs.
VERBOSE_INFO = [
(
"compile", (
"Print the compiler output (noisy).\n"
"Try setting '--jobs=1' for usable output.\n"
),
),
(
"edit_actions", (
"Print the result of each attempted edit, useful for troubleshooting:\n"
"- Causes code not to compile.\n"
"- Compiles but changes the resulting behavior.\n"
"- Succeeds.\n"
),
)
]
# -----------------------------------------------------------------------------
# Generic Constants
# Sorted numeric types.
# Intentionally missing are "unsigned".
BUILT_IN_NUMERIC_TYPES = (
"bool",
"char",
"char32_t",
"double",
"float",
"int",
"int16_t",
"int32_t",
"int64_t",
"int8_t",
"intptr_t",
"long",
"off_t",
"ptrdiff_t",
"short",
"size_t",
"ssize_t",
"uchar",
"uint",
"uint16_t",
"uint32_t",
"uint64_t",
"uint8_t",
"uintptr_t",
"ulong",
"ushort",
)
IDENTIFIER_CHARS = set(string.ascii_letters + "_" + string.digits)
# -----------------------------------------------------------------------------
# General Utilities
# Note that we could use a hash, however there is no advantage, compare its contents.
def file_as_bytes(filename: str) -> bytes:
with open(filename, 'rb') as fh:
return fh.read()
def line_from_span(text: str, start: int, end: int) -> str:
while start > 0 and text[start - 1] != '\n':
start -= 1
while end < len(text) and text[end] != '\n':
end += 1
return text[start:end]
def files_recursive_with_ext(path: str, ext: Tuple[str, ...]) -> Generator[str, None, None]:
for dirpath, dirnames, filenames in os.walk(path):
# skip '.git' and other dot-files.
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
for filename in filenames:
if filename.endswith(ext):
yield os.path.join(dirpath, filename)
def text_matching_bracket_forward(
data: str,
pos_beg: int,
pos_limit: int,
beg_bracket: str,
end_bracket: str,
) -> int:
"""
Return the matching bracket or -1.
.. note:: This is not sophisticated, brackets in strings will confuse the function.
"""
level = 1
# The next bracket.
pos = pos_beg + 1
# Clamp the limit.
limit = min(pos_beg + pos_limit, len(data))
while pos < limit:
c = data[pos]
if c == beg_bracket:
level += 1
elif c == end_bracket:
level -= 1
if level == 0:
return pos
pos += 1
return -1
def text_matching_bracket_backward(
data: str,
pos_end: int,
pos_limit: int,
beg_bracket: str,
end_bracket: str,
) -> int:
"""
Return the matching bracket or -1.
.. note:: This is not sophisticated, brackets in strings will confuse the function.
"""
level = 1
# The next bracket.
pos = pos_end - 1
# Clamp the limit.
limit = max(0, pos_limit)
while pos >= limit:
c = data[pos]
if c == end_bracket:
level += 1
elif c == beg_bracket:
level -= 1
if level == 0:
return pos
pos -= 1
return -1
def text_prev_bol(data: str, pos: int, limit: int) -> int:
if pos == 0:
return pos
# Already at the bounds.
if data[pos - 1] == "\n":
return pos
pos_next = data.rfind("\n", limit, pos)
if pos_next == -1:
return limit
# We don't want to include the newline.
return pos_next + 1
def text_next_eol(data: str, pos: int, limit: int, step_over: bool) -> int:
"""
Extend ``pos`` to just before the next EOL, otherwise EOF.
As this is intended for use as a range, ``data[pos]``
will either be ``\n`` or equal to out of range (equal to ``len(data)``).
"""
if pos + 1 >= len(data):
return pos
# Already at the bounds.
if data[pos] == "\n":
return pos + (1 if step_over else 0)
pos_next = data.find("\n", pos, limit)
if pos_next == -1:
return limit
return pos_next + (1 if step_over else 0)
def text_prev_eol_nonblank(data: str, pos: int, limit: int) -> int:
"""
Return the character immediately before the previous lines new-line,
stepping backwards over any trailing tab or space characters.
"""
if pos == 0:
return pos
# Already at the bounds.
pos_next = data.rfind("\n", limit, pos)
if pos_next == -1:
return limit
# Step over the newline.
pos_next -= 1
if pos_next <= limit:
return pos_next
while pos_next > limit and data[pos_next] in " \t":
pos_next -= 1
return pos_next
# -----------------------------------------------------------------------------
# General C/C++ Source Code Checks
RE_DEFINE = re.compile(r"\s*#\s*define\b")
def text_cxx_in_macro_definition(data: str, pos: int) -> bool:
"""
Return true when ``pos`` is inside a macro (including multi-line macros).
"""
pos_bol = text_prev_bol(data, pos, 0)
pos_eol = text_next_eol(data, pos, len(data), False)
if RE_DEFINE.match(data[pos_bol:pos_eol]):
return True
while (pos_eol_prev := text_prev_eol_nonblank(data, pos_bol, 0)) != pos_bol:
if data[pos_eol_prev] != "\\":
break
pos_bol = text_prev_bol(data, pos_eol_prev + 1, 0)
# Otherwise keep checking if this is part of a macro.
if RE_DEFINE.match(data[pos_bol:pos_eol_prev]):
return True
return False
# -----------------------------------------------------------------------------
# Execution Wrappers
def run(
args: Sequence[str],
*,
cwd: Optional[str],
quiet: bool,
verbose_compile: bool,
) -> int:
if verbose_compile and not quiet:
out = sys.stdout.fileno()
else:
out = subprocess.DEVNULL
with subprocess.Popen(
args,
stdout=out,
stderr=out,
cwd=cwd,
) as proc:
proc.wait()
return proc.returncode
# -----------------------------------------------------------------------------
# Build System Access
def cmake_cache_var(cmake_dir: str, var: str) -> Optional[str]:
with open(os.path.join(cmake_dir, "CMakeCache.txt"), encoding='utf-8') as cache_file:
lines = [
l_strip for l in cache_file
if (l_strip := l.strip())
if not l_strip.startswith(("//", "#"))
]
for l in lines:
if l.split(":")[0] == var:
return l.split("=", 1)[-1]
return None
def cmake_cache_var_is_true(cmake_var: Optional[str]) -> bool:
if cmake_var is None:
return False
cmake_var = cmake_var.upper()
if cmake_var in {"ON", "YES", "TRUE", "Y"}:
return True
if cmake_var.isdigit() and cmake_var != "0":
return True
return False
RE_CFILE_SEARCH = re.compile(r"\s\-c\s([\S]+)")
def process_commands(cmake_dir: str, data: Sequence[str]) -> Optional[ProcessedCommands]:
compiler_c = cmake_cache_var(cmake_dir, "CMAKE_C_COMPILER")
compiler_cxx = cmake_cache_var(cmake_dir, "CMAKE_CXX_COMPILER")
if compiler_c is None:
sys.stderr.write("Can't find C compiler in %r\n" % cmake_dir)
return None
if compiler_cxx is None:
sys.stderr.write("Can't find C++ compiler in %r\n" % cmake_dir)
return None
# Check for unsupported configurations.
for arg in ("WITH_UNITY_BUILD", "WITH_COMPILER_CCACHE"):
if cmake_cache_var_is_true(cmake_cache_var(cmake_dir, arg)):
sys.stderr.write("The option '%s' must be disabled for proper functionality\n" % arg)
return None
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: str) -> Optional[ProcessedCommands]:
import time
cmake_dir = build_dir
make_exe = "ninja"
with subprocess.Popen(
[make_exe, "-t", "commands"],
stdout=subprocess.PIPE,
cwd=build_dir,
) as proc:
while proc.poll():
time.sleep(1)
assert proc.stdout is not None
out = proc.stdout.read()
proc.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: str) -> Optional[ProcessedCommands]:
import time
make_exe = "make"
with subprocess.Popen(
[make_exe, "--always-make", "--dry-run", "--keep-going", "VERBOSE=1"],
stdout=subprocess.PIPE,
cwd=build_dir,
) as proc:
while proc.poll():
time.sleep(1)
assert proc.stdout is not None
out = proc.stdout.read()
proc.stdout.close()
# print("done!", len(out), "bytes")
data = out.decode("utf-8", errors="ignore").split("\n")
return process_commands(build_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.
from collections import namedtuple
Edit = namedtuple(
"Edit", (
# Keep first, for sorting.
"span",
"content",
"content_fail",
# Optional.
"extra_build_args",
),
defaults=(
# `extra_build_args`.
None,
)
)
del namedtuple
class EditGenerator:
__slots__ = ()
# Each subclass must also a default boolean: `is_default`.
# When false, a detailed explanation must be included for why.
#
# Declare here to quiet `mypy` warning, `__init_subclass__` ensures this value is never used.
# This is done so the creator of edit is forced to make a decision on the reliability of the edit
# and document why it might need manual checking.
is_default = False
@classmethod
def __init_subclass__(cls) -> None:
# Ensure the sub-class declares this.
if (not isinstance(getattr(cls, "is_default", None), bool)) or ("is_default" not in cls.__dict__):
raise Exception("Class %r missing \"is_default\" boolean!" % cls)
if getattr(cls, "edit_list_from_file") is EditGenerator.edit_list_from_file:
raise Exception("Class %r missing \"edit_list_from_file\" callback!" % cls)
def __new__(cls, *args: Tuple[Any], **kwargs: Dict[str, Any]) -> Any:
raise RuntimeError("%s should not be instantiated" % cls)
@staticmethod
def edit_list_from_file(_source: str, _data: str, _shared_edit_data: Any) -> List[Edit]:
# The `__init_subclass__` function ensures this is always overridden.
raise RuntimeError("This function must be overridden by it's subclass!")
return []
@staticmethod
def setup() -> Any:
return None
@staticmethod
def teardown(_shared_edit_data: Any) -> None:
pass
class edit_generators:
# fake module.
class sizeof_fixed_array(EditGenerator):
"""
Use fixed size array syntax with `sizeof`:
Replace:
sizeof(float) * 4 * 4
With:
sizeof(float[4][4])
"""
# Not default because there are times when the literal sizes don't represent extra dimensions on an array,
# where making this edit would be misleading as it would indicate a matrix (for e.g.) when a vector is intended.
is_default = False
@staticmethod
def edit_list_from_file(_source: str, data: str, _shared_edit_data: Any) -> List[Edit]:
edits = []
for match in re.finditer(r"sizeof\(([a-zA-Z_]+)\) \* (\d+) \* (\d+)", data):
edits.append(Edit(
span=match.span(),
content='sizeof(%s[%s][%s])' % (match.group(1), match.group(2), match.group(3)),
content_fail='__ALWAYS_FAIL__',
))
for match in re.finditer(r"sizeof\(([a-zA-Z_]+)\) \* (\d+)", data):
edits.append(Edit(
span=match.span(),
content='sizeof(%s[%s])' % (match.group(1), match.group(2)),
content_fail='__ALWAYS_FAIL__',
))
for match in re.finditer(r"\b(\d+) \* sizeof\(([a-zA-Z_]+)\)", data):
edits.append(Edit(
span=match.span(),
content='sizeof(%s[%s])' % (match.group(2), match.group(1)),
content_fail='__ALWAYS_FAIL__',
))
return edits
class use_const(EditGenerator):
"""
Use const variables:
Replace:
float abc[3] = {0, 1, 2};
With:
`const float abc[3] = {0, 1, 2};`
Replace:
float abc[3]
With:
const float abc[3]
As well as casts.
Replace:
(float *)
With:
(const float *)
Replace:
(float (*))
With:
(const float (*))
"""
# Non-default because pre-processor defines can cause `const` variables on some platforms
# to be non `const` on others.
is_default = False
@staticmethod
def edit_list_from_file(_source: str, data: str, _shared_edit_data: Any) -> List[Edit]:
edits = []
# `float abc[3] = {0, 1, 2};` -> `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(Edit(
span=match.span(),
content='%s const %s%s' % (match.group(1), match.group(2), match.group(3)),
content_fail='__ALWAYS_FAIL__',
))
# `float abc[3]` -> `const float abc[3]`
for match in re.finditer(r"(\(|, )([a-zA-Z_0-9]+ [a-zA-Z_0-9]+\[)", data):
edits.append(Edit(
span=match.span(),
content='%s const %s' % (match.group(1), match.group(2)),
content_fail='__ALWAYS_FAIL__',
))
# `(float *)` -> `(const float *)`
# `(float (*))` -> `(const float (*))`
# `(float (*)[4])` -> `(const float (*)[4])`
for match in re.finditer(
r"(\()"
r"([a-zA-Z_0-9]+\s*)"
r"(\*+\)|\(\*+\))"
r"(|\[[a-zA-Z_0-9]+\])",
data,
):
edits.append(Edit(
span=match.span(),
content='%sconst %s%s%s' % (match.group(1), match.group(2), match.group(3), match.group(4)),
content_fail='__ALWAYS_FAIL__',
))
return edits
class use_zero_before_float_suffix(EditGenerator):
"""
Use zero before the float suffix.
Replace:
1.f
With:
1.0f
Replace:
1.0F
With:
1.0f
"""
is_default = True
@staticmethod
def edit_list_from_file(_source: str, data: str, _shared_edit_data: Any) -> List[Edit]:
edits = []
# `1.f` -> `1.0f`
for match in re.finditer(r"\b(\d+)\.([fF])\b", data):
edits.append(Edit(
span=match.span(),
content='%s.0%s' % (match.group(1), match.group(2)),
content_fail='__ALWAYS_FAIL__',
))
# `1.0F` -> `1.0f`
for match in re.finditer(r"\b(\d+\.\d+)F\b", data):
edits.append(Edit(
span=match.span(),
content='%sf' % (match.group(1),),
content_fail='__ALWAYS_FAIL__',
))
return edits
class use_brief_types(EditGenerator):
"""
Use less verbose unsigned types.
Replace:
unsigned int
With:
uint
"""
is_default = True
@staticmethod
def edit_list_from_file(_source: str, data: str, _shared_edit_data: Any) -> List[Edit]:
edits = []
# Keep `typedef` unsigned as some files have local types, e.g.
# `typedef unsigned int uint;`
# Should not be changed to:
# `typedef uint uint;`
# ... even if it happens to compile - because it may cause problems on other platforms
# that don't have `uint` defined.
span_skip = set()
for match in re.finditer(r"\btypedef\s+(unsigned)\b", data):
span_skip.add(match.span(1))
# `unsigned char` -> `uchar`.
for match in re.finditer(r"(unsigned)\s+([a-z]+)", data):
if match.span(1) in span_skip:
continue
edits.append(Edit(
span=match.span(),
content='u%s' % match.group(2),
content_fail='__ALWAYS_FAIL__',
))
# There may be some remaining uses of `unsigned` without any integer type afterwards.
# `unsigned` -> `uint`.
for match in re.finditer(r"\b(unsigned)\b", data):
if match.span(1) in span_skip:
continue
edits.append(Edit(
span=match.span(),
content='uint',
content_fail='__ALWAYS_FAIL__',
))
return edits
class use_nullptr(EditGenerator):
"""
Use ``nullptr`` instead of ``NULL`` for C++ code.
Replace:
NULL
With:
nullptr
"""
is_default = True
@staticmethod
def edit_list_from_file(source: str, data: str, _shared_edit_data: Any) -> List[Edit]:
edits: List[Edit] = []
# The user might include C & C++, if they forget, it is better not to operate on C.
if source.lower().endswith((".h", ".c")):
return edits
# `NULL` -> `nullptr`.
for match in re.finditer(r"\bNULL\b", data):
edits.append(Edit(
span=match.span(),
content='nullptr',
content_fail='__ALWAYS_FAIL__',
))
# There may be some remaining uses of `unsigned` without any integer type afterwards.
# `unsigned` -> `uint`.
for match in re.finditer(r"\bunsigned\b", data):
edits.append(Edit(
span=match.span(),
content='uint',
content_fail='__ALWAYS_FAIL__',
))
return edits
class use_empty_void_arg(EditGenerator):
"""
Use ``()`` instead of ``(void)`` for C++ code.
Replace:
function(void) {}
With:
function() {}
"""
is_default = True
@staticmethod
def edit_list_from_file(source: str, data: str, _shared_edit_data: Any) -> List[Edit]:
edits: List[Edit] = []
# The user might include C & C++, if they forget, it is better not to operate on C.
if source.lower().endswith((".h", ".c")):
return edits
# `(void)` -> `()`.
for match in re.finditer(r"(\(void\))(\s*{)", data, flags=re.MULTILINE):
edits.append(Edit(
span=match.span(),
content="()" + match.group(2),
content_fail="(__ALWAYS_FAIL__) {",
))
return edits
class unused_arg_as_comment(EditGenerator):
"""
Replace `UNUSED(argument)` in C++ code.
Replace:
void function(int UNUSED(arg)) {...}
With:
void function(int /*arg*/) {...}
"""
is_default = True
@staticmethod
def edit_list_from_file(source: str, data: str, _shared_edit_data: Any) -> List[Edit]:
edits: List[Edit] = []
# The user might include C & C++, if they forget, it is better not to operate on C.
if source.lower().endswith((".h", ".c")):
return edits
# `UNUSED(arg)` -> `/*arg*/`.
for match in re.finditer(
r"\b(UNUSED)"
# # Opening parenthesis.
r"\("
# Capture the identifier as group 1.
r"([" + "".join(list(IDENTIFIER_CHARS)) + "]+)"
# # Capture any non-identifier characters as group 2.
# (e.g. `[3]`) which need to be added outside the comment.
r"([^\)]*)"
# Closing parenthesis of `UNUSED(..)`.
r"\)",
data,
):
edits.append(Edit(
span=match.span(),
content='/*%s*/%s' % (match.group(2), match.group(3)),
content_fail='__ALWAYS_FAIL__(%s%s)' % (match.group(2), match.group(3)),
))
return edits
class use_elem_macro(EditGenerator):
"""
Use the `ELEM` macro for more abbreviated expressions.
Replace:
(a == b || a == c)
(a != b && a != c)
With:
(ELEM(a, b, c))
(!ELEM(a, b, c))
"""
is_default = True
@staticmethod
def edit_list_from_file(_source: str, data: str, _shared_edit_data: Any) -> List[Edit]:
edits = []
for use_brackets in (True, False):
test_equal = (
r'([^\|\(\)]+)' # group 1 (no (|))
r'\s+==\s+'
r'([^\|\(\)]+)' # group 2 (no (|))
)
test_not_equal = (
r'([^\|\(\)]+)' # group 1 (no (|))
r'\s+!=\s+'
r'([^\|\(\)]+)' # group 2 (no (|))
)
if use_brackets:
test_equal = r'\(' + test_equal + r'\)'
test_not_equal = r'\(' + test_not_equal + r'\)'
for is_equal in (True, False):
for n in reversed(range(2, 64)):
if is_equal:
re_str = r'\(' + r'\s+\|\|\s+'.join([test_equal] * n) + r'\)'
else:
re_str = r'\(' + r'\s+\&\&\s+'.join([test_not_equal] * n) + r'\)'
for match in re.finditer(re_str, data):
var = match.group(1)
var_rest = []
groups = match.groups()
groups_paired = [(groups[i * 2], groups[i * 2 + 1]) for i in range(len(groups) // 2)]
found = True
for a, b in groups_paired:
# Unlikely but possible the checks are swapped.
if b == var and a != var:
a, b = b, a
if a != var:
found = False
break
var_rest.append(b)
if found:
edits.append(Edit(
span=match.span(),
content='(%sELEM(%s, %s))' % (
('' if is_equal else '!'),
var,
', '.join(var_rest),
),
# Use same expression otherwise this can change values
# inside assert when it shouldn't.
content_fail='(%s__ALWAYS_FAIL__(%s, %s))' % (
('' if is_equal else '!'),
var,
', '.join(var_rest),
),
))
return edits
class use_str_elem_macro(EditGenerator):
"""
Use `STR_ELEM` macro:
Replace:
(STREQ(a, b) || STREQ(a, c))
With:
(STR_ELEM(a, b, c))
"""
is_default = True
@staticmethod
def edit_list_from_file(_source: str, data: str, _shared_edit_data: Any) -> List[Edit]:
edits = []
for use_brackets in (True, False):
test_equal = (
r'STREQ'
r'\('
r'([^\|\(\),]+)' # group 1 (no (|,))
r',\s+'
r'([^\|\(\),]+)' # group 2 (no (|,))
r'\)'
)
test_not_equal = (
'!' # Only difference.
r'STREQ'
r'\('
r'([^\|\(\),]+)' # group 1 (no (|,))
r',\s+'
r'([^\|\(\),]+)' # group 2 (no (|,))
r'\)'
)
if use_brackets:
test_equal = r'\(' + test_equal + r'\)'
test_not_equal = r'\(' + test_not_equal + r'\)'
for is_equal in (True, False):
for n in reversed(range(2, 64)):
if is_equal:
re_str = r'\(' + r'\s+\|\|\s+'.join([test_equal] * n) + r'\)'
else:
re_str = r'\(' + r'\s+\&\&\s+'.join([test_not_equal] * n) + r'\)'
for match in re.finditer(re_str, data):
var = match.group(1)
var_rest = []
groups = match.groups()
groups_paired = [(groups[i * 2], groups[i * 2 + 1]) for i in range(len(groups) // 2)]
found = True
for a, b in groups_paired:
# Unlikely but possible the checks are swapped.
if b == var and a != var:
a, b = b, a
if a != var:
found = False
break
var_rest.append(b)
if found:
edits.append(Edit(
span=match.span(),
content='(%sSTR_ELEM(%s, %s))' % (
('' if is_equal else '!'),
var,
', '.join(var_rest),
),
# Use same expression otherwise this can change values
# inside assert when it shouldn't.
content_fail='(%s__ALWAYS_FAIL__(%s, %s))' % (
('' if is_equal else '!'),
var,
', '.join(var_rest),
),
))
return edits
class use_const_vars(EditGenerator):
"""
Use `const` where possible:
Replace:
float abc[3] = {0, 1, 2};
With:
const float abc[3] = {0, 1, 2};
"""
# Non-default because pre-processor defines can cause `const` variables on some platforms
# to be non `const` on others.
is_default = False
@staticmethod
def edit_list_from_file(_source: str, data: str, _shared_edit_data: Any) -> List[Edit]:
edits = []
# for match in re.finditer(r"( [a-zA-Z0-9_]+ [a-zA-Z0-9_]+ = [A-Z][A-Z_0-9_]*;)", data):
# edits.append(Edit(
# span=match.span(),
# content='const %s' % (match.group(1).lstrip()),
# content_fail='__ALWAYS_FAIL__',
# ))
for match in re.finditer(r"( [a-zA-Z0-9_]+ [a-zA-Z0-9_]+ = .*;)", data):
edits.append(Edit(
span=match.span(),
content='const %s' % (match.group(1).lstrip()),
content_fail='__ALWAYS_FAIL__',
))
return edits
class remove_struct_qualifier(EditGenerator):
"""
Remove redundant struct qualifiers:
Replace:
struct Foo
With:
Foo
"""
is_default = True
@staticmethod
def edit_list_from_file(_source: str, data: str, _shared_edit_data: Any) -> List[Edit]:
edits = []
# Keep:
# - `strucrt Foo;` (forward declaration).
# - `struct Foo {` (declaration).
# - `struct {` (declaration).
# In these cases removing will cause a build error (which is technically "safe")
# it just causes a lot of unnecessary code edits which always fail and slow down operation.
span_skip = set()
for match in re.finditer(r"\b(struct)\s+([a-zA-Z0-9_]+)?\s*({|;)", data):
span_skip.add(match.span(1))
# Remove `struct`
for match in re.finditer(r"\b(struct)\s+[a-zA-Z0-9_]+", data):
span = match.span(1)
if span in span_skip:
continue
edits.append(Edit(
span=span,
content=' ',
content_fail=' __ALWAYS_FAIL__ ',
))
return edits
class remove_return_parens(EditGenerator):
"""
Remove redundant parenthesis around return arguments:
Replace:
return (value);
With:
return value;
"""
is_default = True
@staticmethod
def edit_list_from_file(_source: str, data: str, _shared_edit_data: Any) -> List[Edit]:
edits = []
# Remove `return (NULL);`
for match in re.finditer(r"return \(([a-zA-Z_0-9]+)\);", data):
edits.append(Edit(
span=match.span(),
content='return %s;' % (match.group(1)),
content_fail='return __ALWAYS_FAIL__;',
))
return edits
class use_streq_macro(EditGenerator):
"""
Use `STREQ` macro:
Replace:
strcmp(a, b) == 0
With:
STREQ(a, b)
Replace:
strcmp(a, b) != 0
With:
!STREQ(a, b)
"""
is_default = True
@staticmethod
def edit_list_from_file(_source: str, data: str, _shared_edit_data: Any) -> List[Edit]:
edits = []
# `strcmp(a, b) == 0` -> `STREQ(a, b)`
for match in re.finditer(r"\bstrcmp\((.*)\) == 0", data):
edits.append(Edit(
span=match.span(),
content='STREQ(%s)' % (match.group(1)),
content_fail='__ALWAYS_FAIL__',
))
for match in re.finditer(r"!strcmp\((.*)\)", data):
edits.append(Edit(
span=match.span(),
content='STREQ(%s)' % (match.group(1)),
content_fail='__ALWAYS_FAIL__',
))
# `strcmp(a, b) != 0` -> `!STREQ(a, b)`
for match in re.finditer(r"\bstrcmp\((.*)\) != 0", data):
edits.append(Edit(
span=match.span(),
content='!STREQ(%s)' % (match.group(1)),
content_fail='__ALWAYS_FAIL__',
))
for match in re.finditer(r"\bstrcmp\((.*)\)", data):
edits.append(Edit(
span=match.span(),
content='!STREQ(%s)' % (match.group(1)),
content_fail='__ALWAYS_FAIL__',
))
return edits
class use_std_min_max(EditGenerator):
"""
Use `std::min` & `std::max` instead of `MIN2`, `MAX2` macros:
Replace:
MAX2(a, b)
With:
std::max(a, b)
"""
is_default = True
@staticmethod
def edit_list_from_file(source: str, data: str, _shared_edit_data: Any) -> List[Edit]:
edits: List[Edit] = []
# The user might include C & C++, if they forget, it is better not to operate on C.
if source.lower().endswith((".h", ".c")):
return edits
for src, dst in (
("MIN2", "std::min"),
("MAX2", "std::max"),
):
for match in re.finditer(
(r"\b(" + src + r")\("),
data,
):
edits.append(Edit(
span=match.span(1),
content=dst,
content_fail='__ALWAYS_FAIL__',
))
return edits
class use_str_sizeof_macros(EditGenerator):
"""
Use `STRNCPY` & `SNPRINTF` macros:
Replace:
BLI_strncpy(a, b, sizeof(a))
With:
STRNCPY(a, b)
Replace:
BLI_snprintf(a, sizeof(a), "format %s", b)
With:
SNPRINTF(a, "format %s", b)
"""
is_default = True
@staticmethod
def edit_list_from_file(_source: str, data: str, _shared_edit_data: Any) -> List[Edit]:
edits = []
# `BLI_strncpy(a, b, sizeof(a))` -> `STRNCPY(a, b)`
# `BLI_strncpy(a, b, SOME_ID)` -> `STRNCPY(a, b)`
for src, dst in (
("BLI_strncpy", "STRNCPY"),
("BLI_strncpy_rlen", "STRNCPY_RLEN"),
("BLI_strncpy_utf8", "STRNCPY_UTF8"),
("BLI_strncpy_utf8_rlen", "STRNCPY_UTF8_RLEN"),
):
for match in re.finditer(
(r"\b" + src + (
r"\(([^,]+,\s+[^,]+),\s+" r"("
r"sizeof\([^\(\)]+\)" # Trailing `sizeof(..)`.
r"|"
r"[a-zA-Z0-9_]+" # Trailing identifier (typically a define).
r")" r"\)"
)),
data,
flags=re.MULTILINE,
):
edits.append(Edit(
span=match.span(),
content='%s(%s)' % (dst, match.group(1)),
content_fail='__ALWAYS_FAIL__',
))
# `BLI_snprintf(a, SOME_SIZE, ...` -> `SNPRINTF(a, ...`
for src, dst in (
("BLI_snprintf", "SNPRINTF"),
("BLI_snprintf_rlen", "SNPRINTF_RLEN"),
("BLI_vsnprintf", "VSNPRINTF"),
("BLI_vsnprintf_rlen", "VSNPRINTF_RLEN"),
):
for match in re.finditer(
r"\b" + src + r"\(([^,]+),\s+([^,]+),",
data,
flags=re.MULTILINE,
):
edits.append(Edit(
span=match.span(),
content='%s(%s,' % (dst, match.group(1)),
content_fail='__ALWAYS_FAIL__',
))
return edits
class use_array_size_macro(EditGenerator):
"""
Use macro for an error checked array size:
Replace:
sizeof(foo) / sizeof(*foo)
With:
ARRAY_SIZE(foo)
"""
is_default = True
@staticmethod
def edit_list_from_file(_source: str, data: str, _shared_edit_data: Any) -> List[Edit]:
edits = []
# 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(Edit(
span=match.span(),
content='ARRAY_SIZE(%s)' % match.group(1),
content_fail='__ALWAYS_FAIL__',
))
return edits
class use_listbase_foreach_macro(EditGenerator):
"""
Use macro ``LISTBASE_FOREACH`` or ``LISTBASE_FOREACH_BACKWARD``:
Replace:
for (var = static_cast<SomeType *>(list_base.first); var; var = var->next) {}
With:
LISTBASE_FOREACH(SomeType *, var, &list_base) {}
"""
# This may be default but can generate shadow variable warnings that need
# to be manually corrected (typically by removing the local variable in the outer scope).
is_default = False
@staticmethod
def edit_list_from_file(_source: str, data: str, _shared_edit_data: Any) -> List[Edit]:
edits = []
re_cxx_cast = re.compile(r"[a-z_]+<([^\>]+)>\((.*)\)")
re_c_cast = re.compile(r"\(([^\)]+\*)\)(.*)")
# 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"->(next|prev)\)\s+{", data, flags=re.MULTILINE):
# Chances are this is a for loop over a listbase.
is_forward = match.group(1) == "next"
for_paren_end = match.span()[0] + 6
for_paren_beg = for_paren_end
limit = max(0, for_paren_end - 2000)
i = for_paren_end - 1
level = 1
while True:
if data[i] == ")":
level += 1
elif data[i] == "(":
level -= 1
if level == 0:
for_paren_beg = i
break
i -= 1
if i < limit:
break
if for_paren_beg == for_paren_end:
continue
content = data[for_paren_beg:for_paren_end + 1]
if content.count("=") != 2:
continue
if content.count(";") != 2:
continue
# It just so happens we expect the first element,
# not a strict check, the compile test will filter out errors.
if is_forward:
if "first" not in content:
continue
else:
if "last" not in content:
continue
# It just so happens that this case should be ignored (no check in the middle of the string).
if ";;" in content:
continue
for_beg = for_paren_beg - 4
prefix = data[for_beg: for_paren_beg]
if prefix != "for ":
continue
# Now we surely have a for-loop.
content_beg, content_mid, _content_end = content.split(";")
if "=" not in content_beg:
continue
base = content_beg.rsplit("=", 1)[1].strip()
if match_cast := re_cxx_cast.match(base):
ty = match_cast.group(1)
base = match_cast.group(2)
else:
if match_cast := re_c_cast.match(base):
ty = match_cast.group(1)
base = match_cast.group(2)
else:
continue
del match_cast
# There may be extra parens.
while base.startswith("(") and base.endswith(")"):
base = base[1:-1]
if is_forward:
if base.endswith("->first"):
base = base[:-7]
base_is_pointer = True
elif base.endswith(".first"):
base = base[:-6]
base_is_pointer = False
else:
continue
else:
if base.endswith("->last"):
base = base[:-6]
base_is_pointer = True
elif base.endswith(".last"):
base = base[:-5]
base_is_pointer = False
else:
continue
# Get the variable, most likely it's a single value
# but may be `var != nullptr`, in this case only the first term is needed.
var = content_mid.strip().split(" ", 1)[0].strip()
edits.append(Edit(
# Span covers `for (...)` {
span=(for_beg, for_paren_end + 1),
content='%s (%s, %s, %s%s)' % (
"LISTBASE_FOREACH" if is_forward else "LISTBASE_FOREACH_BACKWARD",
ty,
var,
"" if base_is_pointer else "&",
base,
),
content_fail='__ALWAYS_FAIL__',
))
return edits
class parenthesis_cleanup(EditGenerator):
"""
Use macro for an error checked array size:
Replace:
((a + b))
With:
(a + b)
Replace:
(func(a + b))
With:
func(a + b)
Note that the `CFLAGS` should be set so missing parentheses that contain assignments - error instead of warn:
With GCC: `-Werror=parentheses`
Note that this does not make any edits inside macros because it can be important to keep parenthesis
around macro arguments.
"""
# Non-default because this edit can be applied to macros in situations where removing the parentheses
# could result in macro expansion to have different results (depending on the arguments parsed in).
# TODO: make this check skip macro text and it could be enabled by default.
is_default = False
@staticmethod
def edit_list_from_file(_source: str, data: str, _shared_edit_data: Any) -> List[Edit]:
edits = []
# Give up after searching for a bracket this many characters and finding none.
bracket_seek_limit = 4000
# Don't match double brackets because this will not match multiple overlapping matches
# Where 3 brackets should be checked as two separate pairs.
for match in re.finditer(r"(\()", data):
outer_beg = match.span()[0]
inner_beg = outer_beg + 1
if data[inner_beg] != "(":
continue
inner_end = text_matching_bracket_forward(data, inner_beg, inner_beg + bracket_seek_limit, "(", ")")
if inner_end == -1:
continue
outer_beg = inner_beg - 1
outer_end = text_matching_bracket_forward(data, outer_beg, inner_end + 1, "(", ")")
if outer_end != inner_end + 1:
continue
if text_cxx_in_macro_definition(data, outer_beg):
continue
text = data[inner_beg:inner_end + 1]
edits.append(Edit(
span=(outer_beg, outer_end + 1),
content=text,
content_fail='(__ALWAYS_FAIL__)',
))
# Handle `(func(a + b))` -> `func(a + b)`
for match in re.finditer(r"(\))", data):
inner_end = match.span()[0]
outer_end = inner_end + 1
if data[outer_end] != ")":
continue
inner_beg = text_matching_bracket_backward(data, inner_end, inner_end - bracket_seek_limit, "(", ")")
if inner_beg == -1:
continue
outer_beg = text_matching_bracket_backward(data, outer_end, outer_end - bracket_seek_limit, "(", ")")
if outer_beg == -1:
continue
# The text between the first two opening brackets:
# `(function_name(a + b))` -> `function_name`.
text = data[outer_beg + 1:inner_beg]
# Handled in the first loop looking for forward brackets.
if text == "":
continue
# Don't convert `prefix(func(a + b))` -> `prefixfunc(a + b)`
if data[outer_beg - 1] in IDENTIFIER_CHARS:
continue
# Don't convert `static_cast<float>(foo(bar))` -> `static_cast<float>foo(bar)`
# While this will always fail to compile it slows down tests.
if data[outer_beg - 1] == ">":
continue
# Exact rule here is arbitrary, in general though spaces mean there are operations
# that can use the brackets.
if " " in text:
continue
# Search back an arbitrary number of chars 8 should be enough
# but manual formatting can add additional white-space, so increase
# the size to account for that.
prefix = data[max(outer_beg - 20, 0):outer_beg].strip()
if prefix:
# Avoid `if (SOME_MACRO(..)) {..}` -> `if SOME_MACRO(..) {..}`
# While correct it relies on parenthesis within the macro which isn't ideal.
if prefix.split()[-1] in {"if", "while", "switch"}:
continue
# Avoid `*(--foo)` -> `*--foo`.
# While correct it reads badly.
if data[outer_beg - 1] == "*":
continue
if text_cxx_in_macro_definition(data, outer_beg):
continue
text_no_parens = data[outer_beg + 1: outer_end]
edits.append(Edit(
span=(outer_beg, outer_end + 1),
content=text_no_parens,
content_fail='__ALWAYS_FAIL__',
))
return edits
class header_clean(EditGenerator):
"""
Clean headers, ensuring that the headers removed are not used directly or indirectly.
Note that the `CFLAGS` should be set so missing prototypes error instead of warn:
With GCC: `-Werror=missing-prototypes`
"""
# Non-default because changes to headers may cause breakage on other platforms.
# Before committing these changes all supported platforms should be tested to compile without problems.
is_default = False
@staticmethod
def _header_guard_from_filename(f: str) -> str:
return '__%s__' % os.path.basename(f).replace('.', '_').upper()
@classmethod
def setup(cls) -> Any:
# For each file replace `pragma once` with old-style header guard.
# This is needed so we can remove the header with the knowledge the source file didn't use it indirectly.
files: List[Tuple[str, str, str, str]] = []
shared_edit_data = {
'files': files,
}
for f in files_recursive_with_ext(
os.path.join(SOURCE_DIR, 'source'),
('.h', '.hh', '.inl', '.hpp', '.hxx'),
):
with open(f, 'r', encoding='utf-8') as fh:
data = fh.read()
for match in re.finditer(r'^[ \t]*#\s*(pragma\s+once)\b', data, flags=re.MULTILINE):
header_guard = cls._header_guard_from_filename(f)
start, end = match.span()
src = data[start:end]
dst = (
'#ifndef %s\n#define %s' % (header_guard, header_guard)
)
dst_footer = '\n#endif /* %s */\n' % header_guard
files.append((f, src, dst, dst_footer))
data = data[:start] + dst + data[end:] + dst_footer
with open(f, 'w', encoding='utf-8') as fh:
fh.write(data)
break
return shared_edit_data
@staticmethod
def teardown(shared_edit_data: Any) -> None:
files = shared_edit_data['files']
for f, src, dst, dst_footer in files:
with open(f, 'r', encoding='utf-8') as fh:
data = fh.read()
data = data.replace(
dst, src,
).replace(
dst_footer, '',
)
with open(f, 'w', encoding='utf-8') as fh:
fh.write(data)
@classmethod
def edit_list_from_file(cls, _source: str, data: str, _shared_edit_data: Any) -> List[Edit]:
edits = []
# Remove include.
for match in re.finditer(r"^(([ \t]*#\s*include\s+\")([^\"]+)(\"[^\n]*\n))", data, flags=re.MULTILINE):
header_name = match.group(3)
header_guard = cls._header_guard_from_filename(header_name)
edits.append(Edit(
span=match.span(),
content='', # Remove the header.
content_fail='%s__ALWAYS_FAIL__%s' % (match.group(2), match.group(4)),
extra_build_args=('-D' + header_guard, ),
))
return edits
class use_function_style_cast(EditGenerator):
"""
Use function call style casts (C++ only).
Replace:
(float)(a + b)
With:
float(a + b)
Also support more complex cases involving right hand bracket insertion.
Replace:
(float)foo(a + b)
With:
float(foo(a + b))
"""
is_default = True
@staticmethod
def edit_list_from_file(source: str, data: str, _shared_edit_data: Any) -> List[Edit]:
edits: List[Edit] = []
# The user might include C & C++, if they forget, it is better not to operate on C.
if source.lower().endswith((".h", ".c")):
return edits
any_number_re = "(" + "|".join(BUILT_IN_NUMERIC_TYPES) + ")"
# Handle both:
# - Simple case: `(float)(a + b)` -> `float(a + b)`.
# - Complex Case: `(float)foo(a + b) + c` -> `float(foo(a + b)) + c`
for match in re.finditer(
"(\\()" + # 1st group.
any_number_re + # 2nd group.
"(\\))", # 3rd group.
data,
):
beg, end = match.span()
# This could be ignored, but `sizeof` accounts for such a large number
# of cases that should be left as-is, that it's best to explicitly ignore them.
if (
(beg > 6) and
(data[beg - 6: beg] == 'sizeof') and
(not data[beg - 7].isalpha())
):
continue
char_after = data[end]
if char_after == "(":
# Simple case.
edits.append(Edit(
span=(beg, end),
content=match.group(2),
content_fail='__ALWAYS_FAIL__',
))
else:
# The complex case is involved as brackets need to be added.
# Currently this is not handled in a clever way, just try add in brackets
# and rely on matching build output to know if they were added in the right place.
text = match.group(2)
# `span = (beg, end)`.
for offset_end in range(end + 1, len(data)):
# Not technically correct, but it's rare that this will span lines.
if "\n" == data[offset_end]:
break
if (
(data[offset_end - 1] in IDENTIFIER_CHARS) and
(data[offset_end] in IDENTIFIER_CHARS)
):
continue
# Include `text_tail` in fail content in case it contains comments.
text_tail = "(" + data[end:offset_end] + ")"
edits.append(Edit(
span=(beg, offset_end),
content=text + text_tail,
content_fail='(__ALWAYS_FAIL__)' + text_tail,
))
# Simple case: `static_cast<float>(a + b)` => `float(a + b)`.
for match in re.finditer(
r"\b(static_cast<)" + # 1st group.
any_number_re + # 2nd group.
"(>)", # 3rd group.
data,
):
edits.append(Edit(
span=match.span(),
content='%s' % match.group(2),
content_fail='__ALWAYS_FAIL__',
))
return edits
def test_edit(
source: str,
output: str,
output_bytes: Optional[bytes],
build_args: Sequence[str],
build_cwd: Optional[str],
data: str,
data_test: str,
*,
keep_edits: bool,
expect_failure: bool,
verbose_compile: bool,
verbose_edit_actions: bool,
) -> bool:
"""
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, cwd=build_cwd, quiet=expect_failure, verbose_compile=verbose_compile)
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:
if verbose_edit_actions:
print("Changed code, skip...", hex(hash(output_bytes)), hex(hash(output_bytes_test)))
else:
if not expect_failure:
if verbose_edit_actions:
print("Failed to compile, skip...")
with open(source, 'w', encoding='utf-8') as fh:
fh.write(data)
return False
# -----------------------------------------------------------------------------
# List Fix Functions
def edit_function_get_all(*, is_default: Optional[bool] = None) -> List[str]:
fixes = []
for name in dir(edit_generators):
value = getattr(edit_generators, name)
if type(value) is type and issubclass(value, EditGenerator):
if is_default is not None:
if is_default != value.is_default:
continue
fixes.append(name)
fixes.sort()
return fixes
def edit_class_from_id(name: str) -> Type[EditGenerator]:
result = getattr(edit_generators, name)
assert issubclass(result, EditGenerator)
# MYPY 0.812 doesn't recognize the assert above.
return result # type: ignore
def edit_docstring_from_id(name: str) -> str:
from textwrap import dedent
result = getattr(edit_generators, name).__doc__
return dedent(result or '').strip('\n') + '\n'
def edit_group_compatible(edits: Sequence[str]) -> Sequence[Sequence[str]]:
"""
Group compatible edits, so it's possible for a single process to iterate on many edits for a single file.
"""
edits_grouped = []
edit_generator_class_prev = None
for edit in edits:
edit_generator_class = edit_class_from_id(edit)
if edit_generator_class_prev is None or (
edit_generator_class.setup != edit_generator_class_prev.setup and
edit_generator_class.teardown != edit_generator_class_prev.teardown
):
# Create a new group.
edits_grouped.append([edit])
else:
edits_grouped[-1].append(edit)
edit_generator_class_prev = edit_generator_class
return edits_grouped
# -----------------------------------------------------------------------------
# Accept / Reject Edits
def apply_edit(source_relative: str, data: str, text_to_replace: str, start: int, end: int, *, verbose: bool) -> str:
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:", source_relative)
print(line_before)
print(line_after)
return data
def wash_source_with_edit(
source: str,
output: str,
build_args: Sequence[str],
build_cwd: Optional[str],
skip_test: bool,
verbose_compile: bool,
verbose_edit_actions: bool,
shared_edit_data: Any,
edit_to_apply: str,
) -> None:
# For less verbose printing, strip the prefix.
source_relative = os.path.relpath(source, SOURCE_DIR)
# build_args = build_args + " -Werror=duplicate-decl-specifier"
with open(source, 'r', encoding='utf-8') as fh:
data = fh.read()
edit_generator_class = edit_class_from_id(edit_to_apply)
# After performing all edits, store the result in this set.
#
# This is a heavy solution that guarantees edits never oscillate between
# multiple states, so re-visiting a previously visited state will always exit.
data_states: Set[str] = set()
# When overlapping edits are found, keep attempting edits.
edit_again = True
while edit_again:
edit_again = False
edits = edit_generator_class.edit_list_from_file(source, data, shared_edit_data)
# Sort by span, in a way that tries shorter spans first
# This is more efficient when testing multiple overlapping edits,
# since when a smaller edit succeeds, it's less likely to have to try as many edits that span wider ranges.
# (This applies to `use_function_style_cast`).
edits.sort(reverse=True, key=lambda edit: (edit.span[0], -edit.span[1]))
if not edits:
return
if skip_test:
# Just apply all edits.
for (start, end), text, _text_always_fail, _extra_build_args in edits:
data = apply_edit(source_relative, data, text, start, end, verbose=verbose_edit_actions)
with open(source, 'w', encoding='utf-8') as fh:
fh.write(data)
return
test_edit(
source, output, None, build_args, build_cwd, data, data,
keep_edits=False,
expect_failure=False,
verbose_compile=verbose_compile,
verbose_edit_actions=verbose_edit_actions,
)
if not os.path.exists(output):
# raise Exception("Failed to produce output file: " + output)
# NOTE(@ideasman42): This fails very occasionally and needs to be investigated why.
# For now skip, as it's disruptive to force-quit in the middle of all other changes.
print("Failed to produce output file, skipping:", repr(output))
return
output_bytes = file_as_bytes(output)
# Dummy value that won't cause problems.
edit_prev_start = len(data) + 1
for (start, end), text, text_always_fail, extra_build_args in edits:
if end >= edit_prev_start:
# Run the edits again, in case this would have succeeded,
# but was skipped due to edit-overlap.
edit_again = True
continue
build_args_for_edit = build_args
if extra_build_args:
# Add directly after the compile command.
build_args_for_edit = build_args[:1] + extra_build_args + build_args[1:]
data_test = apply_edit(source_relative, data, text, start, end, verbose=verbose_edit_actions)
if test_edit(
source, output, output_bytes, build_args_for_edit, build_cwd, data, data_test,
keep_edits=False,
expect_failure=False,
verbose_compile=verbose_compile,
verbose_edit_actions=verbose_edit_actions,
):
# This worked, check if the change would fail if replaced with 'text_always_fail'.
data_test_always_fail = apply_edit(source_relative, data, text_always_fail, start, end, verbose=False)
if test_edit(
source, output, output_bytes, build_args_for_edit, build_cwd, data, data_test_always_fail,
expect_failure=True,
keep_edits=False,
verbose_compile=verbose_compile,
verbose_edit_actions=verbose_edit_actions,
):
if verbose_edit_actions:
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)
# Update the last successful edit, the end of the next edit must not overlap this one.
edit_prev_start = start
# Finished applying `edits`, check if further edits should be applied.
if edit_again:
data_states_len = len(data_states)
data_states.add(data)
if data_states_len == len(data_states):
# Avoid the *extremely* unlikely case that edits re-visit previously visited states.
edit_again = False
else:
# It is interesting to know how many passes run when debugging.
# print("Passes for: ", source, len(data_states))
pass
def wash_source_with_edit_list(
source: str,
output: str,
build_args: Sequence[str],
build_cwd: Optional[str],
skip_test: bool,
verbose_compile: bool,
verbose_edit_actions: bool,
shared_edit_data: Any,
edit_list: Sequence[str],
) -> None:
for edit_to_apply in edit_list:
wash_source_with_edit(
source,
output,
build_args,
build_cwd,
skip_test,
verbose_compile,
verbose_edit_actions,
shared_edit_data,
edit_to_apply,
)
# -----------------------------------------------------------------------------
# Edit Source Code From Args
def run_edits_on_directory(
*,
build_dir: str,
regex_list: List[re.Pattern[str]],
edits_to_apply: Sequence[str],
skip_test: bool,
jobs: int,
verbose_compile: bool,
verbose_edit_actions: bool,
) -> int:
import multiprocessing
# 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 1
if jobs <= 0:
jobs = multiprocessing.cpu_count()
if args is None:
# Error will have been reported.
return 1
# 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 split_build_args_with_cwd(build_args_str: str) -> Tuple[Sequence[str], Optional[str]]:
import shlex
build_args = shlex.split(build_args_str)
cwd = None
if len(build_args) > 3:
if build_args[0] == "cd" and build_args[2] == "&&":
cwd = build_args[1]
del build_args[0:3]
return build_args, cwd
def output_from_build_args(build_args: Sequence[str], cwd: Optional[str]) -> str:
i = build_args.index("-o")
# Assume the output is a relative path is a CWD was set.
if cwd:
return os.path.join(cwd, build_args[i + 1])
return build_args[i + 1]
def test_path(c: str) -> bool:
# Skip any generated source files (files in the build directory).
if os.path.abspath(c).startswith(build_dir):
return False
# Raise an exception since this should never happen,
# we want to know about it early if it does, as it will cause failure
# when attempting to compile the missing file.
if not os.path.exists(c):
raise Exception("Missing source file: " + 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 repository.
# 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_with_cwd = [
(c, *split_build_args_with_cwd(build_args_str))
for (c, build_args_str) in args
if test_path(c)
]
del args
print("Operating on %d of %d files..." % (len(args_with_cwd), args_orig_len))
for (c, build_args, build_cwd) in args_with_cwd:
print(" ", c)
del args_orig_len
if jobs > 1:
# Group edits to avoid one file holding up the queue before other edits can be worked on.
# Custom setup/tear-down functions still block though.
edits_to_apply_grouped = edit_group_compatible(edits_to_apply)
else:
# No significant advantage in grouping, split each into a group of one for simpler debugging/execution.
edits_to_apply_grouped = [[edit] for edit in edits_to_apply]
for i, edits_group in enumerate(edits_to_apply_grouped):
print("Applying edit:", edits_group, "(%d of %d)" % (i + 1, len(edits_to_apply_grouped)))
edit_generator_class = edit_class_from_id(edits_group[0])
shared_edit_data = edit_generator_class.setup()
try:
if jobs > 1:
args_expanded = [(
c,
output_from_build_args(build_args, build_cwd),
build_args,
build_cwd,
skip_test,
verbose_compile,
verbose_edit_actions,
shared_edit_data,
edits_group,
) for (c, build_args, build_cwd) in args_with_cwd]
pool = multiprocessing.Pool(processes=jobs)
pool.starmap(wash_source_with_edit_list, args_expanded)
del args_expanded
else:
# now we have commands
for c, build_args, build_cwd in args_with_cwd:
wash_source_with_edit_list(
c,
output_from_build_args(build_args, build_cwd),
build_args,
build_cwd,
skip_test,
verbose_compile,
verbose_edit_actions,
shared_edit_data,
edits_group,
)
except Exception as ex:
raise ex
finally:
edit_generator_class.teardown(shared_edit_data)
print("\n" "Exit without errors")
return 0
def create_parser(edits_all: Sequence[str], edits_all_default: Sequence[str]) -> argparse.ArgumentParser:
from textwrap import indent
# Create doc-string for edits.
edits_all_docs = []
for edit in edits_all:
# `%` -> `%%` is needed for `--help` not to interpret these as formatting arguments.
edits_all_docs.append(
" %s\n%s" % (
edit,
indent(edit_docstring_from_id(edit).replace("%", "%%"), ' '),
)
)
# Create doc-string for verbose.
verbose_all_docs = []
for verbose_id, verbose_doc in VERBOSE_INFO:
# `%` -> `%%` is needed for `--help` not to interpret these as formatting arguments.
verbose_all_docs.append(
" %s\n%s" % (
verbose_id,
indent(verbose_doc.replace("%", "%%"), " "),
)
)
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='+',
default=(
r".*\.(c|cc|cpp)$",
),
required=False,
metavar="REGEX",
help="Match file paths against this expression",
)
parser.add_argument(
"--edits",
dest="edits",
default=",".join(edits_all_default),
help=(
"Specify the edit preset to run.\n"
"\n" +
"\n".join(edits_all_docs) + "\n"
"Multiple edits may be passed at once (comma separated, no spaces).\n"
"\n"
"The default value for this argument includes edits which are unlikely\n"
"to cause problems on other platforms and are generally considered safe to apply.\n"
"Non-default edits should be manually reviewed in more derail before committing."
),
required=False,
)
parser.add_argument(
"--verbose",
dest="verbose",
default="",
help=(
"Specify verbose actions.\n\n" +
"\n".join(verbose_all_docs) + "\n"
"Multiple verbose types may be passed at once (comma separated, no spaces)."),
required=False,
)
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,
)
parser.add_argument(
"--jobs",
dest="jobs",
type=int,
default=0,
help=(
"The number of processes to use. "
"Defaults to zero which detects the available cores, 1 is single threaded (useful for debugging)."
),
required=False,
)
return parser
def main() -> int:
edits_all = edit_function_get_all()
edits_all_default = edit_function_get_all(is_default=True)
parser = create_parser(edits_all, edits_all_default)
args = parser.parse_args()
build_dir = os.path.normpath(os.path.abspath(args.build_dir))
regex_list = []
for expr in args.match:
try:
regex_list.append(re.compile(expr))
except Exception as ex:
print(f"Error in expression: {expr}\n {ex}")
return 1
edits_all_from_args = args.edits.split(",")
if not edits_all_from_args:
print("Error, no '--edits' arguments given!")
return 1
for edit in edits_all_from_args:
if edit not in edits_all:
print("Error, unrecognized '--edits' argument '%s', expected a value in {%s}" % (
edit,
", ".join(edits_all),
))
return 1
verbose_all = [verbose_id for verbose_id, _ in VERBOSE_INFO]
verbose_compile = False
verbose_edit_actions = False
verbose_all_from_args = args.verbose.split(",") if args.verbose else []
while verbose_all_from_args:
match(verbose_id := verbose_all_from_args.pop()):
case "compile":
verbose_compile = True
case "edit_actions":
verbose_edit_actions = True
case _:
print("Error, unrecognized '--verbose' argument '%s', expected a value in {%s}" % (
verbose_id,
", ".join(verbose_all),
))
return 1
if len(edits_all_from_args) > 1:
for edit in edits_all:
if edit not in edits_all_from_args:
print("Skipping edit: %s, default=%d" % (edit, getattr(edit_generators, edit).is_default))
return run_edits_on_directory(
build_dir=build_dir,
regex_list=regex_list,
edits_to_apply=edits_all_from_args,
skip_test=args.skip_test,
jobs=args.jobs,
verbose_compile=verbose_compile,
verbose_edit_actions=verbose_edit_actions,
)
if __name__ == "__main__":
sys.exit(main())