diff --git a/scripts/validate-csharp-naming.sh b/scripts/validate-csharp-naming.sh index 726395b..1a38f04 100644 --- a/scripts/validate-csharp-naming.sh +++ b/scripts/validate-csharp-naming.sh @@ -6,137 +6,143 @@ if ! command -v git >/dev/null 2>&1; then exit 2 fi -if ! command -v python3 >/dev/null 2>&1; then - echo "python3 is required to validate C# naming conventions." >&2 +if ! command -v grep >/dev/null 2>&1; then + echo "grep is required to validate C# naming conventions." >&2 exit 2 fi repo_root="$(git rev-parse --show-toplevel)" cd "$repo_root" -python3 - <<'PY' -from __future__ import annotations +readonly PASCAL_CASE_REGEX='^(?:[A-Z](?=[A-Z][a-z0-9])|[A-Z]{2}(?=$|[A-Z][a-z0-9])|[A-Z][a-z0-9]+)+$' -import re -import subprocess -import sys -from pathlib import Path +files_checked=0 +declare -a namespace_violations=() +declare -a directory_violations=() +declare -A seen_directories=() +declare -A seen_directory_violations=() -ROOT = Path.cwd() -EXCLUDED_PREFIXES = ( - "Godot/script_templates/", -) +is_excluded() { + local path="$1" + case "$path" in + Godot/script_templates|Godot/script_templates/*) + return 0 + ;; + *) + return 1 + ;; + esac +} -NAMESPACE_PATTERN = re.compile(r"^\s*namespace\s+([A-Za-z][A-Za-z0-9_.]*)\s*(?:[;{]|$)") -SEGMENT_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9]*$") -PASCAL_CASE_PATTERN = re.compile( - r"^(?:[A-Z](?=[A-Z][a-z0-9])|[A-Z]{2}(?=$|[A-Z][a-z0-9])|[A-Z][a-z0-9]+)+$" -) +validate_segment() { + local segment="$1" + if [[ ! "$segment" =~ ^[A-Za-z][A-Za-z0-9]*$ ]]; then + printf '%s' "must start with a letter and contain only letters or digits" + return 1 + fi -def is_excluded(path: str) -> bool: - normalized = path.strip("./") - return any( - normalized == prefix.rstrip("/") or normalized.startswith(prefix) - for prefix in EXCLUDED_PREFIXES + if [[ ! "$segment" =~ ^[A-Z] ]]; then + printf '%s' "must start with an uppercase letter" + return 1 + fi + + if [[ "$segment" =~ ^[A-Z]+$ ]]; then + if (( ${#segment} <= 2 )); then + return 0 + fi + + printf '%s' "acronyms longer than 2 letters must use PascalCase" + return 1 + fi + + if ! printf '%s\n' "$segment" | grep -Pq "$PASCAL_CASE_REGEX"; then + printf '%s' "must use PascalCase; only 2-letter acronyms may stay fully uppercase" + return 1 + fi + + return 0 +} + +check_directory_path() { + local relative_dir="$1" + local raw_segment="" + local segment="" + local reason="" + local key="" + + IFS='/' read -r -a raw_segments <<< "$relative_dir" + for raw_segment in "${raw_segments[@]}"; do + IFS='.' read -r -a segments <<< "$raw_segment" + for segment in "${segments[@]}"; do + if ! reason="$(validate_segment "$segment")"; then + key="$relative_dir|$segment|$reason" + if [[ -z "${seen_directory_violations[$key]:-}" ]]; then + seen_directory_violations["$key"]=1 + directory_violations+=("- $relative_dir -> \"$segment\": $reason") + fi + + return + fi + done + done +} + +while IFS= read -r relative_file; do + if [[ -z "$relative_file" ]] || is_excluded "$relative_file"; then + continue + fi + + ((files_checked += 1)) + + while IFS=: read -r line_number namespace; do + [[ -z "$line_number" ]] && continue + + IFS='.' read -r -a segments <<< "$namespace" + errors=() + for segment in "${segments[@]}"; do + if ! reason="$(validate_segment "$segment")"; then + errors+=(" * $segment: $reason") + fi + done + + if (( ${#errors[@]} > 0 )); then + namespace_violations+=("- $relative_file:$line_number -> $namespace") + namespace_violations+=("${errors[@]}") + fi + done < <( + sed '1s/^\xEF\xBB\xBF//' "$relative_file" | + grep -nE '^[[:space:]]*namespace[[:space:]]+[A-Za-z][A-Za-z0-9_.]*[[:space:]]*([;{]|$)' | + sed -E 's/^([0-9]+):[[:space:]]*namespace[[:space:]]+([^[:space:];{]+).*/\1:\2/' ) + current_dir="$(dirname "$relative_file")" + while [[ "$current_dir" != "." ]]; do + if [[ -z "${seen_directories[$current_dir]:-}" ]]; then + seen_directories["$current_dir"]=1 + check_directory_path "$current_dir" + fi -def validate_segment(segment: str) -> str | None: - if not SEGMENT_PATTERN.fullmatch(segment): - return "must start with a letter and contain only letters or digits" + current_dir="$(dirname "$current_dir")" + done +done < <(git ls-files -- '*.cs') - if not segment[0].isupper(): - return "must start with an uppercase letter" +if (( ${#namespace_violations[@]} > 0 || ${#directory_violations[@]} > 0 )); then + echo "C# naming validation failed." - if segment.isupper(): - if len(segment) <= 2: - return None - return "acronyms longer than 2 letters must use PascalCase" + if (( ${#namespace_violations[@]} > 0 )); then + echo + echo "Namespace violations:" + printf '%s\n' "${namespace_violations[@]}" + fi - if not PASCAL_CASE_PATTERN.fullmatch(segment): - return "must use PascalCase; only 2-letter acronyms may stay fully uppercase" + if (( ${#directory_violations[@]} > 0 )); then + echo + echo "Directory violations:" + printf '%s\n' "${directory_violations[@]}" + fi - return None + exit 1 +fi - -def tracked_csharp_files() -> list[str]: - result = subprocess.run( - ["git", "ls-files", "--", "*.cs"], - check=True, - capture_output=True, - text=True, - ) - return [ - line - for line in result.stdout.splitlines() - if line and not is_excluded(line) - ] - - -def read_text(path: Path) -> str: - try: - return path.read_text(encoding="utf-8-sig") - except UnicodeDecodeError: - return path.read_text(encoding="utf-8") - - -files = tracked_csharp_files() -namespace_violations: list[tuple[str, int, str, list[str]]] = [] -directory_violations: list[tuple[str, str, str]] = [] -seen_directories: set[str] = set() - -for relative_file in files: - file_path = ROOT / relative_file - for line_number, line in enumerate(read_text(file_path).splitlines(), start=1): - match = NAMESPACE_PATTERN.match(line) - if not match: - continue - - namespace = match.group(1) - segment_errors = [] - for segment in namespace.split("."): - reason = validate_segment(segment) - if reason is not None: - segment_errors.append(f'{segment}: {reason}') - - if segment_errors: - namespace_violations.append((relative_file, line_number, namespace, segment_errors)) - - parent = Path(relative_file).parent - while parent != Path("."): - relative_dir = parent.as_posix() - if relative_dir not in seen_directories: - seen_directories.add(relative_dir) - for raw_segment in relative_dir.split("/"): - segments = raw_segment.split(".") - for segment in segments: - reason = validate_segment(segment) - if reason is not None: - directory_violations.append((relative_dir, segment, reason)) - break - else: - continue - break - - parent = parent.parent - -if namespace_violations or directory_violations: - print("C# naming validation failed.") - - if namespace_violations: - print("\nNamespace violations:") - for file_path, line_number, namespace, errors in namespace_violations: - print(f"- {file_path}:{line_number} -> {namespace}") - for error in errors: - print(f" * {error}") - - if directory_violations: - print("\nDirectory violations:") - for directory_path, segment, reason in sorted(set(directory_violations)): - print(f'- {directory_path} -> "{segment}": {reason}') - - sys.exit(1) - -print(f"C# naming validation passed for {len(files)} tracked C# files.") -PY +echo "C# naming validation passed for $files_checked tracked C# files."