Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ repos:
language: system
files: (BUILD|BUILD\.bazel|WORKSPACE|\.bazelrc|[^/]+\.(py|bzl|bazel|yaml|yml|rs|c|cpp|h|hpp))$
pass_filenames: false
- id: test-doubles-naming-fix
name: Check and fix test double file names
entry: ./scripts/fix_test_doubles_naming_precommit.py
language: python
files: '\.(cc|cpp|h|hpp)$'
pass_filenames: true
- repo: https://github.com/pocc/pre-commit-hooks
rev: v1.3.5
hooks:
Expand Down
240 changes: 240 additions & 0 deletions scripts/fix_test_doubles_naming_precommit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
#!/usr/bin/env python3
# *******************************************************************************
# Copyright (c) 2026 Contributors to the Eclipse Foundation
#
# See the NOTICE file(s) distributed with this work for additional
# information regarding copyright ownership.
#
# This program and the accompanying materials are made available under the
# terms of the Apache License Version 2.0 which is available at
# https://www.apache.org/licenses/LICENSE-2.0
#
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************
"""Check that test doubles (mocks, stubs. etc.) follow our agreed naming convention.

This script searches for test doubles and optionally fixes any doubles with non-conforming filenames.
This is designed to be compatible with precommit hooks, where the files to check are passed as
parameters.

Usage:
python3 ./fix_test_doubles_naming_precommit.py [-h] [--dry-run] [filenames ...]

Example:
python3 ./fix_test_doubles_naming_precommit.py --dry-run component_mock.cpp
"""

from __future__ import annotations
import logging
import sys
import argparse
import re
from pathlib import Path
from enum import Enum
from dataclasses import dataclass


class TestDoubleName(Enum):
MOCK = "mock"
STUB = "stub"
FAKE = "fake"

@staticmethod
def from_path(path: Path) -> TestDoubleName:
for double in TestDoubleName:
if double.value in str(path):
return double
raise NotImplementedError

@staticmethod
def to_regex_alternation() -> str:
return "(" + ("|".join([name.value for name in TestDoubleName]) + ")")


@dataclass(frozen=True)
class RenameOperation:
"""
Contains the necessary information to conduct a file rename operation
"""

source: Path
target: Path


def normalize_base_name(old_name: str) -> str:
"""
Remove the first occurrence of a test double name from a filename
"""
double_name = TestDoubleName.to_regex_alternation()
# Match the double name only when it sits between separators (`_`/`-`) or
# the start/end of the string, so e.g. "mockable" is left alone. The two
# boundary groups are captured so a shared separator (e.g. the "_" in
# "foo_mock_bar") is preserved rather than consumed twice; the double
# name itself (group 2) is dropped by omitting it from the replacement.
base = re.sub(rf"(?i)(^|[_-]){double_name}($|[_-])", r"\1\3", old_name, count=1)
# Collapse any doubled-up separator left behind (e.g. "foo__bar") and
# trim a leading/trailing one (e.g. from "mock_foo" or "foo_mock").
base = re.sub(r"[_-]{2,}", "_", base)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will replace dashes with underscores, seems unintended since the rest of the code preserves dashes.

return base.strip("_-")


def build_target_name(path: Path) -> Path | None:
"""
Create a path containing the corrected name of the test double
"""
filename, extension = path.stem, path.suffix
test_double_name = TestDoubleName.from_path(path).value

base = normalize_base_name(filename)
# Build a new name using the test double as a prefix
target = f"{test_double_name}_{base}{extension}"

return path.with_name(target)


def is_double(path: Path) -> bool:
"""
Return true if the given path is a test double
"""
SUPPORTED_TEST_DOUBLE_NAMES = [name.value for name in TestDoubleName]
TEST_DOUBLE_SEARCH_PATTERNS = [
re.compile(rf"(?i)(^|[^a-z0-9]){name}([^a-z0-9]|$)")
for name in SUPPORTED_TEST_DOUBLE_NAMES
]
return any([r.search(str(path)) is not None for r in TEST_DOUBLE_SEARCH_PATTERNS])


def has_multiple_double_names(path: Path) -> bool:
"""
Return true if more than one test double names are present in a file name
"""
pattern = TestDoubleName.to_regex_alternation()
matches = re.findall(pattern, str(path), flags=re.IGNORECASE)
return len(matches) > 1


def define_operations(filenames: list[str]) -> list[RenameOperation]:
"""
Define all the rename operations required to make the source files conformant
"""
operations: list[RenameOperation] = []
for path_str in filenames:
path = Path(path_str)

# Skip paths that don't exist as files
if not path.exists() or not path.is_file():
logging.warning(f"Skipping {path}, it does not exist as a file.")
continue

# Skip files that are not test doubles
if not is_double(path):
logging.info(f"Ignoring {path}, it is not a recognised test double.")
continue

# Error if we find more than one test double name in a file name
if has_multiple_double_names(path):
raise ValueError(
f"Invalid file name {path} contains multiple double names."
)

# Error if there is more than one file extension
if len(path.suffixes) > 1:
raise ValueError(
f"More than one file extension found for test double {path}. Fix this!"
)

target_name = build_target_name(path)

# Skip files that are already named correctly
if path == target_name:
continue

operations.append(RenameOperation(source=path, target=target_name))
return operations


def apply_operations(operations: list[RenameOperation]) -> None:
"""
Do the renaming of the files
"""
for operation in operations:
operation.source.rename(operation.target)


def has_conflicts(operations: list[RenameOperation]):
"""
Check if any of the operations will conflict with a previous operation, or an existing file
"""
seen = {}
conflicts = 0

for operation in operations:
if operation.target.exists():
logging.error(
f"Conflict detected: Renaming {operation.source} to {operation.target} will overwrite an existing file"
)
conflicts += 1
if operation.target in seen:
conflicting_source = seen[operation.target]
logging.error(
f"Conflict detected: Renaming {operation.source} to {operation.target} will conflict with the rename from {conflicting_source} to {operation.target}"
)
conflicts += 1
else:
seen[operation.target] = operation.source

return conflicts > 0


def main() -> int:
parser = argparse.ArgumentParser(
description="Rename test double files to conform with a prefix format."
)
parser.add_argument(
"filenames",
nargs="*",
help="Files to check.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Do not make any changes to any files.",
)
args = parser.parse_args()

operations = define_operations(args.filenames)

if has_conflicts(operations):
logging.error("Conflicts detected, aborting.")
return 1

if not operations:
logging.info("No files need renaming.")
return 0

logging.warning("Non-conformant files have been found, fixing now.")
action = "Renaming"
if args.dry_run:
action += " (dry-run)"
for operation in operations:
logging.warning(f"{action}: {operation.source} -> {operation.target}.")

if not args.dry_run:
apply_operations(operations)
else:
logging.info("Dry run only. Re-run without --dry-run to perform changes.")

# We return success only if no rename operations were required
if operations:
return 1

return 0


if __name__ == "__main__":
logging.basicConfig(
format="%(levelname)s: %(message)s",
)
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
raise SystemExit(main())
Loading