Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.

## Next Release

### 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

<!-- insertion marker -->
## [0.0.3](https://github.com/repo/owner/releases/tag/0.0.3) - 2025-12-20

Expand Down
19 changes: 19 additions & 0 deletions src/devops/config/config_cpp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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}")
Expand Down
4 changes: 3 additions & 1 deletion src/devops/cpp/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
)
from devops.logger import cpp_check_logger
from devops.rules import (
FileRuleInput,
ResultType,
Rule,
filter_file_rules,
Expand Down Expand Up @@ -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):
Expand All @@ -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

Expand Down
12 changes: 9 additions & 3 deletions src/devops/cpp/license_header.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
155 changes: 153 additions & 2 deletions src/devops/cpp/style_rules.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand All @@ -25,6 +34,148 @@ 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
# 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."
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(line.lstrip().startswith("#endif") 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 __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]
4 changes: 3 additions & 1 deletion src/devops/rules/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -14,6 +15,7 @@

__all__ = ["ResultType", "ResultTypeEnum"]
__all__ += [
"FileRuleInput",
"Rule",
"RuleInputType",
"RuleType",
Expand Down
28 changes: 18 additions & 10 deletions src/devops/rules/rules.py
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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."""

Expand Down Expand Up @@ -127,24 +138,21 @@ 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
-------
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]:
Expand Down
Loading