1
0
Fork 0
x64dbg/.github/format/AStyleHelper.py

430 lines
13 KiB
Python

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "astyle==3.6.9",
# ]
# ///
from __future__ import annotations
import importlib.metadata
import re
import shutil
import subprocess
import sys
import sysconfig
from pathlib import Path
PATTERNS = ("*.c", "*.h", "*.cpp", "*.hpp")
OPTIONS = (
"style=allman, convert-tabs, align-pointer=type, align-reference=middle, "
"indent=spaces, indent-namespaces, indent-col1-comments, "
"unpad-paren, keep-one-line-blocks, close-templates"
)
IGNORE_PATTERNS = (
r"src/cross/vendor",
r"src/gui/Src/ThirdPartyLibs/md4c",
)
LICENSE_TEXT = ""
EXIT_OK = 0
EXIT_CHANGES = 1
EXIT_ERROR = 2
def stderr(message: str) -> None:
print(message, file=sys.stderr)
def usage() -> int:
stderr(
"Usage: AStyleHelper.py [Silent|Check] [filter_epoch]\n"
"\n"
"Formats the git repository containing the current working directory.\n"
"If the current working directory is not inside a git repository, it formats that directory.\n"
"\n"
"Modes:\n"
" <no mode> Format files and print each file as it is processed.\n"
" Silent Format files without progress output.\n"
" Check Check formatting without modifying files.\n"
"\n"
"Examples:\n"
" uv run --script .github/format/AStyleHelper.py\n"
" uv run --script .github/format/AStyleHelper.py Silent\n"
" uv run --script .github/format/AStyleHelper.py Check\n"
)
return EXIT_ERROR
def parse_filter_epoch(args: list[str]) -> tuple[int, int]:
if not args:
return 0, EXIT_OK
try:
return int(args[0]), EXIT_OK
except ValueError:
stderr(f"Invalid epoch time provided: {args[0]}")
return 0, EXIT_ERROR
def option_flags() -> list[str]:
return [f"--{option.strip()}" for option in OPTIONS.split(",") if option.strip()]
def find_astyle_executable() -> Path:
candidate_names = ("astyle.exe", "AStyle.exe", "astyle", "AStyle")
try:
dist = importlib.metadata.distribution("astyle")
preferred = []
fallback = []
for file in dist.files or []:
path = Path(file)
if path.name not in candidate_names:
continue
located = Path(dist.locate_file(file))
if not located.is_file():
continue
if "data" in path.parts:
preferred.append(located)
else:
fallback.append(located)
if preferred:
return preferred[0]
if fallback:
return fallback[0]
except importlib.metadata.PackageNotFoundError:
pass
candidate_dirs = []
scripts_dir = sysconfig.get_path("scripts")
if scripts_dir:
candidate_dirs.append(Path(scripts_dir))
candidate_dirs.append(Path(sys.executable).resolve().parent)
for directory in candidate_dirs:
for name in candidate_names:
candidate = directory / name
if candidate.is_file():
return candidate
for name in candidate_names:
resolved = shutil.which(name)
if resolved:
return Path(resolved)
raise FileNotFoundError(
"Could not locate the 'astyle' executable from the astyle wheel."
)
def get_git_root(start_dir: Path) -> Path:
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
cwd=start_dir,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
if result.returncode == 0:
git_root = result.stdout.strip()
if git_root:
return Path(git_root).resolve()
except OSError:
pass
return start_dir.resolve()
def filter_git_ignored(root: Path, files: list[Path]) -> list[Path]:
if not files:
return files
try:
relative_paths = [file.as_posix() for file in files]
result = subprocess.run(
["git", "check-ignore", "--no-index", "--stdin"],
cwd=root,
input="\n".join(relative_paths) + "\n",
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
if result.returncode not in (0, 1):
return files
ignored = {
line.strip().replace("\\", "/")
for line in result.stdout.splitlines()
if line.strip()
}
return [file for file in files if file.as_posix() not in ignored]
except OSError:
return files
def git_ls_files(root: Path) -> list[Path]:
try:
result = subprocess.run(
[
"git",
"ls-files",
"--cached",
"--others",
"--exclude-standard",
"--",
*PATTERNS,
],
cwd=root,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
if result.returncode == 0:
seen: set[str] = set()
files: list[Path] = []
for line in result.stdout.splitlines():
line = line.strip()
if not line or line in seen:
continue
seen.add(line)
files.append(Path(line))
return filter_git_ignored(root, files)
except OSError:
pass
seen: set[str] = set()
files: list[Path] = []
for pattern in PATTERNS:
for path in root.rglob(pattern):
if not path.is_file():
continue
relative = path.relative_to(root)
key = relative.as_posix()
if key in seen:
continue
seen.add(key)
files.append(relative)
files.sort(key=lambda path: path.as_posix())
return files
def should_ignore(path: Path) -> bool:
normalized = path.as_posix()
return any(re.search(pattern, normalized) for pattern in IGNORE_PATTERNS)
def should_process(root: Path, relative_path: Path, filter_epoch: int) -> bool:
if should_ignore(relative_path):
return False
if filter_epoch <= 0:
return True
try:
return int((root / relative_path).stat().st_mtime) >= filter_epoch
except OSError:
return False
def read_utf8(path: Path) -> str | None:
try:
return path.read_bytes().decode("utf-8")
except (OSError, UnicodeDecodeError):
return None
def normalize_output(text: str) -> str:
text = text.replace("\r\n", "\n").replace("\r", "\n")
text = text.replace("\n", "\r\n")
text = text.strip("\uFEFF\u200B")
if LICENSE_TEXT and not text.startswith(LICENSE_TEXT):
text = LICENSE_TEXT + text
return text
def chunk_paths(paths: list[Path], max_command_chars: int = 24000) -> list[list[Path]]:
chunks: list[list[Path]] = []
current: list[Path] = []
current_chars = 0
for path in paths:
path_chars = len(str(path)) + 3
if current and current_chars + path_chars > max_command_chars:
chunks.append(current)
current = []
current_chars = 0
current.append(path)
current_chars += path_chars
if current:
chunks.append(current)
return chunks
def run_astyle(astyle_exe: Path, file_paths: list[Path], dry_run: bool) -> subprocess.CompletedProcess[str]:
command = [
str(astyle_exe),
*option_flags(),
"--options=none",
"--project=none",
"--formatted",
]
if dry_run:
command.append("--dry-run")
else:
command.append("--suffix=none")
command.extend(str(file_path) for file_path in file_paths)
return subprocess.run(
command,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
def parse_changed_paths(output: str, root: Path) -> list[str]:
changed_files: list[str] = []
root_resolved = root.resolve()
for line in output.splitlines():
line = line.strip()
if not line or not line.startswith("Formatted"):
continue
path_text = line[len("Formatted"):].strip()
try:
relative = Path(path_text).resolve().relative_to(root_resolved).as_posix()
except (OSError, ValueError):
relative = path_text.replace("\\", "/")
changed_files.append(relative)
return changed_files
def format_directory(root: Path, write_changes: bool, filter_epoch: int, show_progress: bool = False) -> tuple[list[str], list[str]]:
changed_files: set[str] = set()
errors: list[str] = []
astyle_exe = find_astyle_executable()
file_records: list[tuple[Path, str]] = []
for relative_path in git_ls_files(root):
if not should_process(root, relative_path, filter_epoch):
continue
original_text = read_utf8(root / relative_path)
if original_text is None:
continue
file_records.append((relative_path, original_text))
if show_progress:
for relative_path, _ in file_records:
print(relative_path.as_posix(), flush=True)
absolute_paths = [root / relative_path for relative_path, _ in file_records]
for batch in chunk_paths(absolute_paths):
result = run_astyle(astyle_exe, batch, dry_run=not write_changes)
if result.returncode != 0:
batch_set = {path.resolve() for path in batch}
for relative_path, _ in file_records:
if (root / relative_path).resolve() in batch_set:
errors.append(f"Cannot format {relative_path.as_posix()}")
continue
changed_files.update(parse_changed_paths(result.stdout, root))
for relative_path, original_text in file_records:
display_path = relative_path.as_posix()
if write_changes:
formatted_text = read_utf8(root / relative_path)
if formatted_text is None:
errors.append(f"Cannot format {display_path}")
continue
normalized_text = normalize_output(formatted_text)
if formatted_text != normalized_text:
try:
(root / relative_path).write_bytes(normalized_text.encode("utf-8"))
except OSError:
errors.append(f"Cannot format {display_path}")
continue
if original_text != normalized_text:
changed_files.add(display_path)
elif original_text != normalize_output(original_text):
changed_files.add(display_path)
return sorted(changed_files), errors
def run_format(root: Path, filter_epoch: int, show_progress: bool) -> int:
changed_files, errors = format_directory(
root,
write_changes=True,
filter_epoch=filter_epoch,
show_progress=show_progress,
)
for error in errors:
stderr(error)
if errors and not changed_files:
return EXIT_ERROR
return EXIT_CHANGES if changed_files else EXIT_OK
def run_silent(root: Path, filter_epoch: int) -> int:
return run_format(root, filter_epoch, show_progress=False)
def run_check(root: Path, filter_epoch: int) -> int:
changed_files, errors = format_directory(root, write_changes=False, filter_epoch=filter_epoch)
for error in errors:
stderr(error)
if changed_files:
stderr("Nonconforming files:")
for path in changed_files:
stderr(path)
return EXIT_CHANGES
if errors:
return EXIT_ERROR
print("Formatting fully conforming!")
return EXIT_OK
def main(argv: list[str]) -> int:
root = get_git_root(Path.cwd())
if not argv:
try:
return run_format(root, 0, show_progress=True)
except FileNotFoundError as exc:
stderr(str(exc))
return EXIT_ERROR
except OSError as exc:
stderr(str(exc))
return EXIT_ERROR
filter_epoch, status = parse_filter_epoch(argv[1:])
if status != EXIT_OK:
return status
command = argv[0].lower()
try:
if command == "silent":
return run_silent(root, filter_epoch)
if command == "check":
return run_check(root, filter_epoch)
stderr(f"Invalid argument: {argv[0]}")
return usage()
except FileNotFoundError as exc:
stderr(str(exc))
return EXIT_ERROR
except OSError as exc:
stderr(str(exc))
return EXIT_ERROR
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))