CI: Refactor generic whitespace checks linting setup.

This commit is contained in:
Joao Matos
2025-02-16 14:15:00 +00:00
parent 9356419f6c
commit 3d2fc3c554
3 changed files with 278 additions and 89 deletions

165
tools/lint/generic_checks.py Executable file
View File

@@ -0,0 +1,165 @@
import argparse
import glob
import os
from utils import (
add_common_arguments,
init_environment,
write_file,
emit_problem_matchers,
)
def check_line_endings(file_paths):
"""Check if files have non-Unix (CRLF or CR) line endings.
Returns a dict mapping file path to an issue description.
"""
issues = {}
for path in file_paths:
try:
with open(path, "rb") as f:
content = f.read()
if b"\r\n" in content or (b"\r" in content and b"\n" not in content):
issues[path] = "File has non-Unix line endings."
except Exception as e:
issues[path] = f"Error reading file: {e}"
return issues
def check_trailing_whitespace(file_paths):
"""Check for trailing whitespace in files.
Returns a dict mapping file paths to a list of line numbers with trailing whitespace.
"""
issues = {}
for path in file_paths:
try:
with open(path, "r", encoding="utf-8") as f:
lines = f.readlines()
error_lines = []
for idx, line in enumerate(lines, start=1):
if line.rstrip("\n") != line.rstrip():
error_lines.append(idx)
if error_lines:
issues[path] = error_lines
except Exception as e:
issues[path] = f"Error reading file: {e}"
return issues
def check_tabs(file_paths):
"""Check for usage of tab characters in files.
Returns a dict mapping file paths to a list of line numbers containing tabs.
"""
issues = {}
for path in file_paths:
try:
with open(path, "r", encoding="utf-8") as f:
lines = f.readlines()
tab_lines = []
for idx, line in enumerate(lines, start=1):
if "\t" in line:
tab_lines.append(idx)
if tab_lines:
issues[path] = tab_lines
except Exception as e:
issues[path] = f"Error reading file: {e}"
return issues
def format_report(section_title, issues):
"""Format a report section with a Markdown details block."""
report = []
if issues:
count = len(issues)
report.append(
f"<details><summary>:information_source: Found {count} issue(s) in {section_title}</summary>\n"
)
report.append("```")
for file, details in issues.items():
report.append(f"{file}: {details}")
report.append("```\n</details>\n")
else:
report.append(f":heavy_check_mark: No issues found in {section_title}\n")
return "\n".join(report)
def main():
parser = argparse.ArgumentParser(description="Run formatting lint checks.")
add_common_arguments(parser)
parser.add_argument(
"--lineendings-check",
action="store_true",
help="Enable check for non-Unix line endings.",
)
parser.add_argument(
"--whitespace-check",
action="store_true",
help="Enable check for trailing whitespace.",
)
parser.add_argument(
"--tabs-check", action="store_true", help="Enable check for tab characters."
)
args = parser.parse_args()
init_environment(args)
file_list = glob.glob(args.files, recursive=True)
file_list = [f for f in file_list if os.path.isfile(f)]
report_sections = []
# Check non-Unix line endings.
if args.lineendings_check:
le_issues = check_line_endings(file_list)
for file, detail in le_issues.items():
print(f"::warning file={file},title={file}::{detail}")
report_sections.append(format_report("Non-Unix Line Endings", le_issues))
# Check trailing whitespace.
if args.whitespace_check:
ws_issues = check_trailing_whitespace(file_list)
if ws_issues:
ws_output_lines = []
for file, details in ws_issues.items():
if isinstance(details, list):
for line in details:
ws_output_lines.append(f"{file}:{line}: trailing whitespace")
else:
ws_output_lines.append(f"{file}: {details}")
ws_output = "\n".join(ws_output_lines)
ws_log_file = os.path.join(args.log_dir, "whitespace.log")
write_file(ws_log_file, ws_output)
emit_problem_matchers(
ws_log_file, "grepMatcherWarning.json", "grepMatcher-warning"
)
report_sections.append(format_report("Trailing Whitespace", ws_issues))
# Check tab usage.
if args.tabs_check:
tab_issues = check_tabs(file_list)
if tab_issues:
tab_output_lines = []
for file, details in tab_issues.items():
if isinstance(details, list):
for line in details:
tab_output_lines.append(f"{file}:{line}: contains tab")
else:
tab_output_lines.append(f"{file}: {details}")
tab_output = "\n".join(tab_output_lines)
tab_log_file = os.path.join(args.log_dir, "tabs.log")
write_file(tab_log_file, tab_output)
emit_problem_matchers(
tab_log_file, "grepMatcherWarning.json", "grepMatcher-warning"
)
report_sections.append(format_report("Tab Usage", tab_issues))
report_content = "\n".join(report_sections)
write_file(args.report_file, report_content)
print("Lint report generated at:", args.report_file)
if __name__ == "__main__":
main()

95
tools/lint/utils.py Normal file
View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python3
import os
import sys
import logging
import subprocess
import argparse
from typing import Tuple
def run_command(cmd, check=False) -> Tuple[str, str, int]:
"""
Run a command using subprocess.run and return stdout, stderr, and exit code.
"""
try:
result = subprocess.run(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=check, text=True
)
return result.stdout, result.stderr, result.returncode
except subprocess.CalledProcessError as e:
return e.stdout, e.stderr, e.returncode
def setup_logger(verbose: bool):
"""
Setup the logging level based on the verbose flag.
"""
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(level=level, format="%(levelname)s: %(message)s")
def write_file(file_path: str, content: str):
"""Write content to the specified file."""
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
logging.info("Wrote file: %s", file_path)
def append_file(file_path: str, content: str):
"""Append content to the specified file."""
with open(file_path, "a", encoding="utf-8") as f:
f.write(content + "\n")
logging.info("Appended content to file: %s", file_path)
def emit_problem_matchers(log_path: str, matcher_filename: str, remove_owner: str):
"""
Emit GitHub Actions problem matcher commands using the given matcher file.
This function will:
1. Check if the log file exists.
2. Use the RUNNER_WORKSPACE environment variable to construct the matcher path.
3. Print the add-matcher command, then the log content, then the remove-matcher command.
"""
if os.path.isfile(log_path):
runner_workspace = os.getenv("RUNNER_WORKSPACE")
matcher_path = os.path.join(
runner_workspace, "FreeCAD", ".github", "problemMatcher", matcher_filename
)
print(f"::add-matcher::{matcher_path}")
with open(log_path, "r", encoding="utf-8") as f:
sys.stdout.write(f.read())
print(f"::remove-matcher owner={remove_owner}::")
def add_common_arguments(parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
"""
Add common command-line arguments shared between tools.
"""
parser.add_argument(
"--files",
required=True,
help="A space-separated list or glob pattern of files to check.",
)
parser.add_argument(
"--log-dir", required=True, help="Directory where log files will be written."
)
parser.add_argument(
"--report-file",
required=True,
help="Path to the Markdown report file to append results.",
)
parser.add_argument("--verbose", action="store_true", help="Enable verbose output.")
return parser
def init_environment(args: argparse.Namespace):
"""
Perform common initialization tasks:
- Set up logging.
- Create the log directory.
- Create the directory for the report file.
"""
setup_logger(args.verbose)
os.makedirs(args.log_dir, exist_ok=True)
os.makedirs(os.path.dirname(args.report_file), exist_ok=True)