From 4d5214ac10a93e07adaf4678d92267c9adc13c0a Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sat, 20 Dec 2025 22:09:24 +0100 Subject: [PATCH 01/11] feat: implement CPP rule to ensure header guards match directory structure --- CHANGELOG.md | 7 ++ src/devops/config/config_cpp.py | 19 ++++ src/devops/cpp/checks.py | 4 +- src/devops/cpp/license_header.py | 12 ++- src/devops/cpp/style_rules.py | 152 ++++++++++++++++++++++++++++++- src/devops/rules/__init__.py | 4 +- src/devops/rules/rules.py | 28 ++++-- tests/cpp/test_license_header.py | 44 ++++++--- tests/rules/test_rules.py | 15 --- tests/scripts/test_cpp_checks.py | 6 +- 10 files changed, 244 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 946bdcc..d2f5a99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. ## Next Release +### Features + +#### + +- Add CPP rule to check that in a header file there is always a header guard present +- Adding option to enforce header guard format via file path + ## [0.0.3](https://github.com/repo/owner/releases/tag/0.0.3) - 2025-12-20 diff --git a/src/devops/config/config_cpp.py b/src/devops/config/config_cpp.py index d978ebb..c58f51b 100644 --- a/src/devops/config/config_cpp.py +++ b/src/devops/config/config_cpp.py @@ -13,16 +13,23 @@ class CppConfig: # Enable or disable running C++ style checks (e.g., clang-format, clang-tidy). style_checks: bool = True + # Enable or disable verification that source files contain # the expected license header. license_header_check: bool = True + # Path to the license header file whose contents should be enforced, or None to use # the tool's default behavior (for example, no custom license header content). license_header: str | None = None + # If True, limit checks to files that are currently staged # (e.g., in a pre-commit hook). check_only_staged_files: bool = False + # If True, enforce that header guards match the file path. + # This helps ensure consistency and prevents duplicate header guards. + header_guards_according_to_filepath: bool = False + def to_toml_lines(self) -> list[str]: """Convert the CppConfig to TOML lines. @@ -50,6 +57,11 @@ def to_toml_lines(self) -> list[str]: f"#check_only_staged_files = {str(self.check_only_staged_files).lower()}\n" ) + lines.append( + "#header_guards_according_to_filepath = " + f"{str(self.header_guards_according_to_filepath).lower()}\n" + ) + return lines @@ -80,11 +92,18 @@ def parse_cpp_config(raw_config: dict) -> CppConfig: table, "check_only_staged_files", default=CppConfig.check_only_staged_files ) + header_guards_according_to_filepath = get_bool( + table, + "header_guards_according_to_filepath", + default=CppConfig.header_guards_according_to_filepath, + ) + config = CppConfig( style_checks=style_checks, license_header_check=license_header_check, license_header=license_header, check_only_staged_files=check_only_staged_files, + header_guards_according_to_filepath=header_guards_according_to_filepath, ) config_logger.debug(f"Parsed C++ configuration: {config}") diff --git a/src/devops/cpp/checks.py b/src/devops/cpp/checks.py index e191261..7105191 100644 --- a/src/devops/cpp/checks.py +++ b/src/devops/cpp/checks.py @@ -14,6 +14,7 @@ ) from devops.logger import cpp_check_logger from devops.rules import ( + FileRuleInput, ResultType, Rule, filter_file_rules, @@ -89,6 +90,7 @@ def run_file_rules(rules: list[Rule], file: Path) -> list[ResultType]: """ results = [] + file = Path(file) # to be 100% sure file_type = determine_file_type(file) if any(not is_file_rule(rule) for rule in rules): @@ -101,7 +103,7 @@ def run_file_rules(rules: list[Rule], file: Path) -> list[ResultType]: if file_type not in rule.file_types: continue - results.append(rule.apply((content,))) + results.append(rule.apply(FileRuleInput(file_content=content, path=file))) return results diff --git a/src/devops/cpp/license_header.py b/src/devops/cpp/license_header.py index d3932f3..e0eed00 100644 --- a/src/devops/cpp/license_header.py +++ b/src/devops/cpp/license_header.py @@ -2,21 +2,25 @@ from __future__ import annotations +import typing from pathlib import Path from devops.files import file_exist, open_file from devops.rules import ResultType, ResultTypeEnum, Rule, RuleInputType, RuleType +if typing.TYPE_CHECKING: + from devops.rules import FileRuleInput + def check_license_header( - file_content: str, required_header_file: str | Path + file_rule_input: FileRuleInput, required_header_file: str | Path ) -> ResultType: """Check if the file content starts with the required license header. Parameters ---------- - file_content: str - The content of the file to check. + file_rule_input: FileRuleInput + The input containing the file content and path to check. required_header_file: str | Path The path to the file containing the required license header. @@ -30,6 +34,8 @@ def check_license_header( DevOpsFileNotFoundError If the required header file does not exist. """ + file_content = file_rule_input.file_content + required_header_file = Path(required_header_file) # return value can be ignored as exception will be raised if file does not exist diff --git a/src/devops/cpp/style_rules.py b/src/devops/cpp/style_rules.py index a355247..0c45533 100644 --- a/src/devops/cpp/style_rules.py +++ b/src/devops/cpp/style_rules.py @@ -1,8 +1,17 @@ """C++ rules for mstd devops.""" -from devops.rules import Rule, RuleInputType, RuleType +from __future__ import annotations + +import typing + +from devops import __GLOBAL_CONFIG__ +from devops.files import FileType +from devops.rules import ResultType, ResultTypeEnum, Rule, RuleInputType, RuleType from devops.utils import check_key_sequence_ordered +if typing.TYPE_CHECKING: + from devops.rules import FileRuleInput + class CheckKeySeqOrder(Rule): """Rule to check that a specific key sequence appears only in a given order.""" @@ -25,6 +34,145 @@ def __init__(self, key_sequence: str) -> None: ) +class HeaderGuardError(Exception): + """Custom exception for header guard errors.""" + + +def find_define_macro(lines: list[str], macro: str) -> bool: + """Check if the #define for the given macro exists in the lines. + + Parameters + ---------- + lines: list[str] + The lines of the header file. + macro: str + The header guard macro to look for. + + Returns + ------- + bool + True if the #define for the macro is found, False otherwise. + + """ + for _line in lines: + line = _line.strip() + if line.startswith("#define"): + parts = line.split() + if len(parts) <= 1: + continue + if parts[1] == macro: + return True + return False + + +def find_header_guard(lines: list[str]) -> str: + """Find the header guard macro in the given lines. + + Parameters + ---------- + lines: list[str] + The lines of the header file. + + Returns + ------- + str + The header guard macro if found. + + Raises + ------ + HeaderGuardError + If the header guard is not properly defined. + + """ + macro = None + for _line in lines: + line = _line.strip() + if line.startswith("#ifndef"): + parts = line.split() + if len(parts) <= 1: + continue + macro = parts[1] + + if macro is None: + msg = "Header guard macro not found with #ifndef." + raise HeaderGuardError(msg) + + is_defined = find_define_macro(lines, macro) + + if not is_defined: + msg = "Header guard macro not defined with #define." + raise HeaderGuardError(msg) + + if not any("#endif" in line.strip() for line in lines): + msg = "Header guard missing closing #endif." + raise HeaderGuardError(msg) + + return macro + + +def check_header_guards(file_rule_input: FileRuleInput) -> ResultType: + """Check if the C++ header file has proper header guards. + + Parameters + ---------- + file_rule_input: FileRuleInput + The input containing the file content and path to check. + + Returns + ------- + ResultType + The result of the header guard check. + """ + file_content = file_rule_input.file_content + path = file_rule_input.path + + lines = file_content.splitlines() + try: + guard_macro = find_header_guard(lines) + except HeaderGuardError as e: + return ResultType(ResultTypeEnum.Error, str(e)) + + if guard_macro is None: + return ResultType( + ResultTypeEnum.Error, + "Missing header guard (#ifndef ... #define ... #endif).", + ) + + if __GLOBAL_CONFIG__.cpp.header_guards_according_to_filepath and path is not None: + expected_macro = str(path).upper() + expected_macro = expected_macro.removeprefix("INCLUDE/") + expected_macro = expected_macro.removeprefix("TEST/") + expected_macro = expected_macro.replace("/", "__") + expected_macro = expected_macro.removesuffix(".HPP") + expected_macro = expected_macro.removesuffix(".H") + expected_macro = "__" + expected_macro + "_HPP__" + + if guard_macro != expected_macro: + msg = ( + f"Header guard macro '{guard_macro}' does not match expected " + f"macro '{expected_macro}' according to file path." + ) + return ResultType(ResultTypeEnum.Error, msg) + + return ResultType(ResultTypeEnum.Ok) + + +class CheckHeaderGuards(Rule): + """Rule to check for proper header guards in C++ header files.""" + + def __init__(self) -> None: + """Initialize CheckHeaderGuards rule.""" + super().__init__( + name="CheckHeaderGuards", + description="Ensure that all C++ header files have proper header guards.", + rule_type=RuleType.CPP_STYLE, + rule_input_type=RuleInputType.FILE, + file_types=[FileType.CPPHeader], + func=check_header_guards, + ) + + rule01 = CheckKeySeqOrder("static inline constexpr") +rule02 = CheckHeaderGuards() -cpp_style_rules = [rule01] +cpp_style_rules = [rule01, rule02] diff --git a/src/devops/rules/__init__.py b/src/devops/rules/__init__.py index b9f3702..f34fb1c 100644 --- a/src/devops/rules/__init__.py +++ b/src/devops/rules/__init__.py @@ -1,7 +1,8 @@ -"""Top level package for rules in mstd checks.""" +"""Top level package for rules in devops.""" from .result_type import ResultType, ResultTypeEnum from .rules import ( + FileRuleInput, Rule, RuleInputType, RuleType, @@ -14,6 +15,7 @@ __all__ = ["ResultType", "ResultTypeEnum"] __all__ += [ + "FileRuleInput", "Rule", "RuleInputType", "RuleType", diff --git a/src/devops/rules/rules.py b/src/devops/rules/rules.py index b3b8276..79edc63 100644 --- a/src/devops/rules/rules.py +++ b/src/devops/rules/rules.py @@ -1,20 +1,23 @@ -"""Module defining rules for mstd checks.""" +"""Module defining rules for devops.""" from __future__ import annotations import typing +from dataclasses import dataclass from devops.enums import StrEnum from devops.files import FileType if typing.TYPE_CHECKING: from collections.abc import Callable + from pathlib import Path + from typing import Any from .result_type import ResultType class RuleType(StrEnum): - """Enumeration of rule types for mstd checks.""" + """Enumeration of rule types for devops.""" GENERAL = "GENERAL" CPP_STYLE = "CPP_STYLE" @@ -33,13 +36,21 @@ def cpp_rules(cls) -> set[RuleType]: class RuleInputType(StrEnum): - """Enumeration of rule input types for mstd checks.""" + """Enumeration of rule input types for devops.""" NONE = "NONE" LINE = "LINE" FILE = "FILE" +@dataclass(frozen=True) +class FileRuleInput: + """Dataclass for file rule input.""" + + file_content: str + path: Path | None = None + + class Rule: """Base class for defining a rule.""" @@ -127,13 +138,13 @@ def __init__( self.rule_identifier = Rule.increment_rule_counter(rule_type) - def apply(self, args: tuple) -> ResultType: + def apply(self, rule_input: Any) -> ResultType: """Apply the rule on a specific line. Parameters ---------- - args: tuple - The arguments to pass to the rule function. + rule_input: Any + The rule input to apply the rule on. Returns ------- @@ -141,10 +152,7 @@ def apply(self, args: tuple) -> ResultType: The result of applying the rule. """ - if isinstance(args, str): - args = (args,) - - return self.func(*args) if args else self.func() + return self.func(rule_input) def filter_cpp_rules(rules: list[Rule]) -> list[Rule]: diff --git a/tests/cpp/test_license_header.py b/tests/cpp/test_license_header.py index 1876445..500e06a 100644 --- a/tests/cpp/test_license_header.py +++ b/tests/cpp/test_license_header.py @@ -5,7 +5,7 @@ import typing from devops.cpp.license_header import CheckLicenseHeader, check_license_header -from devops.rules import ResultTypeEnum, RuleInputType, RuleType +from devops.rules import FileRuleInput, ResultTypeEnum, RuleInputType, RuleType if typing.TYPE_CHECKING: from pathlib import Path @@ -30,7 +30,9 @@ def test_check_license_header_present(self, tmp_path: Path) -> None: # Create file content that starts with the header file_content = "// Copyright 2024\n// All rights reserved\n\nint main() {}\n" - result = check_license_header(file_content, header_file) + result = check_license_header( + FileRuleInput(file_content=file_content), header_file + ) assert result.value == ResultTypeEnum.Ok def test_check_license_header_missing(self, tmp_path: Path) -> None: @@ -49,7 +51,9 @@ def test_check_license_header_missing(self, tmp_path: Path) -> None: # Create file content without the header file_content = "int main() {}\n" - result = check_license_header(file_content, header_file) + result = check_license_header( + FileRuleInput(file_content=file_content), header_file + ) assert result.value == ResultTypeEnum.Error assert result.description == "Missing or incorrect license header." @@ -69,7 +73,9 @@ def test_check_license_header_incorrect(self, tmp_path: Path) -> None: # Create file content with different header file_content = "// Copyright 2023\n// Some rights reserved\n\nint main() {}\n" - result = check_license_header(file_content, header_file) + result = check_license_header( + FileRuleInput(file_content=file_content), header_file + ) assert result.value == ResultTypeEnum.Error assert result.description == "Missing or incorrect license header." @@ -89,7 +95,9 @@ def test_check_license_header_partial_match(self, tmp_path: Path) -> None: # Create file content with only part of the header file_content = "// Copyright 2024\n\nint main() {}\n" - result = check_license_header(file_content, header_file) + result = check_license_header( + FileRuleInput(file_content=file_content), header_file + ) assert result.value == ResultTypeEnum.Error def test_check_license_header_empty_file(self, tmp_path: Path) -> None: @@ -108,7 +116,9 @@ def test_check_license_header_empty_file(self, tmp_path: Path) -> None: # Empty file content file_content = "" - result = check_license_header(file_content, header_file) + result = check_license_header( + FileRuleInput(file_content=file_content), header_file + ) assert result.value == ResultTypeEnum.Error def test_check_license_header_empty_header(self, tmp_path: Path) -> None: @@ -127,7 +137,9 @@ def test_check_license_header_empty_header(self, tmp_path: Path) -> None: # Any file content should pass with empty header file_content = "int main() {}\n" - result = check_license_header(file_content, header_file) + result = check_license_header( + FileRuleInput(file_content=file_content), header_file + ) assert result.value == ResultTypeEnum.Ok def test_check_license_header_with_str_path(self, tmp_path: Path) -> None: @@ -146,7 +158,9 @@ def test_check_license_header_with_str_path(self, tmp_path: Path) -> None: # Create file content with the header file_content = "// Header\nint main() {}\n" - result = check_license_header(file_content, str(header_file)) + result = check_license_header( + FileRuleInput(file_content=file_content), str(header_file) + ) assert result.value == ResultTypeEnum.Ok def test_check_license_header_multiline(self, tmp_path: Path) -> None: @@ -171,7 +185,9 @@ def test_check_license_header_multiline(self, tmp_path: Path) -> None: # Create file content with the header file_content = header_text + "\n#include \n\nint main() {}\n" - result = check_license_header(file_content, header_file) + result = check_license_header( + FileRuleInput(file_content=file_content), header_file + ) assert result.value == ResultTypeEnum.Ok def test_check_license_header_with_leading_whitespace(self, tmp_path: Path) -> None: @@ -193,7 +209,9 @@ def test_check_license_header_with_leading_whitespace(self, tmp_path: Path) -> N " // Copyright 2024\n // All rights reserved\n\nint main() {}\n" ) - result = check_license_header(file_content, header_file) + result = check_license_header( + FileRuleInput(file_content=file_content), header_file + ) assert result.value == ResultTypeEnum.Ok @@ -238,7 +256,7 @@ def test_check_license_header_class_apply_with_valid_content( rule = CheckLicenseHeader(str(header_file)) file_content = "// Header\nint main() {}\n" - result = rule.apply((file_content,)) + result = rule.apply(FileRuleInput(file_content=file_content)) assert result.value == ResultTypeEnum.Ok def test_check_license_header_class_apply_with_invalid_content( @@ -258,7 +276,7 @@ def test_check_license_header_class_apply_with_invalid_content( rule = CheckLicenseHeader(str(header_file)) file_content = "int main() {}\n" - result = rule.apply((file_content,)) + result = rule.apply(FileRuleInput(file_content=file_content, path=header_file)) assert result.value == ResultTypeEnum.Error assert result.description == "Missing or incorrect license header." @@ -277,5 +295,5 @@ def test_check_license_header_class_with_path_object(self, tmp_path: Path) -> No rule = CheckLicenseHeader(header_file) file_content = "// Header\nint main() {}\n" - result = rule.apply((file_content,)) + result = rule.apply(FileRuleInput(file_content=file_content, path=header_file)) assert result.value == ResultTypeEnum.Ok diff --git a/tests/rules/test_rules.py b/tests/rules/test_rules.py index 378d027..b058d3c 100644 --- a/tests/rules/test_rules.py +++ b/tests/rules/test_rules.py @@ -175,21 +175,6 @@ def check_func(line: str) -> ResultType: result = rule.apply("goodbye world") assert result.value == ResultTypeEnum.Error - def test_apply_with_tuple_arg(self) -> None: - """Test Rule apply with tuple argument.""" - - def check_func(a: str, b: str) -> ResultType: - if a == b: - return ResultType(ResultTypeEnum.Ok) - return ResultType(ResultTypeEnum.Error, "Values don't match") - - rule = Rule(name="match_check", func=check_func) - result = rule.apply(("test", "test")) - assert result.value == ResultTypeEnum.Ok - - result = rule.apply(("test1", "test2")) - assert result.value == ResultTypeEnum.Error - class TestRuleFiltering: """Tests for rule filtering functions.""" diff --git a/tests/scripts/test_cpp_checks.py b/tests/scripts/test_cpp_checks.py index 67151a6..a420bd3 100644 --- a/tests/scripts/test_cpp_checks.py +++ b/tests/scripts/test_cpp_checks.py @@ -14,6 +14,8 @@ if typing.TYPE_CHECKING: from pathlib import Path + from devops.rules import FileRuleInput + cpp_rules = build_cpp_rules() @@ -378,8 +380,8 @@ def test_run_file_rules_receives_full_content(self, tmp_path: Path) -> None: received_content = [] - def capture_content(content: str) -> ResultType: - received_content.append(content) + def capture_content(file_rule_input: FileRuleInput) -> ResultType: + received_content.append(file_rule_input.file_content) return ResultType(ResultTypeEnum.Ok) rule = Rule( From 3c9223dbc90a9d19c137d1a186619eeeff6f3dcd Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sat, 20 Dec 2025 22:17:49 +0100 Subject: [PATCH 02/11] Update src/devops/cpp/style_rules.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/devops/cpp/style_rules.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/devops/cpp/style_rules.py b/src/devops/cpp/style_rules.py index 0c45533..35225a6 100644 --- a/src/devops/cpp/style_rules.py +++ b/src/devops/cpp/style_rules.py @@ -132,12 +132,6 @@ def check_header_guards(file_rule_input: FileRuleInput) -> ResultType: except HeaderGuardError as e: return ResultType(ResultTypeEnum.Error, str(e)) - if guard_macro is None: - return ResultType( - ResultTypeEnum.Error, - "Missing header guard (#ifndef ... #define ... #endif).", - ) - if __GLOBAL_CONFIG__.cpp.header_guards_according_to_filepath and path is not None: expected_macro = str(path).upper() expected_macro = expected_macro.removeprefix("INCLUDE/") From fb2a8f7bf4d364310d8bf8d804b801213e8d9433 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sat, 20 Dec 2025 22:19:47 +0100 Subject: [PATCH 03/11] fix: correct header in changelog for CPP rules section --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f5a99..68ff601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. ### Features -#### +#### CPP Rules - Add CPP rule to check that in a header file there is always a header guard present - Adding option to enforce header guard format via file path From 756804589b1ff68423b6ea65ccc52d571560eb36 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sat, 20 Dec 2025 22:20:14 +0100 Subject: [PATCH 04/11] Update src/devops/cpp/style_rules.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/devops/cpp/style_rules.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/devops/cpp/style_rules.py b/src/devops/cpp/style_rules.py index 35225a6..34e480b 100644 --- a/src/devops/cpp/style_rules.py +++ b/src/devops/cpp/style_rules.py @@ -85,13 +85,22 @@ def find_header_guard(lines: list[str]) -> str: """ macro = None - for _line in lines: + # Search for the header guard macro near the beginning of the file. + # Limit the search to the first N lines to avoid picking up feature-detection + # or other conditional macros that appear later in the file. + max_header_guard_search_lines = 50 + for idx, _line in enumerate(lines): + if idx > max_header_guard_search_lines: + break line = _line.strip() if line.startswith("#ifndef"): parts = line.split() if len(parts) <= 1: continue macro = parts[1] + # Assume the first valid #ifndef within the search window is the + # header guard and stop searching to avoid later #ifndef directives. + break if macro is None: msg = "Header guard macro not found with #ifndef." From dac5bb65d37824ec055a2a81ee9c00794c470a8f Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sat, 20 Dec 2025 22:20:35 +0100 Subject: [PATCH 05/11] Update src/devops/cpp/style_rules.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/devops/cpp/style_rules.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/devops/cpp/style_rules.py b/src/devops/cpp/style_rules.py index 34e480b..75fd7f6 100644 --- a/src/devops/cpp/style_rules.py +++ b/src/devops/cpp/style_rules.py @@ -112,7 +112,7 @@ def find_header_guard(lines: list[str]) -> str: msg = "Header guard macro not defined with #define." raise HeaderGuardError(msg) - if not any("#endif" in line.strip() for line in lines): + if not any(line.lstrip().startswith("#endif") for line in lines): msg = "Header guard missing closing #endif." raise HeaderGuardError(msg) From 4a04225a3c7e0770bd4f016abb0c53f290f2e464 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 21:21:09 +0000 Subject: [PATCH 06/11] Initial plan From 82f792f9cc82c50e0c0555ec2299bbf634d7ba68 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 21:26:30 +0000 Subject: [PATCH 07/11] Add comprehensive test coverage for CheckHeaderGuards rule Co-authored-by: 97gamjak <77228802+97gamjak@users.noreply.github.com> --- tests/cpp/test_style_rules.py | 466 +++++++++++++++++++++++++++++++++- 1 file changed, 464 insertions(+), 2 deletions(-) diff --git a/tests/cpp/test_style_rules.py b/tests/cpp/test_style_rules.py index effac7e..d304c03 100644 --- a/tests/cpp/test_style_rules.py +++ b/tests/cpp/test_style_rules.py @@ -1,8 +1,27 @@ """Unit tests for C++ style rules.""" -from devops.cpp.style_rules import CheckKeySeqOrder, cpp_style_rules, rule01 +from __future__ import annotations + +import typing +from pathlib import Path +from unittest.mock import Mock, patch + +from devops.cpp.style_rules import ( + CheckHeaderGuards, + CheckKeySeqOrder, + HeaderGuardError, + check_header_guards, + cpp_style_rules, + find_define_macro, + find_header_guard, + rule01, + rule02, +) from devops.files import FileType -from devops.rules import ResultTypeEnum, Rule, RuleInputType, RuleType +from devops.rules import FileRuleInput, ResultTypeEnum, Rule, RuleInputType, RuleType + +if typing.TYPE_CHECKING: + from pathlib import Path as PathType class TestCheckKeySeqOrder: @@ -259,3 +278,446 @@ def test_wrong_order_in_function(self) -> None: "inline static constexpr auto compute() -> int { return 42; }" ) assert result.value == ResultTypeEnum.Error + + +class TestFindDefineMacro: + """Tests for find_define_macro function.""" + + def test_find_define_macro_present(self) -> None: + """Test finding a #define macro that exists.""" + lines = [ + "#ifndef MY_HEADER_HPP", + "#define MY_HEADER_HPP", + "", + "// code here", + "#endif", + ] + result = find_define_macro(lines, "MY_HEADER_HPP") + assert result is True + + def test_find_define_macro_not_present(self) -> None: + """Test when #define macro does not exist.""" + lines = [ + "#ifndef MY_HEADER_HPP", + "#define OTHER_MACRO", + "", + "// code here", + "#endif", + ] + result = find_define_macro(lines, "MY_HEADER_HPP") + assert result is False + + def test_find_define_macro_with_whitespace(self) -> None: + """Test finding #define with leading/trailing whitespace.""" + lines = [ + " #ifndef MY_HEADER_HPP ", + " #define MY_HEADER_HPP ", + "", + "// code here", + "#endif", + ] + result = find_define_macro(lines, "MY_HEADER_HPP") + assert result is True + + def test_find_define_macro_multiple_defines(self) -> None: + """Test finding specific macro among multiple #defines.""" + lines = [ + "#ifndef MY_HEADER_HPP", + "#define FIRST_MACRO", + "#define MY_HEADER_HPP", + "#define LAST_MACRO", + "#endif", + ] + result = find_define_macro(lines, "MY_HEADER_HPP") + assert result is True + + def test_find_define_macro_malformed_define(self) -> None: + """Test with malformed #define (no macro name).""" + lines = [ + "#ifndef MY_HEADER_HPP", + "#define", + "#endif", + ] + result = find_define_macro(lines, "MY_HEADER_HPP") + assert result is False + + def test_find_define_macro_empty_lines(self) -> None: + """Test with empty list of lines.""" + lines: list[str] = [] + result = find_define_macro(lines, "MY_HEADER_HPP") + assert result is False + + +class TestFindHeaderGuard: + """Tests for find_header_guard function.""" + + def test_find_header_guard_valid(self) -> None: + """Test finding a valid header guard.""" + lines = [ + "#ifndef MY_HEADER_HPP", + "#define MY_HEADER_HPP", + "", + "// code here", + "#endif", + ] + result = find_header_guard(lines) + assert result == "MY_HEADER_HPP" + + def test_find_header_guard_missing_ifndef(self) -> None: + """Test when #ifndef is missing.""" + lines = [ + "#define MY_HEADER_HPP", + "", + "// code here", + "#endif", + ] + try: + find_header_guard(lines) + assert False, "Should have raised HeaderGuardError" + except HeaderGuardError as e: + assert "Header guard macro not found with #ifndef" in str(e) + + def test_find_header_guard_missing_define(self) -> None: + """Test when #define is missing.""" + lines = [ + "#ifndef MY_HEADER_HPP", + "", + "// code here", + "#endif", + ] + try: + find_header_guard(lines) + assert False, "Should have raised HeaderGuardError" + except HeaderGuardError as e: + assert "Header guard macro not defined with #define" in str(e) + + def test_find_header_guard_missing_endif(self) -> None: + """Test when #endif is missing.""" + lines = [ + "#ifndef MY_HEADER_HPP", + "#define MY_HEADER_HPP", + "", + "// code here", + ] + try: + find_header_guard(lines) + assert False, "Should have raised HeaderGuardError" + except HeaderGuardError as e: + assert "Header guard missing closing #endif" in str(e) + + def test_find_header_guard_malformed_ifndef(self) -> None: + """Test with malformed #ifndef (no macro name).""" + lines = [ + "#ifndef", + "#define MY_HEADER_HPP", + "#endif", + ] + try: + find_header_guard(lines) + assert False, "Should have raised HeaderGuardError" + except HeaderGuardError as e: + assert "Header guard macro not found with #ifndef" in str(e) + + def test_find_header_guard_with_whitespace(self) -> None: + """Test header guard with leading/trailing whitespace.""" + lines = [ + " #ifndef MY_HEADER_HPP ", + " #define MY_HEADER_HPP ", + "", + "// code here", + " #endif ", + ] + result = find_header_guard(lines) + assert result == "MY_HEADER_HPP" + + def test_find_header_guard_first_valid_ifndef(self) -> None: + """Test that it finds the first valid #ifndef within search window.""" + lines = [ + "// Some comment", + "#ifndef MY_HEADER_HPP", + "#define MY_HEADER_HPP", + "", + "#ifndef FEATURE_DETECTION", + "#define FEATURE_DETECTION", + "#endif", + "", + "#endif", + ] + result = find_header_guard(lines) + assert result == "MY_HEADER_HPP" + + def test_find_header_guard_beyond_search_limit(self) -> None: + """Test that #ifndef beyond search limit is not found.""" + # Create a file with #ifndef after 50 lines + lines = ["// comment line"] * 51 + lines.append("#ifndef MY_HEADER_HPP") + lines.append("#define MY_HEADER_HPP") + lines.append("#endif") + + try: + find_header_guard(lines) + assert False, "Should have raised HeaderGuardError" + except HeaderGuardError as e: + assert "Header guard macro not found with #ifndef" in str(e) + + +class TestCheckHeaderGuards: + """Tests for check_header_guards function.""" + + def test_check_header_guards_valid(self) -> None: + """Test with valid header guards.""" + file_content = """#ifndef MY_HEADER_HPP +#define MY_HEADER_HPP + +class MyClass { +public: + void myMethod(); +}; + +#endif +""" + result = check_header_guards(FileRuleInput(file_content=file_content)) + assert result.value == ResultTypeEnum.Ok + + def test_check_header_guards_missing_ifndef(self) -> None: + """Test with missing #ifndef.""" + file_content = """#define MY_HEADER_HPP + +class MyClass { +public: + void myMethod(); +}; + +#endif +""" + result = check_header_guards(FileRuleInput(file_content=file_content)) + assert result.value == ResultTypeEnum.Error + assert "Header guard macro not found with #ifndef" in result.description + + def test_check_header_guards_missing_define(self) -> None: + """Test with missing #define.""" + file_content = """#ifndef MY_HEADER_HPP + +class MyClass { +public: + void myMethod(); +}; + +#endif +""" + result = check_header_guards(FileRuleInput(file_content=file_content)) + assert result.value == ResultTypeEnum.Error + assert "Header guard macro not defined with #define" in result.description + + def test_check_header_guards_missing_endif(self) -> None: + """Test with missing #endif.""" + file_content = """#ifndef MY_HEADER_HPP +#define MY_HEADER_HPP + +class MyClass { +public: + void myMethod(); +}; +""" + result = check_header_guards(FileRuleInput(file_content=file_content)) + assert result.value == ResultTypeEnum.Error + assert "Header guard missing closing #endif" in result.description + + def test_check_header_guards_with_path_match_disabled(self) -> None: + """Test header guards when path matching is disabled.""" + file_content = """#ifndef ANY_NAME_WORKS +#define ANY_NAME_WORKS + +class MyClass {}; + +#endif +""" + with patch("devops.cpp.style_rules.__GLOBAL_CONFIG__") as mock_config: + mock_config.cpp.header_guards_according_to_filepath = False + result = check_header_guards( + FileRuleInput( + file_content=file_content, path=Path("include/test/myfile.hpp") + ) + ) + assert result.value == ResultTypeEnum.Ok + + def test_check_header_guards_with_path_match_enabled_correct(self) -> None: + """Test header guards with path matching enabled and correct macro.""" + file_content = """#ifndef __MYFILE_HPP__ +#define __MYFILE_HPP__ + +class MyClass {}; + +#endif +""" + with patch("devops.cpp.style_rules.__GLOBAL_CONFIG__") as mock_config: + mock_config.cpp.header_guards_according_to_filepath = True + result = check_header_guards( + FileRuleInput( + file_content=file_content, path=Path("include/myfile.hpp") + ) + ) + assert result.value == ResultTypeEnum.Ok + + def test_check_header_guards_with_path_match_enabled_incorrect(self) -> None: + """Test header guards with path matching enabled but incorrect macro.""" + file_content = """#ifndef WRONG_MACRO_NAME +#define WRONG_MACRO_NAME + +class MyClass {}; + +#endif +""" + with patch("devops.cpp.style_rules.__GLOBAL_CONFIG__") as mock_config: + mock_config.cpp.header_guards_according_to_filepath = True + result = check_header_guards( + FileRuleInput( + file_content=file_content, path=Path("include/myfile.hpp") + ) + ) + assert result.value == ResultTypeEnum.Error + assert "does not match expected macro" in result.description + assert "__MYFILE_HPP__" in result.description + + def test_check_header_guards_with_include_prefix(self) -> None: + """Test header guards with INCLUDE/ prefix in path.""" + file_content = """#ifndef __UTILS__HELPER_HPP__ +#define __UTILS__HELPER_HPP__ + +class Helper {}; + +#endif +""" + with patch("devops.cpp.style_rules.__GLOBAL_CONFIG__") as mock_config: + mock_config.cpp.header_guards_according_to_filepath = True + result = check_header_guards( + FileRuleInput( + file_content=file_content, path=Path("INCLUDE/utils/helper.hpp") + ) + ) + assert result.value == ResultTypeEnum.Ok + + def test_check_header_guards_with_test_prefix(self) -> None: + """Test header guards with TEST/ prefix in path.""" + file_content = """#ifndef __MOCKS__MOCK_CLASS_HPP__ +#define __MOCKS__MOCK_CLASS_HPP__ + +class MockClass {}; + +#endif +""" + with patch("devops.cpp.style_rules.__GLOBAL_CONFIG__") as mock_config: + mock_config.cpp.header_guards_according_to_filepath = True + result = check_header_guards( + FileRuleInput( + file_content=file_content, path=Path("TEST/mocks/mock_class.hpp") + ) + ) + assert result.value == ResultTypeEnum.Ok + + def test_check_header_guards_with_h_extension(self) -> None: + """Test header guards with .h extension.""" + file_content = """#ifndef __MYHEADER_HPP__ +#define __MYHEADER_HPP__ + +void myFunction(); + +#endif +""" + with patch("devops.cpp.style_rules.__GLOBAL_CONFIG__") as mock_config: + mock_config.cpp.header_guards_according_to_filepath = True + result = check_header_guards( + FileRuleInput( + file_content=file_content, path=Path("include/myheader.h") + ) + ) + assert result.value == ResultTypeEnum.Ok + + def test_check_header_guards_no_path_provided(self) -> None: + """Test header guards when no path is provided.""" + file_content = """#ifndef MY_HEADER_HPP +#define MY_HEADER_HPP + +class MyClass {}; + +#endif +""" + with patch("devops.cpp.style_rules.__GLOBAL_CONFIG__") as mock_config: + mock_config.cpp.header_guards_according_to_filepath = True + result = check_header_guards(FileRuleInput(file_content=file_content)) + assert result.value == ResultTypeEnum.Ok + + def test_check_header_guards_nested_path(self) -> None: + """Test header guards with deeply nested path.""" + file_content = """#ifndef __A__B__C__D__FILE_HPP__ +#define __A__B__C__D__FILE_HPP__ + +class File {}; + +#endif +""" + with patch("devops.cpp.style_rules.__GLOBAL_CONFIG__") as mock_config: + mock_config.cpp.header_guards_according_to_filepath = True + result = check_header_guards( + FileRuleInput( + file_content=file_content, path=Path("include/a/b/c/d/file.hpp") + ) + ) + assert result.value == ResultTypeEnum.Ok + + +class TestCheckHeaderGuardsRule: + """Tests for CheckHeaderGuards rule class.""" + + def setup_method(self) -> None: + """Reset rule counters before each test.""" + Rule.cpp_style_rule_counter = 0 + Rule.general_rule_counter = 0 + + def test_rule_initialization(self) -> None: + """Test CheckHeaderGuards rule initialization.""" + rule = CheckHeaderGuards() + assert rule.name == "CheckHeaderGuards" + assert rule.rule_type == RuleType.CPP_STYLE + assert rule.rule_input_type == RuleInputType.FILE + assert rule.description == ( + "Ensure that all C++ header files have proper header guards." + ) + assert rule.file_types == [FileType.CPPHeader] + + def test_rule_apply_valid(self) -> None: + """Test applying rule to valid header file.""" + rule = CheckHeaderGuards() + file_content = """#ifndef MY_HEADER_HPP +#define MY_HEADER_HPP + +class MyClass {}; + +#endif +""" + result = rule.apply(FileRuleInput(file_content=file_content)) + assert result.value == ResultTypeEnum.Ok + + def test_rule_apply_invalid(self) -> None: + """Test applying rule to invalid header file.""" + rule = CheckHeaderGuards() + file_content = """class MyClass {}; +""" + result = rule.apply(FileRuleInput(file_content=file_content)) + assert result.value == ResultTypeEnum.Error + + def test_rule02_is_check_header_guards(self) -> None: + """Test that rule02 is a CheckHeaderGuards instance.""" + assert isinstance(rule02, CheckHeaderGuards) + + def test_rule02_in_cpp_style_rules(self) -> None: + """Test that rule02 is in the cpp_style_rules list.""" + assert rule02 in cpp_style_rules + + def test_cpp_style_rules_contains_header_guard_rule(self) -> None: + """Test that cpp_style_rules contains CheckHeaderGuards rule.""" + header_guard_rules = [ + rule for rule in cpp_style_rules if isinstance(rule, CheckHeaderGuards) + ] + assert len(header_guard_rules) == 1 + assert header_guard_rules[0] == rule02 From 615dca503b9c51b6db92fe9f40362ed8761401d5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 21:27:50 +0000 Subject: [PATCH 08/11] Fix incomplete CHANGELOG section header Co-authored-by: 97gamjak <77228802+97gamjak@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f5a99..68ff601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. ### Features -#### +#### CPP Rules - Add CPP rule to check that in a header file there is always a header guard present - Adding option to enforce header guard format via file path From 770dab9fbd21c28afe477ceb13fd2c6ab98ad46c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 21:29:00 +0000 Subject: [PATCH 09/11] Remove unused import in test file Co-authored-by: 97gamjak <77228802+97gamjak@users.noreply.github.com> --- tests/cpp/test_style_rules.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/cpp/test_style_rules.py b/tests/cpp/test_style_rules.py index d304c03..dc87a97 100644 --- a/tests/cpp/test_style_rules.py +++ b/tests/cpp/test_style_rules.py @@ -20,9 +20,6 @@ from devops.files import FileType from devops.rules import FileRuleInput, ResultTypeEnum, Rule, RuleInputType, RuleType -if typing.TYPE_CHECKING: - from pathlib import Path as PathType - class TestCheckKeySeqOrder: """Tests for CheckKeySeqOrder rule.""" From 2e6defa460deb025964a5edfa6d46eb3a1ab5369 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 21:30:46 +0000 Subject: [PATCH 10/11] Improve test code quality with pytest.raises and remove unused imports Co-authored-by: 97gamjak <77228802+97gamjak@users.noreply.github.com> --- tests/cpp/test_style_rules.py | 30 ++++++++---------------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/tests/cpp/test_style_rules.py b/tests/cpp/test_style_rules.py index dc87a97..88d625b 100644 --- a/tests/cpp/test_style_rules.py +++ b/tests/cpp/test_style_rules.py @@ -2,9 +2,10 @@ from __future__ import annotations -import typing from pathlib import Path -from unittest.mock import Mock, patch +from unittest.mock import patch + +import pytest from devops.cpp.style_rules import ( CheckHeaderGuards, @@ -368,11 +369,8 @@ def test_find_header_guard_missing_ifndef(self) -> None: "// code here", "#endif", ] - try: + with pytest.raises(HeaderGuardError, match="Header guard macro not found with #ifndef"): find_header_guard(lines) - assert False, "Should have raised HeaderGuardError" - except HeaderGuardError as e: - assert "Header guard macro not found with #ifndef" in str(e) def test_find_header_guard_missing_define(self) -> None: """Test when #define is missing.""" @@ -382,11 +380,8 @@ def test_find_header_guard_missing_define(self) -> None: "// code here", "#endif", ] - try: + with pytest.raises(HeaderGuardError, match="Header guard macro not defined with #define"): find_header_guard(lines) - assert False, "Should have raised HeaderGuardError" - except HeaderGuardError as e: - assert "Header guard macro not defined with #define" in str(e) def test_find_header_guard_missing_endif(self) -> None: """Test when #endif is missing.""" @@ -396,11 +391,8 @@ def test_find_header_guard_missing_endif(self) -> None: "", "// code here", ] - try: + with pytest.raises(HeaderGuardError, match="Header guard missing closing #endif"): find_header_guard(lines) - assert False, "Should have raised HeaderGuardError" - except HeaderGuardError as e: - assert "Header guard missing closing #endif" in str(e) def test_find_header_guard_malformed_ifndef(self) -> None: """Test with malformed #ifndef (no macro name).""" @@ -409,11 +401,8 @@ def test_find_header_guard_malformed_ifndef(self) -> None: "#define MY_HEADER_HPP", "#endif", ] - try: + with pytest.raises(HeaderGuardError, match="Header guard macro not found with #ifndef"): find_header_guard(lines) - assert False, "Should have raised HeaderGuardError" - except HeaderGuardError as e: - assert "Header guard macro not found with #ifndef" in str(e) def test_find_header_guard_with_whitespace(self) -> None: """Test header guard with leading/trailing whitespace.""" @@ -451,11 +440,8 @@ def test_find_header_guard_beyond_search_limit(self) -> None: lines.append("#define MY_HEADER_HPP") lines.append("#endif") - try: + with pytest.raises(HeaderGuardError, match="Header guard macro not found with #ifndef"): find_header_guard(lines) - assert False, "Should have raised HeaderGuardError" - except HeaderGuardError as e: - assert "Header guard macro not found with #ifndef" in str(e) class TestCheckHeaderGuards: From 4dcaa03460b46e2c1c1c9f0e6d3e927bc466f255 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sat, 20 Dec 2025 22:36:31 +0100 Subject: [PATCH 11/11] fix: ruff formatting issues --- tests/cpp/test_style_rules.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/cpp/test_style_rules.py b/tests/cpp/test_style_rules.py index 88d625b..8e4f8cd 100644 --- a/tests/cpp/test_style_rules.py +++ b/tests/cpp/test_style_rules.py @@ -369,7 +369,9 @@ def test_find_header_guard_missing_ifndef(self) -> None: "// code here", "#endif", ] - with pytest.raises(HeaderGuardError, match="Header guard macro not found with #ifndef"): + with pytest.raises( + HeaderGuardError, match="Header guard macro not found with #ifndef" + ): find_header_guard(lines) def test_find_header_guard_missing_define(self) -> None: @@ -380,7 +382,9 @@ def test_find_header_guard_missing_define(self) -> None: "// code here", "#endif", ] - with pytest.raises(HeaderGuardError, match="Header guard macro not defined with #define"): + with pytest.raises( + HeaderGuardError, match="Header guard macro not defined with #define" + ): find_header_guard(lines) def test_find_header_guard_missing_endif(self) -> None: @@ -391,7 +395,9 @@ def test_find_header_guard_missing_endif(self) -> None: "", "// code here", ] - with pytest.raises(HeaderGuardError, match="Header guard missing closing #endif"): + with pytest.raises( + HeaderGuardError, match="Header guard missing closing #endif" + ): find_header_guard(lines) def test_find_header_guard_malformed_ifndef(self) -> None: @@ -401,7 +407,9 @@ def test_find_header_guard_malformed_ifndef(self) -> None: "#define MY_HEADER_HPP", "#endif", ] - with pytest.raises(HeaderGuardError, match="Header guard macro not found with #ifndef"): + with pytest.raises( + HeaderGuardError, match="Header guard macro not found with #ifndef" + ): find_header_guard(lines) def test_find_header_guard_with_whitespace(self) -> None: @@ -440,7 +448,9 @@ def test_find_header_guard_beyond_search_limit(self) -> None: lines.append("#define MY_HEADER_HPP") lines.append("#endif") - with pytest.raises(HeaderGuardError, match="Header guard macro not found with #ifndef"): + with pytest.raises( + HeaderGuardError, match="Header guard macro not found with #ifndef" + ): find_header_guard(lines)