diff --git a/.github/workflows/sub_lint.yml b/.github/workflows/sub_lint.yml
index ab28e819da..11bb736d37 100644
--- a/.github/workflows/sub_lint.yml
+++ b/.github/workflows/sub_lint.yml
@@ -276,103 +276,32 @@ jobs:
if: inputs.checkLineendings && always()
continue-on-error: ${{ inputs.lineendingsFailSilent }}
run: |
- lineendings=0
- for file in ${{ inputs.changedFiles }}
- do
- # Check for DOS or MAC line endings
- if [[ $(file -b $file) =~ "with CR" ]]
- then
- echo "::warning file=$file,title=$file::File has non Unix line endings"
- echo "$file has non Unix line endings" >> ${{ env.logdir }}lineendings.log
- ((lineendings=lineendings+1))
- fi
- done
- echo "Found $lineendings line ending errors"
- # Write the report
- if [ $lineendings -gt 0 ]
- then
- echo ":information_source: Found $lineendings problems with line endings
" >> ${{env.reportdir}}${{ env.reportfilename }}
- echo "" >> ${{env.reportdir}}${{ env.reportfilename }}
- echo '```' >> ${{env.reportdir}}${{ env.reportfilename }}
- cat ${{ env.logdir }}lineendings.log >> ${{env.reportdir}}${{ env.reportfilename }}
- echo '```' >> ${{env.reportdir}}${{ env.reportfilename }}
- echo " " >> ${{env.reportdir}}${{ env.reportfilename }}
- else
- echo ":heavy_check_mark: No line ending problem found " >> ${{env.reportdir}}${{ env.reportfilename }}
- fi
- echo "" >> ${{env.reportdir}}${{ env.reportfilename }}
- # Exit the step with appropriate code
- [ $lineendings -eq 0 ]
+ python3 tools/lint/generic_checks.py \
+ --lineendings-check \
+ --files "${{ inputs.changedFiles }}" \
+ --report-file "${{ env.reportdir }}${{ env.reportfilename }}" \
+ --log-dir "${{ env.logdir }}"
+
- name: Check for trailing whitespaces
if: inputs.checkWhitespace && always()
continue-on-error: ${{ inputs.whitespaceFailSilent }}
run: |
- whitespaceErrors=0
- exclude="*[.md]"
- for file in ${{ inputs.changedFiles }}
- do
- # Check for trailing whitespaces
- grep -nIHE --exclude="$exclude" " $" $file | sed -e "s/$/<-- trailing whitespace/" >> ${{ env.logdir }}whitespace.log || true
- done
- # Write the Log to the console with the Problem Matchers
- if [ -f ${{ env.logdir }}whitespace.log ]
- then
- echo "::add-matcher::${{ runner.workspace }}/FreeCAD/.github/problemMatcher/grepMatcherWarning.json"
- cat ${{ env.logdir }}whitespace.log
- echo "::remove-matcher owner=grepMatcher-warning::"
- whitespaceErrors=$(wc -l < ${{ env.logdir }}whitespace.log)
- fi
- echo "Found $whitespaceErrors whitespace errors"
- # Write the report
- if [ $whitespaceErrors -gt 0 ]
- then
- echo ":information_source: Found $whitespaceErrors trailing whitespace
" >> ${{env.reportdir}}${{ env.reportfilename }}
- echo "" >> ${{env.reportdir}}${{ env.reportfilename }}
- echo '```' >> ${{env.reportdir}}${{ env.reportfilename }}
- cat ${{ env.logdir }}whitespace.log >> ${{env.reportdir}}${{ env.reportfilename }}
- echo '```' >> ${{env.reportdir}}${{ env.reportfilename }}
- echo " " >> ${{env.reportdir}}${{ env.reportfilename }}
- else
- echo ":heavy_check_mark: No trailing whitespace found " >> ${{env.reportdir}}${{ env.reportfilename }}
- fi
- echo "" >> ${{env.reportdir}}${{ env.reportfilename }}
- # Exit the step with appropriate code
- [ $whitespaceErrors -eq 0 ]
+ python3 tools/lint/generic_checks.py \
+ --whitespace-check \
+ --files "${{ inputs.changedFiles }}" \
+ --report-file "${{ env.reportdir }}${{ env.reportfilename }}" \
+ --log-dir "${{ env.logdir }}"
+
- name: Check for Tab usage
if: inputs.checkTabs && always()
continue-on-error: ${{ inputs.tabsFailSilent }}
run: |
- tabErrors=0
- exclude="*[.md]"
- # Check for Tab usage
- for file in ${{ steps.changed-files.outputs.all_changed_files }}
- do
- grep -nIHE --exclude="$exclude" $'\t' $file | sed -e "s/$/ <-- contains tab/" >> ${{ env.logdir }}tab.log || true
- done
- # Write the Log to the console with the Problem Matchers
- if [ -f ${{ env.logdir }}tab.log ]
- then
- echo "::add-matcher::${{ runner.workspace }}/FreeCAD/.github/problemMatcher/grepMatcherWarning.json"
- cat ${{ env.logdir }}tab.log
- echo "::remove-matcher owner=grepMatcher-warning::"
- tabErrors=$(wc -l < ${{ env.logdir }}tab.log)
- fi
- echo "Found $tabErrors tab errors"
- # Write the report
- if [ $tabErrors -gt 0 ]; then
- echo ":information_source: Found $tabErrors tabs, better to use spaces
" >> ${{env.reportdir}}${{ env.reportfilename }}
- echo "" >> ${{env.reportdir}}${{ env.reportfilename }}
- echo '```' >> ${{env.reportdir}}${{ env.reportfilename }}
- cat ${{ env.logdir }}tab.log >> ${{env.reportdir}}${{ env.reportfilename }}
- echo '```' >> ${{env.reportdir}}${{ env.reportfilename }}
- echo " " >> ${{env.reportdir}}${{ env.reportfilename }}
- else
- echo ":heavy_check_mark: No tabs found" >> ${{env.reportdir}}${{ env.reportfilename }}
- fi
- echo "" >> ${{env.reportdir}}${{ env.reportfilename }}
- # Exit the step with appropriate code
- [ $tabErrors -eq 0 ]
- # Run Python lints
+ python3 tools/lint/generic_checks.py \
+ --tabs-check \
+ --files "${{ inputs.changedFiles }}" \
+ --report-file "${{ env.reportdir }}${{ env.reportfilename }}" \
+ --log-dir "${{ env.logdir }}"
+
- name: Pylint
if: inputs.checkPylint && inputs.changedPythonFiles != '' && always()
continue-on-error: ${{ inputs.pylintFailSilent }}
diff --git a/tools/lint/generic_checks.py b/tools/lint/generic_checks.py
new file mode 100755
index 0000000000..25f1a2d794
--- /dev/null
+++ b/tools/lint/generic_checks.py
@@ -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":information_source: Found {count} issue(s) in {section_title}
\n"
+ )
+ report.append("```")
+ for file, details in issues.items():
+ report.append(f"{file}: {details}")
+ report.append("```\n \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()
diff --git a/tools/lint/utils.py b/tools/lint/utils.py
new file mode 100644
index 0000000000..26cc4186a5
--- /dev/null
+++ b/tools/lint/utils.py
@@ -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)