TRX/tools/shared/linting.py

233 lines
7 KiB
Python
Raw Permalink Normal View History

2024-10-02 10:23:23 +02:00
#!/usr/bin/env python3
import json
import re
2025-01-15 14:13:32 +01:00
from collections import defaultdict
2024-10-02 10:23:23 +02:00
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from pathlib import Path
2025-01-15 14:13:32 +01:00
PROJECTS = ["tr1", "tr2", "libtrx"]
RE_GAME_STRING_DEFINE = re.compile(r"GS_DEFINE\(([A-Z_]+),.*\)")
RE_GAME_STRING_USAGE = re.compile(r"GS(?:_ID)?\(([A-Z_]+)\)")
2024-10-02 10:23:23 +02:00
@dataclass
class LintContext:
root_dir: Path
versioned_files: list[Path]
@dataclass
class LintWarning:
path: Path
message: str
line: int | None = None
def __str__(self) -> str:
prefix = str(self.path)
if self.line is not None:
prefix += f":{self.line}"
return f"{prefix}: {self.message}"
def lint_json_validity(
context: LintContext, path: Path
) -> Iterable[LintWarning]:
if path.suffix != ".json":
return
try:
json.loads(path.read_text())
except json.JSONDecodeError as ex:
yield LintWarning(path, f"malformed JSON: {ex!s}")
def lint_newlines(context: LintContext, path: Path) -> Iterable[LintWarning]:
text = path.read_text(encoding="utf-8")
if not text:
return
if not text.endswith("\n"):
yield LintWarning(path, "missing newline character at end of file")
if text.endswith("\n\n"):
yield LintWarning(path, "extra newline character at end of file")
def lint_trailing_whitespace(
context: LintContext, path: Path
) -> Iterable[LintWarning]:
if path.suffix == ".md":
return
for i, line in enumerate(path.open("r"), 1):
if line.rstrip("\n").endswith(" "):
yield LintWarning(path, "trailing whitespace", line=i)
def lint_const_primitives(
context: LintContext, path: Path
) -> Iterable[LintWarning]:
if path.suffix != ".h":
return
for i, line in enumerate(path.open("r"), 1):
if re.search(r"const (int[a-z0-9_]*|bool)\b\s*[a-z]", line):
yield LintWarning(path, "useless const", line=i)
if re.search(r"\*\s*const", line):
yield LintWarning(path, "useless const", line=i)
2025-01-15 14:13:32 +01:00
def get_relevant_project(context: LintContext, path: Path) -> str:
for project, project_path in get_project_paths(context).items():
if path.absolute().is_relative_to(project_path.absolute()):
break
else:
raise RuntimeError(f"{path}: Unable to get project path")
return project
def get_project_paths(context: LintContext) -> dict[str, Path]:
return {
project: context.root_dir / "src" / project for project in PROJECTS
}
def get_project_game_strings_paths(
context: LintContext,
) -> dict[str, list[Path]]:
return {
project: list(project_path.rglob("**/game_string.def"))
for project, project_path in get_project_paths(context).items()
}
def get_project_game_string_maps(context: LintContext) -> dict[str, set[str]]:
return {
project: [
match.group(1)
for path in def_paths
for match in re.finditer(RE_GAME_STRING_DEFINE, path.read_text())
]
for project, def_paths in get_project_game_strings_paths(
context
).items()
}
def lint_undefined_game_strings(
2024-10-02 10:23:23 +02:00
context: LintContext, paths: list[Path]
) -> Iterable[LintWarning]:
2025-01-15 14:13:32 +01:00
project_paths = get_project_paths(context)
project_game_strings_paths = get_project_game_strings_paths(context)
def_string_map = get_project_game_string_maps(context)
if not def_string_map:
yield LintWarning("Unable to list game string definitions")
2024-10-02 10:23:23 +02:00
return
for path in paths:
if path.suffix != ".c":
continue
2025-01-15 14:13:32 +01:00
relevant_projects = [get_relevant_project(context, path), "libtrx"]
relevant_paths = sum(
[
project_game_strings_paths[relevant_project]
for relevant_project in sorted(relevant_projects)
],
[],
)
path_hint = " or ".join(
str(relevant_path.relative_to(context.root_dir))
for relevant_path in relevant_paths
)
2024-10-02 10:23:23 +02:00
for i, line in enumerate(path.open("r"), 1):
2025-01-15 14:13:32 +01:00
for match in re.finditer(RE_GAME_STRING_USAGE, line):
2024-10-02 10:23:23 +02:00
def_ = match.group(1)
2025-01-15 14:13:32 +01:00
if any(
def_ in def_string_map[project]
for project in relevant_projects
):
2024-10-02 10:23:23 +02:00
continue
yield LintWarning(
path,
f"undefined game string: {def_}. "
2025-01-15 14:13:32 +01:00
f"Make sure it's defined in {path_hint}.",
2024-10-02 10:23:23 +02:00
i,
)
2025-01-15 14:13:32 +01:00
def lint_unused_game_strings(context: LintContext) -> Iterable[LintWarning]:
project_paths = get_project_paths(context)
project_game_strings_paths = get_project_game_strings_paths(context)
project_game_strings_maps = get_project_game_string_maps(context)
if not project_game_strings_maps:
yield LintWarning("Unable to list game string definitions")
return
used_strings = defaultdict(set)
for path in context.versioned_files:
2025-01-16 12:14:13 +01:00
if path.suffix not in {".c", ".def"}:
2025-01-15 14:13:32 +01:00
continue
relevant_project = get_relevant_project(context, path)
for i, line in enumerate(path.open("r"), 1):
for match in re.finditer(RE_GAME_STRING_USAGE, line):
used_strings[relevant_project].add(match.group(1))
for project, defs in project_game_strings_maps.items():
relevant_projects = {
"libtrx": PROJECTS,
"tr1": ["tr1", "libtrx"],
"tr2": ["tr2", "libtrx"],
}[project]
for def_ in defs:
used_projects = {rel_project
for rel_project in relevant_projects
if def_ in used_strings[rel_project]
}
if len(used_projects) == 0:
yield LintWarning(
project_paths[project], f"unused game string: {def_}."
)
elif project == 'libtrx' and 'libtrx' not in used_projects and len(used_projects) == 1:
yield LintWarning(
2025-01-16 12:14:13 +01:00
project_paths[project], f"game string used only in a single child project: {def_} ({used_projects!s})."
2025-01-15 14:13:32 +01:00
)
ALL_FILE_LINTERS: list[
Callable[[LintContext, Path], Iterable[LintWarning]]
] = [
2024-10-02 10:23:23 +02:00
lint_json_validity,
lint_newlines,
lint_trailing_whitespace,
lint_const_primitives,
]
ALL_BULK_LINTERS: list[
Callable[[LintContext, list[Path]], Iterable[LintWarning]]
] = [
2025-01-15 14:13:32 +01:00
lint_undefined_game_strings,
]
ALL_REPO_LINTERS: list[
Callable[[LintContext, list[Path]], Iterable[LintWarning]]
] = [
lint_unused_game_strings,
2024-10-02 10:23:23 +02:00
]
def lint_file(context: LintContext, file: Path) -> Iterable[LintWarning]:
2025-01-15 14:13:32 +01:00
for linter_func in ALL_FILE_LINTERS:
2024-10-02 10:23:23 +02:00
yield from linter_func(context, file)
def lint_bulk_files(
context: LintContext, files: list[Path]
) -> Iterable[LintWarning]:
for linter_func in ALL_BULK_LINTERS:
yield from linter_func(context, files)
2025-01-15 14:13:32 +01:00
def lint_repo(context: LintContext) -> Iterable[LintWarning]:
for linter_func in ALL_REPO_LINTERS:
yield from linter_func(context)