From c2eb7d6a9e5c246261aea37c7727828e1075c55a Mon Sep 17 00:00:00 2001 From: kilinchange Date: Thu, 24 Sep 2026 11:17:33 +0000 Subject: [PATCH] ci: integrate clang-tidy checks --- .clang-tidy | 17 + .github/workflows/clang-tidy.yaml | 95 +++++ docs/code_quality.md | 229 ++++++++++ scripts/clang_tidy.py | 401 ++++++++++++++++++ tools/clang_tidy/CMakeLists.txt | 35 ++ tools/clang_tidy/InfiniTrainTidyModule.cpp | 23 + .../NoMemberDefinitionsInHeadersCheck.cpp | 87 ++++ .../NoMemberDefinitionsInHeadersCheck.h | 20 + 8 files changed, 907 insertions(+) create mode 100644 .clang-tidy create mode 100644 .github/workflows/clang-tidy.yaml create mode 100644 docs/code_quality.md create mode 100644 scripts/clang_tidy.py create mode 100644 tools/clang_tidy/CMakeLists.txt create mode 100644 tools/clang_tidy/InfiniTrainTidyModule.cpp create mode 100644 tools/clang_tidy/NoMemberDefinitionsInHeadersCheck.cpp create mode 100644 tools/clang_tidy/NoMemberDefinitionsInHeadersCheck.h diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 000000000..06b573a57 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,17 @@ +--- +Checks: > + -*, + infinitrain-no-member-definitions-in-headers, + readability-identifier-naming +WarningsAsErrors: '*' +HeaderFilterRegex: '(^|.*/)(infini_train|example|tests|tools)/' +HeaderFileExtensions: [h, hh, hpp, hxx, cuh] +FormatStyle: none +CheckOptions: + # InfiniTrain custom checks. + infinitrain-no-member-definitions-in-headers.IgnoreEmptyBodies: true + + # Enum constants: kFooBar + readability-identifier-naming.EnumConstantCase: CamelCase + readability-identifier-naming.EnumConstantPrefix: k +... diff --git a/.github/workflows/clang-tidy.yaml b/.github/workflows/clang-tidy.yaml new file mode 100644 index 000000000..ee55ca2a5 --- /dev/null +++ b/.github/workflows/clang-tidy.yaml @@ -0,0 +1,95 @@ +name: clang-tidy + +on: + pull_request: + push: + branches: + - master + +jobs: + clang-tidy: + name: Run clang-tidy check + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect relevant changes + id: changes + env: + BASE_REF: ${{ github.event.pull_request.base.sha || github.event.before }} + run: | + if [[ -z "$BASE_REF" || "$BASE_REF" =~ ^0+$ ]]; then + BASE_REF="$(git rev-parse HEAD^)" + fi + + set +e + python3 scripts/clang_tidy.py \ + --has-relevant-changes \ + --ref "$BASE_REF" + status=$? + set -e + + case "$status" in + 0) echo "run=true" >> "$GITHUB_OUTPUT" ;; + 1) echo "run=false" >> "$GITHUB_OUTPUT" ;; + *) exit "$status" ;; + esac + + - name: Checkout submodules + if: steps.changes.outputs.run == 'true' + run: | + git -c url."https://github.com/".insteadOf="git@github.com:" \ + submodule update --init --recursive + + - name: Install clang-tidy 18 + if: steps.changes.outputs.run == 'true' + run: | + sudo apt-get update + sudo apt-get install -y \ + clang-18 \ + clang-tidy-18 \ + libclang-18-dev \ + llvm-18-dev \ + ninja-build + + - name: Build InfiniTrain clang-tidy plugin + if: steps.changes.outputs.run == 'true' + run: | + cmake -S tools/clang_tidy -B build/clang-tidy-plugin -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_C_COMPILER=clang-18 \ + -DCMAKE_CXX_COMPILER=clang++-18 \ + -DLLVM_DIR=/usr/lib/llvm-18/lib/cmake/llvm \ + -DClang_DIR=/usr/lib/llvm-18/lib/cmake/clang + cmake --build build/clang-tidy-plugin + + - name: Generate compile database + if: steps.changes.outputs.run == 'true' + run: | + cmake -S . -B build/lint -G Ninja \ + -DCMAKE_CXX_COMPILER=clang++-18 \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ + -DCMAKE_CXX_SCAN_FOR_MODULES=OFF \ + -DUSE_CUDA=OFF \ + -DUSE_NCCL=OFF \ + -DUSE_OMP=OFF \ + -DBUILD_TEST=ON + + - name: Check all translation units + if: steps.changes.outputs.run == 'true' + run: | + python3 scripts/clang_tidy.py \ + --all \ + --build-dir build/lint \ + --load-plugin build/clang-tidy-plugin/InfiniTrainTidy.so + + - name: Upload clang-tidy reports + if: always() && steps.changes.outputs.run == 'true' + uses: actions/upload-artifact@v4 + with: + name: clang-tidy-reports + path: build/lint/clang-tidy-report/*.txt + if-no-files-found: warn diff --git a/docs/code_quality.md b/docs/code_quality.md new file mode 100644 index 000000000..869743508 --- /dev/null +++ b/docs/code_quality.md @@ -0,0 +1,229 @@ +# Clang-Tidy + +InfiniTrain 使用 clang-tidy 对 C++ 代码进行静态检查,并通过自定义 clang-tidy 插件实现项目特定的代码规范检查。 + +clang-tidy 的配置位于仓库根目录的 `.clang-tidy` 文件中。项目自定义检查规则位于 `tools/clang_tidy/`,`scripts/clang_tidy.py` 则作为统一入口,用于本地开发和 CI 环境中的 clang-tidy 执行。 + +当前 clang-tidy 及自定义插件统一使用 LLVM/Clang 18。由于 clang-tidy 插件依赖 LLVM/Clang ABI,构建插件时应使用与运行 clang-tidy 相同主版本的 LLVM/Clang。 + +## 检查规则 + +当前仓库启用了以下规则: + +```text +infinitrain-no-member-definitions-in-headers +readability-identifier-naming +``` + +### 头文件成员函数实现 + +该规则用于检查项目头文件中的普通 C++ 成员函数实现。 + +对于不需要在头文件中提供实现的普通成员函数,应仅在头文件中保留声明,并将函数实现放到对应的源文件中,以减少头文件暴露的实现细节,并降低不必要的编译依赖。 + +以下情况不会触发该规则: + +* 函数模板; +* 类模板中的成员函数; +* `constexpr` 或 `consteval` 函数; +* `= default` 函数; +* `= delete` 函数; +* 编译器隐式生成的成员函数; +* Lambda; +* 由宏展开产生的函数定义。 + +通过以下配置可以忽略空函数体: + +```yaml +CheckOptions: + infinitrain-no-member-definitions-in-headers.IgnoreEmptyBodies: true +``` + +系统头文件、第三方代码以及 InfiniTrain 项目目录之外的头文件不会参与该规则检查。 + +### 标识符命名 + +`readability-identifier-naming` 是 clang-tidy 内置规则,本项目首期使用它演示如何优先复用现成规则,避免为已有能力编写 +自定义检查。当前配置为: + +```yaml +CheckOptions: + readability-identifier-naming.FunctionCase: CamelCase + readability-identifier-naming.MethodCase: CamelCase + readability-identifier-naming.VariableCase: lower_case +``` + +自由函数和成员函数使用 `CamelCase`,变量使用 `lower_case`。命名规则只报告问题,不自动修改代码,避免跨声明和调用位置的 +批量重命名影响代码语义或对外接口。 + +## CI 检查 + +clang-tidy CI 会在 Pull Request 以及向 `master` 分支推送代码时运行。 + +在安装 clang-tidy 和构建自定义插件之前,CI 会先判断当前提交是否包含与静态检查相关的修改,包括: + +* C/C++ 源文件或项目头文件; +* CMake 配置文件; +* `.gitmodules` 或 submodule commit 引用; +* `.clang-tidy`; +* `scripts/clang_tidy.py`; +* `tools/clang_tidy/` 下的自定义插件代码; +* clang-tidy CI workflow。 + +如果不存在相关修改,则直接跳过 submodule 拉取、工具安装、插件构建和 clang-tidy 检查。 + +如果修改了 `.gitmodules` 或 submodule commit 引用,也会触发后续检查。拉取 submodule 时,CI 仅对本次命令将 +`git@github.com:` 地址转换为 `https://github.com/`,因此公开 GitHub submodule 不依赖 SSH 密钥;如果引用的 commit +尚未推送、URL 无效或私有仓库缺少访问凭据,CI 仍会失败。 + +如果存在相关修改,CI 会生成 CPU-only 的 CMake compilation database,并对过滤后的所有 C/C++ translation unit 执行 clang-tidy。 + +需要注意: + +**变更检测仅用于决定是否执行 clang-tidy,并不会将检查范围限制在本次修改的代码行。** + +CI 的全量扫描范围包括: + +```text +infini_train/ +example/ +tests/ +tools/ +``` + +以下目录或文件不会参与检查: + +```text +third_party/ +build/ +tests/**/*_compile_fail.cc +``` + +当前 clang-tidy 使用 CPU-only compilation database,不包含 CUDA translation unit,因此纯 `.cu` 修改不会被判定为 +clang-tidy relevant change,也不会执行后续扫描。CUDA clang-tidy 将在后续具备相应 toolchain 和 compilation database 后单独接入。 + +对于被 C++ translation unit 引用的 `.cuh` 等头文件,如果进入 clang-tidy 的 AST 分析范围,仍可能产生相关诊断。 + +项目头文件的 include/exclude 范围由 `.clang-tidy` 中的 `HeaderFilterRegex` 统一维护。自定义 check 只判断定义是否来自 +被 translation unit 包含的头文件以及 AST 语义,不再维护目录或扩展名列表。`scripts/clang_tidy.py` 中的目录集合仅用于筛选 +需要执行的 translation unit。 + +workflow 保持对所有 Pull Request 启动,以便无相关改动时也能返回明确的成功状态,避免 required check 因 workflow 被路径过滤 +而保持 pending。Python 变更检测作为统一过滤入口;只有发现相关改动后才递归拉取 submodule 并执行后续检查。 + +## 检查报告 + +同一个头文件可能被多个 translation unit 引用,因此 clang-tidy 原始输出中,同一个头文件诊断可能重复出现多次。 + +`scripts/clang_tidy.py` 会同时生成完整报告和去重后的报告: + +```text +build/lint/clang-tidy-report/clang-tidy-raw.txt +build/lint/clang-tidy-report/clang-tidy-unique.txt +``` + +其中: + +* `clang-tidy-raw.txt` 保存 clang-tidy 的完整原始输出; +* `clang-tidy-unique.txt` 按诊断位置和规则进行去重,便于查看实际问题。 + +报告去重仅影响输出展示,不会改变 clang-tidy 的退出状态。 + +## 本地运行 + +以下命令可以在 Ubuntu 环境中复现 CI 使用的 clang-tidy 配置。 + +首先安装 LLVM/Clang 18 和 Ninja: + +```bash +sudo apt-get install -y \ + clang-18 \ + clang-tidy-18 \ + libclang-18-dev \ + llvm-18-dev \ + ninja-build +``` + +### 构建自定义 clang-tidy 插件 + +```bash +cmake -S tools/clang_tidy -B build/clang-tidy-plugin -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_C_COMPILER=clang-18 \ + -DCMAKE_CXX_COMPILER=clang++-18 \ + -DLLVM_DIR=/usr/lib/llvm-18/lib/cmake/llvm \ + -DClang_DIR=/usr/lib/llvm-18/lib/cmake/clang + +cmake --build build/clang-tidy-plugin +``` + +构建完成后会生成 clang-tidy 插件: + +```text +build/clang-tidy-plugin/InfiniTrainTidy.so +``` + +### 生成 compilation database + +```bash +cmake -S . -B build/lint -G Ninja \ + -DCMAKE_CXX_COMPILER=clang++-18 \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ + -DCMAKE_CXX_SCAN_FOR_MODULES=OFF \ + -DUSE_CUDA=OFF \ + -DUSE_NCCL=OFF \ + -DUSE_OMP=OFF \ + -DBUILD_TEST=ON +``` + +生成的 compilation database 位于: + +```text +build/lint/compile_commands.json +``` + +### 执行完整检查 + +执行与 CI 相同的全量扫描: + +```bash +python3 scripts/clang_tidy.py \ + --all \ + --build-dir build/lint \ + --load-plugin build/clang-tidy-plugin/InfiniTrainTidy.so +``` + +该模式会对过滤后的所有 C/C++ translation unit 运行 clang-tidy。 + +### 检查当前分支修改 + +本地开发过程中,也可以仅关注当前分支相对于指定 Git revision 修改代码行产生的诊断: + +```bash +python3 scripts/clang_tidy.py \ + --ref origin/master \ + --build-dir build/lint \ + --load-plugin build/clang-tidy-plugin/InfiniTrainTidy.so +``` + +该模式通过 `clang-tidy-diff` 对最终诊断结果进行行级过滤。 + +需要注意,clang-tidy 仍然会解析相关 translation unit 的完整 AST,只是最终仅报告修改行对应的诊断。 + +## 添加自定义检查规则 + +新的 InfiniTrain clang-tidy 检查规则应放置在: + +```text +tools/clang_tidy/ +``` + +并在: + +```text +tools/clang_tidy/InfiniTrainTidyModule.cpp +``` + +中完成注册。 + +新增或修改自定义检查规则时,应先通过测试用例验证规则行为是否符合预期,包括应命中的问题、不应命中的合法代码以及需要豁免的场景。验证通过后,再执行全仓扫描,评估存量代码的命中情况及处理方式,最后在 .clang-tidy 中正式启用该规则。 diff --git a/scripts/clang_tidy.py b/scripts/clang_tidy.py new file mode 100644 index 000000000..f5aec1be5 --- /dev/null +++ b/scripts/clang_tidy.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +"""Run the repository clang-tidy configuration on changed code or all TUs.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SOURCE_SUFFIXES = {".c", ".cc", ".cpp", ".cxx"} +RELEVANT_SUFFIXES = SOURCE_SUFFIXES | { + ".h", + ".hh", + ".hpp", + ".hxx", + ".cuh", + ".cmake", +} +TRANSLATION_UNIT_ROOTS = {"infini_train", "example", "tests", "tools"} +RELEVANT_FILES = { + ".gitmodules", + ".clang-tidy", + ".github/workflows/clang-tidy.yaml", + "scripts/clang_tidy.py", +} +DIAGNOSTIC_RE = re.compile( + r"^(.*):(\d+):(\d+):\s+(warning|error|fatal error):\s+" + r"(.*?)(?:\s+\[([^\]]+)\])?\s*$" +) + + +def capture(command: list[str]) -> str: + process = subprocess.run( + command, cwd=ROOT, capture_output=True, text=True, check=False + ) + if process.returncode != 0: + raise RuntimeError(process.stderr.strip() or "command failed") + return process.stdout + + +def execute(command: list[str], *, input_text: str | None = None) -> int: + print("+", " ".join(str(part) for part in command), flush=True) + return subprocess.run( + command, cwd=ROOT, input=input_text, text=True, check=False + ).returncode + + +def execute_captured(command: list[str]) -> tuple[int, str]: + print("+", " ".join(str(part) for part in command), flush=True) + process = subprocess.run( + command, + cwd=ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + return process.returncode, process.stdout + + +def find_tool(explicit: str | None, candidates: list[str]) -> str | None: + if explicit: + return explicit + return next((path for name in candidates if (path := shutil.which(name))), None) + + +def merge_base(ref: str) -> str: + return capture(["git", "merge-base", ref, "HEAD"]).strip() + + +def diff_text(ref: str | None) -> str: + scope = [ + "--", + ".", + ":(exclude)**/third_party/**", + ":(exclude)third_party/**", + ":(exclude)build/**", + ":(exclude)**/*_compile_fail.cc", + ] + if ref: + return capture( + ["git", "diff", "--unified=0", f"{merge_base(ref)}..HEAD", *scope] + ) + return capture(["git", "diff", "--cached", "--unified=0", *scope]) + + +def changed_paths(ref: str | None) -> list[Path]: + scope = [ + "--", + ".", + ":(exclude)**/third_party/**", + ":(exclude)third_party/**", + ":(exclude)build/**", + ] + command = ["git", "diff", "--name-only", "--diff-filter=ACMR"] + if ref: + command.append(f"{merge_base(ref)}..HEAD") + else: + command.append("--cached") + return [Path(line) for line in capture([*command, *scope]).splitlines()] + + +def changed_gitlinks(ref: str | None) -> list[Path]: + command = ["git", "diff", "--raw", "--no-abbrev", "--diff-filter=ACDMR"] + if ref: + command.append(f"{merge_base(ref)}..HEAD") + else: + command.append("--cached") + command.extend(["--", "."]) + + paths = [] + for line in capture(command).splitlines(): + fields = line.split("\t") + metadata = fields[0].split() + if len(fields) < 2 or len(metadata) < 2: + continue + old_mode = metadata[0].removeprefix(":") + new_mode = metadata[1] + if "160000" in {old_mode, new_mode}: + paths.extend(Path(path) for path in fields[1:]) + return paths + + +def relevant_change(path: Path) -> bool: + normalized = path.as_posix() + return ( + normalized in RELEVANT_FILES + or path.name == "CMakeLists.txt" + or path.suffix.lower() in RELEVANT_SUFFIXES + ) + + +def has_relevant_changes(ref: str | None) -> bool: + relevant = [path for path in changed_paths(ref) if relevant_change(path)] + relevant.extend(changed_gitlinks(ref)) + relevant = list(dict.fromkeys(relevant)) + if not relevant: + print("No clang-tidy-relevant changes found.") + return False + print("clang-tidy-relevant changes:") + for path in relevant: + print(f" {path.as_posix()}") + return True + + +def translation_unit_entry(entry: dict[str, object]) -> bool: + source = Path(str(entry["file"])) + if not source.is_absolute(): + source = Path(str(entry.get("directory", ROOT))) / source + source = source.resolve() + try: + relative = source.relative_to(ROOT) + except ValueError: + return False + return ( + bool(relative.parts) + and relative.parts[0] in TRANSLATION_UNIT_ROOTS + and "third_party" not in relative.parts + and "build" not in relative.parts + and source.suffix.lower() in SOURCE_SUFFIXES + and not source.name.endswith("_compile_fail.cc") + ) + + +def filtered_database(build_dir: Path) -> Path: + source_path = build_dir / "compile_commands.json" + database = json.loads(source_path.read_text(encoding="utf-8")) + filtered = [entry for entry in database if translation_unit_entry(entry)] + output_dir = build_dir / "clang-tidy-full" + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / "compile_commands.json").write_text( + json.dumps(filtered, indent=2) + "\n", encoding="utf-8" + ) + print( + f"Full scan database: {len(filtered)} compile commands in {output_dir}", + flush=True, + ) + return output_dir + + +def plugin_arguments(plugin: Path) -> list[str]: + return ["-load", str(plugin)] + + +def display_path(path_text: str) -> str: + path = Path(path_text) + try: + if path.is_absolute(): + path = path.resolve().relative_to(ROOT) + except (OSError, ValueError): + pass + return path.as_posix() + + +def unique_diagnostics(output: str) -> tuple[list[str], int]: + unique: dict[tuple[str, str, str, str, str, str], int] = {} + for line in output.splitlines(): + match = DIAGNOSTIC_RE.match(line) + if match is None: + continue + path, line_number, column, severity, message, check_name = match.groups() + key = ( + display_path(path), + line_number, + column, + severity, + message, + check_name or "", + ) + unique[key] = unique.get(key, 0) + 1 + + lines = [] + ordered = sorted( + unique.items(), + key=lambda item: ( + item[0][0], + int(item[0][1]), + int(item[0][2]), + item[0][3:], + ), + ) + for key, count in ordered: + path, line_number, column, severity, message, check_name = key + suffix = f" [{check_name}]" if check_name else "" + repeated = f" (reported {count} times)" if count > 1 else "" + lines.append( + f"{path}:{line_number}:{column}: {severity}: {message}{suffix}{repeated}" + ) + return lines, sum(unique.values()) + + +def write_full_scan_reports(output: str, report_dir: Path) -> tuple[Path, Path]: + report_dir.mkdir(parents=True, exist_ok=True) + raw_path = report_dir / "clang-tidy-raw.txt" + unique_path = report_dir / "clang-tidy-unique.txt" + raw_path.write_text(output, encoding="utf-8") + + diagnostics, total = unique_diagnostics(output) + duplicate_count = total - len(diagnostics) + summary = ( + f"Unique diagnostics: {len(diagnostics)}; raw diagnostics: {total}; " + f"duplicates removed: {duplicate_count}." + ) + unique_path.write_text( + "\n".join([summary, *diagnostics, ""]), encoding="utf-8" + ) + print(summary) + for diagnostic in diagnostics: + print(diagnostic) + print(f"Raw log: {raw_path}") + print(f"Deduplicated report: {unique_path}") + return raw_path, unique_path + + +def run_changed(args: argparse.Namespace, clang_tidy: str) -> int: + tidy_diff = find_tool( + args.clang_tidy_diff, + [ + "clang-tidy-diff-18.py", + "clang-tidy-diff.py", + "clang-tidy-diff-18", + "clang-tidy-diff", + ], + ) + if tidy_diff is None: + print("error: clang-tidy-diff is required", file=sys.stderr) + return 2 + command = [] + if Path(tidy_diff).suffix.lower() == ".py": + command = [sys.executable, "-W", "ignore::SyntaxWarning"] + command += [ + tidy_diff, + "-p1", + "-path", + str(args.build_dir), + "-clang-tidy-binary", + clang_tidy, + "-config-file", + str(ROOT / ".clang-tidy"), + "-j", + str(args.jobs), + "-quiet", + ] + command += plugin_arguments(args.load_plugin) + return execute(command, input_text=diff_text(args.ref)) + + +def run_all(args: argparse.Namespace) -> int: + runner = find_tool( + args.run_clang_tidy, ["run-clang-tidy-18", "run-clang-tidy.py", "run-clang-tidy"] + ) + if runner is None: + print("error: run-clang-tidy is required for --all", file=sys.stderr) + return 2 + command = [] + if Path(runner).suffix.lower() == ".py": + command = [sys.executable, "-W", "ignore::SyntaxWarning"] + command += [ + runner, + "-p", + str(filtered_database(args.build_dir)), + "-config-file", + str(ROOT / ".clang-tidy"), + "-j", + str(args.jobs), + "-quiet", + ] + command += plugin_arguments(args.load_plugin) + return_code, output = execute_captured(command) + diagnostics, _ = unique_diagnostics(output) + write_full_scan_reports(output, args.report_dir) + if return_code != 0 and not diagnostics: + print(output, file=sys.stderr, end="" if output.endswith("\n") else "\n") + return return_code + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--all", + action="store_true", + help="scan every project TU in the filtered compile database", + ) + parser.add_argument( + "--has-relevant-changes", + action="store_true", + help="report whether the diff contains changes that require clang-tidy", + ) + parser.add_argument( + "--ref", + help="comparison ref for changed-line mode or --has-relevant-changes", + ) + parser.add_argument("--build-dir", type=Path, default=Path("build/lint")) + parser.add_argument( + "--report-dir", + type=Path, + help="full-scan report directory (default: BUILD_DIR/clang-tidy-report)", + ) + parser.add_argument( + "--jobs", type=int, default=min(4, max(1, os.cpu_count() or 1)) + ) + parser.add_argument("--clang-tidy") + parser.add_argument("--clang-tidy-diff") + parser.add_argument("--run-clang-tidy") + parser.add_argument( + "--load-plugin", + type=Path, + help="path to the InfiniTrain clang-tidy plugin", + ) + args = parser.parse_args() + + if args.all and args.ref: + parser.error("--ref cannot be used with --all; --all scans every project TU") + + try: + if args.has_relevant_changes: + return 0 if has_relevant_changes(args.ref) else 1 + except (OSError, RuntimeError, ValueError) as error: + print(f"error: {error}", file=sys.stderr) + return 2 + + if args.load_plugin is None: + print("error: --load-plugin is required when running clang-tidy", file=sys.stderr) + return 2 + + args.build_dir = args.build_dir.resolve() + args.load_plugin = args.load_plugin.resolve() + args.report_dir = ( + args.report_dir.resolve() + if args.report_dir is not None + else args.build_dir / "clang-tidy-report" + ) + + if not (args.build_dir / "compile_commands.json").exists(): + print( + f"error: {args.build_dir / 'compile_commands.json'} does not exist", + file=sys.stderr, + ) + return 2 + if not args.load_plugin.is_file(): + print(f"error: {args.load_plugin} does not exist", file=sys.stderr) + return 2 + clang_tidy = find_tool(args.clang_tidy, ["clang-tidy-18", "clang-tidy"]) + if clang_tidy is None: + print("error: clang-tidy is required", file=sys.stderr) + return 2 + try: + return run_all(args) if args.all else run_changed(args, clang_tidy) + except (OSError, RuntimeError, ValueError) as error: + print(f"error: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/clang_tidy/CMakeLists.txt b/tools/clang_tidy/CMakeLists.txt new file mode 100644 index 000000000..3508b076b --- /dev/null +++ b/tools/clang_tidy/CMakeLists.txt @@ -0,0 +1,35 @@ +cmake_minimum_required(VERSION 3.20) + +project(InfiniTrainTidy LANGUAGES C CXX) + +find_package(LLVM REQUIRED CONFIG) +find_package(Clang REQUIRED CONFIG) + +if(NOT LLVM_PACKAGE_VERSION VERSION_GREATER_EQUAL 18 OR + LLVM_PACKAGE_VERSION VERSION_GREATER_EQUAL 19) + message(FATAL_ERROR "InfiniTrainTidy requires LLVM/Clang 18") +endif() + +separate_arguments(LLVM_DEFINITIONS_LIST NATIVE_COMMAND "${LLVM_DEFINITIONS}") + +add_library(InfiniTrainTidy MODULE + InfiniTrainTidyModule.cpp + NoMemberDefinitionsInHeadersCheck.cpp +) + +target_compile_features(InfiniTrainTidy PRIVATE cxx_std_17) +target_include_directories(InfiniTrainTidy SYSTEM PRIVATE + ${LLVM_INCLUDE_DIRS} + ${CLANG_INCLUDE_DIRS} +) +target_compile_options(InfiniTrainTidy PRIVATE ${LLVM_DEFINITIONS_LIST}) +target_link_libraries(InfiniTrainTidy PRIVATE + clangAST + clangASTMatchers + clangBasic + clangTidy +) + +set_target_properties(InfiniTrainTidy PROPERTIES + PREFIX "" +) diff --git a/tools/clang_tidy/InfiniTrainTidyModule.cpp b/tools/clang_tidy/InfiniTrainTidyModule.cpp new file mode 100644 index 000000000..9ec04e4d2 --- /dev/null +++ b/tools/clang_tidy/InfiniTrainTidyModule.cpp @@ -0,0 +1,23 @@ +#include "NoMemberDefinitionsInHeadersCheck.h" +#include "clang-tidy/ClangTidyModule.h" +#include "clang-tidy/ClangTidyModuleRegistry.h" + +namespace clang::tidy::infinitrain { +namespace { + +class InfiniTrainTidyModule : public ClangTidyModule { + public: + void addCheckFactories(ClangTidyCheckFactories &factories) override { + factories.registerCheck( + "infinitrain-no-member-definitions-in-headers"); + } +}; + +static ClangTidyModuleRegistry::Add module( + "infinitrain-module", "Adds InfiniTrain project checks."); + +} // namespace + +volatile int InfiniTrainTidyModuleAnchorSource = 0; + +} // namespace clang::tidy::infinitrain diff --git a/tools/clang_tidy/NoMemberDefinitionsInHeadersCheck.cpp b/tools/clang_tidy/NoMemberDefinitionsInHeadersCheck.cpp new file mode 100644 index 000000000..447470ca6 --- /dev/null +++ b/tools/clang_tidy/NoMemberDefinitionsInHeadersCheck.cpp @@ -0,0 +1,87 @@ +#include "NoMemberDefinitionsInHeadersCheck.h" + +#include "clang/AST/DeclCXX.h" +#include "clang/AST/Stmt.h" +#include "clang/ASTMatchers/ASTMatchFinder.h" +#include "llvm/ADT/StringRef.h" +#include "llvm/Support/Casting.h" + +namespace clang::tidy::infinitrain { +namespace { + +bool isTemplated(const CXXMethodDecl &method) { + if (method.getTemplatedKind() != FunctionDecl::TK_NonTemplate) { + return true; + } + + for (const DeclContext *context = method.getDeclContext(); context != nullptr; + context = context->getParent()) { + const auto *record = dyn_cast(context); + if (record != nullptr && (record->getDescribedClassTemplate() != nullptr || + isa(record))) { + return true; + } + } + return false; +} + +} // namespace + +NoMemberDefinitionsInHeadersCheck::NoMemberDefinitionsInHeadersCheck( + StringRef name, ClangTidyContext *context) + : ClangTidyCheck(name, context), + ignore_empty_bodies_(Options.get("IgnoreEmptyBodies", true)) {} + +bool NoMemberDefinitionsInHeadersCheck::isLanguageVersionSupported( + const LangOptions &lang_opts) const { + return lang_opts.CPlusPlus; +} + +void NoMemberDefinitionsInHeadersCheck::storeOptions( + ClangTidyOptions::OptionMap &options) { + Options.store(options, "IgnoreEmptyBodies", ignore_empty_bodies_); +} + +void NoMemberDefinitionsInHeadersCheck::registerMatchers( + ast_matchers::MatchFinder *finder) { + using namespace ast_matchers; + finder->addMatcher( + cxxMethodDecl(isDefinition(), unless(isImplicit())).bind("method"), this); +} + +void NoMemberDefinitionsInHeadersCheck::check( + const ast_matchers::MatchFinder::MatchResult &result) { + const auto *method = result.Nodes.getNodeAs("method"); + if (method == nullptr || method->isDefaulted() || method->isDeleted() || + method->isConstexpr() || method->getParent()->isLambda() || + isTemplated(*method)) { + return; + } + + const auto *body = dyn_cast_or_null(method->getBody()); + if (ignore_empty_bodies_ && body != nullptr && body->body_empty()) { + return; + } + + SourceLocation location = method->getLocation(); + if (location.isInvalid() || location.isMacroID()) { + return; + } + if (const Stmt *body = method->getBody(); + body != nullptr && body->getBeginLoc().isMacroID()) { + return; + } + + const SourceManager &source_manager = *result.SourceManager; + location = source_manager.getSpellingLoc(location); + if (source_manager.isInSystemHeader(location) || + source_manager.isWrittenInMainFile(location)) { + return; + } + + diag(location, + "member function definition must be moved from the header to an " + "implementation file"); +} + +} // namespace clang::tidy::infinitrain diff --git a/tools/clang_tidy/NoMemberDefinitionsInHeadersCheck.h b/tools/clang_tidy/NoMemberDefinitionsInHeadersCheck.h new file mode 100644 index 000000000..1285c705f --- /dev/null +++ b/tools/clang_tidy/NoMemberDefinitionsInHeadersCheck.h @@ -0,0 +1,20 @@ +#pragma once + +#include "clang-tidy/ClangTidyCheck.h" + +namespace clang::tidy::infinitrain { + +class NoMemberDefinitionsInHeadersCheck : public ClangTidyCheck { + public: + NoMemberDefinitionsInHeadersCheck(StringRef name, ClangTidyContext *context); + + void registerMatchers(ast_matchers::MatchFinder *finder) override; + void check(const ast_matchers::MatchFinder::MatchResult &result) override; + bool isLanguageVersionSupported(const LangOptions &lang_opts) const override; + void storeOptions(ClangTidyOptions::OptionMap &options) override; + + private: + const bool ignore_empty_bodies_; +}; + +} // namespace clang::tidy::infinitrain