#!/usr/bin/env python3 """ Rewrites gtest names to the required style in this project: the suite name is CamelCase, the test-case name is snake_case. TEST(SuiteName, test_case_name) The gtest `DISABLED_` prefix is kept verbatim on either name. Both conversions fold acronyms the way a reader expects: `SetAndResetAccountTxnID` -> `set_and_reset_account_txn_id`, not `set_and_reset_account_txn_i_d`. The first argument of `TEST_F`, `TEST_P`, `TYPED_TEST` and `TYPED_TEST_P` is a fixture class rather than a free identifier, so rewriting it here would leave the class it names behind. Those are reported for a human to rename (clang-tidy checks the class declaration itself, via readability-identifier-naming). Usage: ./bin/pre-commit/fix_gtest_names.py ... """ import re import sys from collections import Counter from pathlib import Path # A test-case definition, `MACRO(SuiteOrFixture, TestName)`, anchored at the # start of a line so that commented-out definitions and project macros that # merely look similar (`TEST_EXPECT(...)`) are left alone. The `\s*` between # arguments allows for a definition clang-format wrapped over several lines. PATTERN = re.compile( r"(?P^[ \t]*(?PTYPED_TEST_P|TYPED_TEST|TEST_F|TEST_P|TEST)\s*\(\s*)" r"(?P\w+)(?P\s*,\s*)(?P\w+)(?P\s*\))", re.MULTILINE, ) # The macros whose first argument names a fixture class, not a free identifier. FIXTURE_MACROS = ("TEST_F", "TEST_P", "TYPED_TEST", "TYPED_TEST_P") DISABLED = "DISABLED_" ACRONYM_BOUNDARY = re.compile(r"([A-Z]+)([A-Z][a-z])") WORD_BOUNDARY = re.compile(r"([a-z\d])([A-Z])") def _split_disabled(name: str) -> tuple[str, str]: """Splits off gtest's `DISABLED_` prefix, which is kept verbatim.""" if name.startswith(DISABLED): return DISABLED, name[len(DISABLED) :] return "", name def snake_case(name: str) -> str: """Returns the name in snake_case, leaving acronyms whole. `SetAndResetAccountTxnID` -> `set_and_reset_account_txn_id`, `parseStatRSSkB` -> `parse_stat_rs_sk_b`. """ prefix, core = _split_disabled(name) core = ACRONYM_BOUNDARY.sub(r"\1_\2", core) return prefix + WORD_BOUNDARY.sub(r"\1_\2", core).lower() def camel_case(name: str) -> str: """Returns the name in CamelCase, capitalizing each underscored word. Only the letters that have to change are touched, so acronyms survive: a conversion that went via snake_case would turn `SHAMapTest` into `ShaMapTest`, whereas here it is already CamelCase and stays put. `json_value` -> `JsonValue`, `parseStatRSSkB` -> `ParseStatRSSkB`. """ prefix, core = _split_disabled(name) return prefix + "".join(w[:1].upper() + w[1:] for w in core.split("_") if w) def _corrected(match: re.Match) -> tuple[str, str]: """Returns the suite and test-case names this definition should end up with.""" suite = match["suite"] return ( suite if match["macro"] in FIXTURE_MACROS else camel_case(suite), snake_case(match["name"]), ) def fix_source(text: str) -> tuple[str, list[str]]: """Returns the corrected text and one `line: message` report per bad name.""" # gtest joins the suite and test names into one class name, so two test # cases whose joined names agree cannot coexist: `TEST(a, b_c)` and # `TEST(a_b, c)` both define `a_b_c_Test`. A rename that would introduce # such a clash is reported for a human instead of applied. joined = Counter("_".join(_corrected(m)) for m in PATTERN.finditer(text)) reports = [] def rewrite(match: re.Match) -> str: suite, name = match["suite"], match["name"] new_suite, new_name = _corrected(match) line = text.count("\n", 0, match.start()) + 1 if match["macro"] in FIXTURE_MACROS and camel_case(suite) != suite: reports.append( f"{line}: fixture '{suite}' is not CamelCase: rename the class " f"to '{camel_case(suite)}' by hand" ) if (new_suite, new_name) == (suite, name): return match[0] if joined[f"{new_suite}_{new_name}"] > 1: reports.append( f"{line}: cannot rename '{suite}, {name}' to '{new_suite}, " f"{new_name}': another test case already generates that name" ) return match[0] if new_suite != suite: reports.append(f"{line}: renamed suite '{suite}' to '{new_suite}'") if new_name != name: reports.append(f"{line}: renamed test case '{name}' to '{new_name}'") return match["head"] + new_suite + match["mid"] + new_name + match["tail"] return PATTERN.sub(rewrite, text), reports def fix_names(path: Path) -> bool: """Corrects one file's gtest names, reporting each on stdout.""" original = path.read_text(encoding="utf-8") fixed, reports = fix_source(original) for report in reports: print(f"{path}:{report}") if fixed != original: path.write_text(fixed, encoding="utf-8") return not reports def main() -> int: files = [Path(f) for f in sys.argv[1:]] success = True for path in files: success &= fix_names(path) return 0 if success else 1 if __name__ == "__main__": sys.exit(main())