Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,6 @@ htmlcov/

# mypy
.mypy_cache/

# gdcruiser incremental parse cache
.gdcruiser_cache.json
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,16 @@ gdcruiser [-h] [-f {text,json,dot,mermaid}] [-o FILE] [--no-cycles] [-v] [path]
| `--validate-config` | Validate config file and exit |
| `--ignore-rules` | Skip rule evaluation |
| `--exclude PATTERN` | Regex pattern to exclude paths (can be repeated) |
| `--cache` | Enable incremental parse caching (default file: `.gdcruiser_cache.json`) |
| `--cache-file FILE` | Path to the incremental parse cache (implies `--cache`) |
| `-v, --verbose` | Verbose output |

With caching enabled, unchanged files (matched by modification time and size)
are restored from the cache instead of being re-parsed — useful for large
projects run repeatedly in CI or a pre-commit hook. Symbol resolution and cycle
detection always run fresh, so results are identical to an uncached run. Add
the cache file to your `.gitignore`.

### Examples

Analyze the current directory:
Expand Down
72 changes: 58 additions & 14 deletions src/gdcruiser/analyzer.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
from dataclasses import dataclass, field
from pathlib import Path

from .cache import ParseCache
from .scanner import Scanner
from .parser.gdscript import GDScriptParser
from .parser.tscn import TscnParser
from .parser.tres import TresParser
from .parser.project_godot import parse_autoloads
from .graph.dependency import DependencyGraph
from .graph.cycles import CycleDetector
from .graph.node import Module
from .symbols.table import SymbolTable


Expand All @@ -19,13 +21,15 @@ class AnalysisResult:
cycles: list[list[str]] = field(default_factory=list)
symbol_table: SymbolTable = field(default_factory=SymbolTable)
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)

def to_dict(self) -> dict:
return {
"graph": self.graph.to_dict(),
"cycles": self.cycles,
"symbols": self.symbol_table.all_classes(),
"errors": self.errors,
"warnings": self.warnings,
}


Expand All @@ -37,6 +41,7 @@ def __init__(
project_path: Path,
verbose: bool = False,
exclude: list[str] | None = None,
cache: ParseCache | None = None,
) -> None:
self._scanner = Scanner(project_path, exclude=exclude)
self._symbol_table = SymbolTable()
Expand All @@ -45,7 +50,9 @@ def __init__(
self._tres_parser = TresParser(self._symbol_table)
self._graph = DependencyGraph()
self._verbose = verbose
self._cache = cache
self._errors: list[str] = []
self._warnings: list[str] = []

def analyze(self, detect_cycles: bool = True) -> AnalysisResult:
"""Analyze the project and return results."""
Expand All @@ -54,8 +61,13 @@ def analyze(self, detect_cycles: bool = True) -> AnalysisResult:

# Register project.godot [autoload] singletons before any class-ref
# resolution runs — `TurnManager.foo` then resolves the same way as
# any other class_name reference.
autoloads = parse_autoloads(root)
# any other class_name reference. A malformed/unreadable project.godot
# must not abort the whole analysis.
try:
autoloads = parse_autoloads(root)
except Exception as e:
autoloads = {}
self._errors.append(f"Error parsing project.godot: {e}")
for identifier, path in autoloads.items():
self._symbol_table.register(identifier, path)

Expand All @@ -69,32 +81,33 @@ def analyze(self, detect_cycles: bool = True) -> AnalysisResult:
# First pass: parse all GDScript files to build symbol table
modules = []
for gd_file in gd_files:
try:
module = self._gd_parser.parse(gd_file, root)
module = self._parse_file(gd_file, self._gd_parser, root)
if module is not None:
modules.append(module)
self._graph.add_module(module)
except Exception as e:
self._errors.append(f"Error parsing {gd_file}: {e}")

# Second pass: resolve class name dependencies
for module in modules:
self._gd_parser.resolve_class_dependencies(module)

# Parse scene files
for tscn_file in tscn_files:
try:
module = self._tscn_parser.parse(tscn_file, root)
module = self._parse_file(tscn_file, self._tscn_parser, root)
if module is not None:
self._graph.add_module(module)
except Exception as e:
self._errors.append(f"Error parsing {tscn_file}: {e}")

# Parse resource (.tres) files
for tres_file in tres_files:
try:
module = self._tres_parser.parse(tres_file, root)
module = self._parse_file(tres_file, self._tres_parser, root)
if module is not None:
self._graph.add_module(module)
except Exception as e:
self._errors.append(f"Error parsing {tres_file}: {e}")

if self._cache is not None:
self._cache.save()
if self._verbose:
print(
f"Parse cache: {self._cache.hits} hits, {self._cache.misses} misses"
)

# Detect cycles
cycles: list[list[str]] = []
Expand All @@ -104,9 +117,40 @@ def analyze(self, detect_cycles: bool = True) -> AnalysisResult:
if self._verbose:
print(f"Found {len(cycles)} cycles")

# Surface duplicate class_name / autoload registrations as warnings.
for name, existing, new in self._symbol_table.collisions():
self._warnings.append(
f"Duplicate symbol '{name}': registered by both "
f"{existing} and {new} (using {new})"
)

return AnalysisResult(
graph=self._graph,
cycles=cycles,
symbol_table=self._symbol_table,
errors=self._errors,
warnings=self._warnings,
)

def _parse_file(self, file_path: Path, parser, root: Path) -> Module | None:
"""Parse a file, restoring it from the cache when unchanged.

Cached modules are returned verbatim, but their declared class_name is
re-registered in the symbol table so cross-file resolution still works
without re-parsing the file body.
"""
try:
if self._cache is not None:
key = self._cache.stat_key(file_path)
cached = self._cache.get(file_path, key)
if cached is not None:
if cached.class_name:
self._symbol_table.register(cached.class_name, cached.path)
return cached
module = parser.parse(file_path, root)
self._cache.put(file_path, key, module)
return module
return parser.parse(file_path, root)
except Exception as e:
self._errors.append(f"Error parsing {file_path}: {e}")
return None
71 changes: 71 additions & 0 deletions src/gdcruiser/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Incremental parse cache.

Persists the *raw* parsed :class:`Module` for each source file keyed by the
file's modification time and size. On a subsequent run, unchanged files are
restored from the cache instead of being re-read and re-parsed.

Only per-file parsing is cached. Cross-file work (symbol resolution and cycle
detection) always runs fresh, because a change in one file can alter how an
unchanged file's class references resolve.
"""

import json
from pathlib import Path

from .graph.node import Module

# Bump when the cached representation or parsing semantics change, so stale
# caches from older versions are transparently ignored.
CACHE_VERSION = 1


class ParseCache:
"""A file-backed cache of parsed modules keyed by (mtime_ns, size)."""

def __init__(self, cache_path: Path) -> None:
self._path = cache_path
self._old: dict[str, dict] = {}
self._new: dict[str, dict] = {}
self.hits = 0
self.misses = 0

def load(self) -> None:
"""Load an existing cache file, ignoring anything unreadable or stale."""
try:
data = json.loads(self._path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return
if isinstance(data, dict) and data.get("version") == CACHE_VERSION:
entries = data.get("entries")
if isinstance(entries, dict):
self._old = entries

@staticmethod
def stat_key(file_path: Path) -> str:
"""Return a cache key capturing the file's current mtime and size."""
stat = file_path.stat()
return f"{stat.st_mtime_ns}:{stat.st_size}"

def get(self, file_path: Path, key: str) -> Module | None:
"""Return the cached module for ``file_path`` if its key still matches."""
path_key = str(file_path)
entry = self._old.get(path_key)
if entry is not None and entry.get("key") == key:
self.hits += 1
# Carry the still-valid entry forward so it survives the next save.
self._new[path_key] = entry
return Module.from_dict(entry["module"])
self.misses += 1
return None

def put(self, file_path: Path, key: str, module: Module) -> None:
"""Store a freshly parsed module under its current key."""
self._new[str(file_path)] = {"key": key, "module": module.to_dict()}

def save(self) -> None:
"""Write the accumulated entries back to disk."""
payload = {"version": CACHE_VERSION, "entries": self._new}
try:
self._path.write_text(json.dumps(payload), encoding="utf-8")
except OSError:
pass
56 changes: 38 additions & 18 deletions src/gdcruiser/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@
from pathlib import Path

from .analyzer import Analyzer
from .cache import ParseCache
from .config import ConfigError, ConfigLoader, ConfigValidator
from .output.dot import DotFormatter
from .output.json import JsonFormatter
from .output.mermaid import MermaidFormatter
from .output.text import TextFormatter
from .output import FORMATTERS
from .rules import RuleEngine


Expand Down Expand Up @@ -40,7 +38,7 @@ def create_parser() -> argparse.ArgumentParser:
parser.add_argument(
"-f",
"--format",
choices=["text", "json", "dot", "mermaid"],
choices=list(FORMATTERS),
default="text",
help="Output format (default: text)",
)
Expand Down Expand Up @@ -91,6 +89,18 @@ def create_parser() -> argparse.ArgumentParser:
help="Regex pattern to exclude paths from analysis (can be repeated)",
)

parser.add_argument(
"--cache",
action="store_true",
help="Enable incremental parse caching (default file: .gdcruiser_cache.json)",
)

parser.add_argument(
"--cache-file",
metavar="FILE",
help="Path to the incremental parse cache (implies --cache)",
)

return parser


Expand All @@ -115,6 +125,11 @@ def run(args: argparse.Namespace) -> int:
print(f"Error: {e}", file=sys.stderr)
return 1

# Surface non-fatal config issues (unknown keys, bad severity values) even
# when they leave no valid rules behind (e.g. a mistyped section name).
for w in config.warnings:
print(f"Warning: {w}", file=sys.stderr)

# Validate config
if config.has_rules() or args.validate_config:
validator = ConfigValidator()
Expand Down Expand Up @@ -150,7 +165,22 @@ def run(args: argparse.Namespace) -> int:
if args.exclude:
exclude.extend(args.exclude)

analyzer = Analyzer(project_path, verbose=args.verbose, exclude=exclude or None)
cache = None
if args.cache or args.cache_file:
cache_path = (
Path(args.cache_file)
if args.cache_file
else project_path / ".gdcruiser_cache.json"
)
cache = ParseCache(cache_path)
cache.load()

analyzer = Analyzer(
project_path,
verbose=args.verbose,
exclude=exclude or None,
cache=cache,
)
result = analyzer.analyze(detect_cycles=not args.no_cycles)

# Evaluate rules
Expand All @@ -166,18 +196,8 @@ def run(args: argparse.Namespace) -> int:
)

# Format output
if args.format == "json":
formatter = JsonFormatter()
output = formatter.format(result, rule_result)
elif args.format == "dot":
formatter = DotFormatter()
output = formatter.format(result)
elif args.format == "mermaid":
formatter = MermaidFormatter()
output = formatter.format(result)
else:
formatter = TextFormatter()
output = formatter.format(result, rule_result)
formatter = FORMATTERS[args.format]()
output = formatter.format(result, rule_result)

# Write output
if args.output:
Expand Down
Loading