2"""Automate source list maintenance and self-header includes for YAZE."""
4from __future__
import annotations
7from dataclasses
import dataclass, field
9from pathlib
import Path
10from typing
import Any, Iterable, List, Optional, Sequence, Set
17 print(
"Warning: 'pathspec' module not found. Install with: pip3 install pathspec")
18 print(
" .gitignore support will be disabled.")
20PROJECT_ROOT = Path(__file__).resolve().parent.parent
21SOURCE_ROOT = PROJECT_ROOT /
"src"
23SUPPORTED_EXTENSIONS = (
".cc",
".c",
".cpp",
".cxx",
".mm")
24HEADER_EXTENSIONS = (
".h",
".hh",
".hpp",
".hxx")
25BUILD_CLEANER_IGNORE_TOKEN =
"build_cleaner:ignore"
29 'std::': [
'<memory>',
'<string>',
'<vector>',
'<map>',
'<set>',
'<algorithm>',
'<functional>'],
30 'absl::': [
'<absl/status/status.h>',
'<absl/status/statusor.h>',
'<absl/strings/str_format.h>'],
31 'ImGui::': [
'<imgui.h>'],
36@dataclass(frozen=True)
39 recursive: bool =
True
40 extensions: Sequence[str] = SUPPORTED_EXTENSIONS
43 if not self.
path.exists():
46 iterator = self.
path.rglob(
"*")
48 iterator = self.
path.glob(
"*")
49 for candidate
in iterator:
50 if candidate.is_file()
and candidate.suffix
in self.
extensions:
58 directories: Sequence[DirectorySpec]
59 exclude: Set[Path] = field(default_factory=set)
63 """Load .gitignore patterns into a pathspec matcher."""
67 gitignore_path = PROJECT_ROOT /
".gitignore"
68 if not gitignore_path.exists():
72 with gitignore_path.open(
'r', encoding=
'utf-8')
as f:
73 patterns = [line.strip()
for line
in f
if line.strip()
and not line.startswith(
'#')]
74 return pathspec.PathSpec.from_lines(
'gitwildmatch', patterns)
75 except Exception
as e:
76 print(f
"Warning: Could not load .gitignore: {e}")
81 """Check if a path should be ignored based on .gitignore patterns."""
82 if gitignore_spec
is None or not HAS_PATHSPEC:
86 rel_path = path.relative_to(PROJECT_ROOT)
87 return gitignore_spec.match_file(str(rel_path))
94 Auto-discover CMake library files that explicitly opt-in to auto-maintenance.
96 Looks for marker comments like:
97 - "# build_cleaner:auto-maintain"
98 - "# auto-maintained by build_cleaner.py"
101 Only source lists with these markers will be updated.
103 Supports decomposed libraries where one cmake file defines multiple PREFIX_SUBDIR_SRC
104 variables (e.g., GFX_TYPES_SRC, GFX_BACKEND_SRC). Automatically scans subdirectories.
107 file_variables: dict[Path, list[str]] = {}
109 for cmake_file
in SOURCE_ROOT.rglob(
"*.cmake"):
110 if 'lib/' in str(cmake_file)
or 'third_party/' in str(cmake_file):
114 content = cmake_file.read_text(encoding=
'utf-8')
115 lines = content.splitlines()
117 for i, line
in enumerate(lines):
119 auto_maintained =
False
120 for j
in range(max(0, i-5), i):
121 line_lower = lines[j].lower()
122 if (
'build_cleaner' in line_lower
and 'auto-maintain' in line_lower)
or \
123 'auto_maintain' in line_lower:
124 auto_maintained =
True
127 if not auto_maintained:
131 match = re.search(
r'set\s*\(\s*(\w+(?:_SRC|_SOURCES|_SOURCE))(?:\s|$|\))', line)
133 var_name = match.group(1)
134 if cmake_file
not in file_variables:
135 file_variables[cmake_file] = []
136 if var_name
not in file_variables[cmake_file]:
137 file_variables[cmake_file].append(var_name)
139 except Exception
as e:
140 print(f
"Warning: Could not process {cmake_file}: {e}")
144 for cmake_file, variables
in file_variables.items():
145 cmake_dir = cmake_file.parent
146 is_recursive = cmake_dir != SOURCE_ROOT /
"app/core"
150 prefix_groups: dict[str, list[str]] = {}
151 for var_name
in variables:
152 match = re.match(
r'([A-Z]+)_([A-Z_]+)_(?:SRC|SOURCES|SOURCE)$', var_name)
154 prefix = match.group(1)
155 if prefix
not in prefix_groups:
156 prefix_groups[prefix] = []
157 prefix_groups[prefix].append(var_name)
160 decomposed_prefixes = {p
for p, vars
in prefix_groups.items()
if len(vars) >= 2}
164 is_likely_decomposed = len(variables) >= 2
and any(p
in decomposed_prefixes
for p
in prefix_groups)
166 for var_name
in variables:
168 subdir_match = re.match(
r'([A-Z]+)_([A-Z_]+)_(?:SRC|SOURCES|SOURCE)$', var_name)
170 prefix = subdir_match.group(1)
171 subdir_part = subdir_match.group(2)
174 if prefix
in decomposed_prefixes:
175 subdir = subdir_part.lower()
176 target_dir = cmake_dir / subdir
178 if target_dir.exists()
and target_dir.is_dir():
181 cmake_path=cmake_file,
188 simple_match = re.match(
r'([A-Z]+)_(?:SRC|SOURCES|SOURCE)$', var_name)
189 if simple_match
and is_likely_decomposed:
190 subdir_part = simple_match.group(1)
191 subdir = subdir_part.lower()
192 target_dir = cmake_dir / subdir
194 if target_dir.exists()
and target_dir.is_dir():
197 cmake_path=cmake_file,
205 cmake_path=cmake_file,
206 directories=(
DirectorySpec(cmake_dir, recursive=is_recursive),),
214STATIC_CONFIG: Sequence[CMakeSourceBlock] = (
216 variable=
"YAZE_APP_EMU_SRC",
217 cmake_path=SOURCE_ROOT /
"CMakeLists.txt",
221 variable=
"YAZE_APP_CORE_SRC",
222 cmake_path=SOURCE_ROOT /
"app/core/core_library.cmake",
223 directories=(
DirectorySpec(SOURCE_ROOT /
"app/core", recursive=
False),),
226 variable=
"YAZE_APP_EDITOR_SRC",
227 cmake_path=SOURCE_ROOT /
"app/editor/editor_library.cmake",
231 variable=
"YAZE_APP_ZELDA3_SRC",
232 cmake_path=SOURCE_ROOT /
"zelda3/zelda3_library.cmake",
236 variable=
"YAZE_NET_SRC",
237 cmake_path=SOURCE_ROOT /
"app/net/net_library.cmake",
239 exclude={Path(
"app/net/rom_service_impl.cc")},
242 variable=
"YAZE_UTIL_SRC",
243 cmake_path=SOURCE_ROOT /
"util/util.cmake",
309 variable=
"YAZE_AGENT_SOURCES",
310 cmake_path=SOURCE_ROOT /
"cli/agent.cmake",
318 Path(
"cli/cli_main.cc"),
322 variable=
"YAZE_TEST_SOURCES",
323 cmake_path=SOURCE_ROOT /
"app/test/test.cmake",
330 return path.relative_to(SOURCE_ROOT)
334 """Return index of the closing ')' line for a set/list block."""
335 for idx
in range(start_idx + 1, len(lines)):
336 if lines[idx].strip().startswith(
")"):
338 raise ValueError(f
"Unterminated set/list block starting at line {start_idx}")
342 stripped = line.strip()
343 if not stripped
or stripped.startswith(
"#"):
346 stripped = stripped.split(
"#", 1)[0].strip()
349 if stripped.startswith(
"$"):
355 """Extract files that are added to the variable via conditional blocks (if/endif)."""
356 conditional_files: Set[str] = set()
359 lines = cmake_path.read_text(encoding=
'utf-8').splitlines()
361 return conditional_files
363 in_conditional =
False
364 conditional_depth = 0
366 for i, line
in enumerate(lines):
367 stripped = line.strip()
370 if stripped.startswith(
'if(')
or stripped.startswith(
'if '):
372 conditional_depth += 1
374 in_conditional =
True
375 conditional_depth = 0
376 elif stripped.startswith(
'endif(')
or stripped ==
'endif()':
377 if conditional_depth > 0:
378 conditional_depth -= 1
380 in_conditional =
False
383 if in_conditional
and f
'APPEND {variable}' in line:
387 match = re.search(rf
'APPEND\s+{re.escape(variable)}\s+(.+?)\)', line)
389 file_str = match.group(1).strip()
391 for f
in file_str.split():
393 if f
and not f.startswith(
'$')
and '/' in f
and f.endswith(
'.cc'):
394 conditional_files.add(f)
398 while j < len(lines)
and not lines[j].strip().startswith(
')'):
401 conditional_files.add(entry)
404 return conditional_files
411 entries: Set[str] = set()
412 for directory
in block.directories:
413 for source_file
in directory.iter_files():
425 rel_path = source_file.relative_to(SOURCE_ROOT)
426 rel_path_str = str(rel_path).replace(
"\\",
"/")
430 if rel_path_str
not in conditional_files:
431 entries.add(rel_path_str)
433 return sorted(entries)
438 with path.open(
"r", encoding=
"utf-8")
as handle:
439 head = handle.read(256)
440 except (OSError, UnicodeDecodeError):
442 return BUILD_CLEANER_IGNORE_TOKEN
in head
446 """Extract all #include statements from a source file."""
449 with file_path.open(
'r', encoding=
'utf-8')
as f:
452 match = re.match(
r'^\s*#include\s+[<"]([^>"]+)[>"]', line)
454 includes.add(match.group(1))
455 except (OSError, UnicodeDecodeError):
461 """Extract potential symbols/identifiers that might need headers."""
464 with file_path.open(
'r', encoding=
'utf-8')
as f:
468 namespace_symbols = re.findall(
r'\b([a-zA-Z_]\w*::)', content)
469 symbols.update(namespace_symbols)
472 func_calls = re.findall(
r'\b([A-Z][a-zA-Z0-9_]*)\s*\(', content)
473 symbols.update(func_calls)
475 except (OSError, UnicodeDecodeError):
481 """Analyze a source file and suggest missing headers based on symbol usage."""
490 for symbol_prefix, headers
in COMMON_HEADERS.items():
491 if any(symbol_prefix
in sym
for sym
in symbols):
492 for header
in headers:
494 header_name = header.strip(
'<>')
495 if header_name
not in ' '.join(current_includes):
496 missing.append(header)
503 Find conditional blocks (if/endif) that append to the variable after the main set() block.
504 Returns lines that should be preserved.
506 conditional_lines = []
509 while idx < len(cmake_lines):
510 line = cmake_lines[idx]
511 stripped = line.strip()
519 if stripped.startswith(
'if(')
or stripped.startswith(
'if ('):
526 while temp_idx < len(cmake_lines)
and block_depth > 0:
527 temp_line = cmake_lines[temp_idx].strip()
528 if temp_line.startswith(
'if(')
or temp_line.startswith(
'if '):
530 elif temp_line.startswith(
'endif(')
or temp_line ==
'endif()':
534 if f
'APPEND {variable}' in temp_line
or f
'APPEND\n {variable}' in cmake_lines[temp_idx]:
541 conditional_lines.extend(cmake_lines[block_start:temp_idx])
553 return conditional_lines
557 cmake_lines = (block.cmake_path.read_text(encoding=
"utf-8")).splitlines()
558 pattern = re.compile(rf
"\s*set\(\s*{re.escape(block.variable)}\b")
560 start_idx: Optional[int] =
None
561 for idx, line
in enumerate(cmake_lines):
562 if pattern.match(line):
566 if start_idx
is None:
567 for idx, line
in enumerate(cmake_lines):
568 stripped = line.strip()
569 if not stripped.startswith(
"set("):
571 remainder = stripped[4:].strip()
573 if remainder.startswith(block.variable):
578 while lookahead < len(cmake_lines):
579 next_line = cmake_lines[lookahead].strip()
580 if not next_line
or next_line.startswith(
"#"):
583 if next_line == block.variable:
586 if start_idx
is not None:
589 if start_idx
is None:
590 raise ValueError(f
"Could not locate set({block.variable}) in {block.cmake_path}")
593 block_slice = cmake_lines[start_idx + 1 : end_idx]
595 prelude: List[str] = []
596 postlude: List[str] = []
597 existing_entries: List[str] = []
599 first_entry_idx: Optional[int] =
None
601 for idx, line
in enumerate(block_slice):
604 if entry == block.variable
and not existing_entries:
607 existing_entries.append(entry)
608 if first_entry_idx
is None:
609 first_entry_idx = idx
611 if first_entry_idx
is None:
614 postlude.append(line)
617 expected_set = set(expected_entries)
619 if set(existing_entries) == expected_set:
623 if first_entry_idx
is not None:
624 sample_line = block_slice[first_entry_idx]
625 indent = sample_line[: len(sample_line) - len(sample_line.lstrip())]
627 rebuilt_block = prelude + [f
"{indent}{entry}" for entry
in expected_entries] + postlude
630 print(f
"[DRY-RUN] Would update {block.cmake_path.relative_to(PROJECT_ROOT)} :: {block.variable}")
633 cmake_lines[start_idx + 1 : end_idx] = rebuilt_block
634 block.cmake_path.write_text(
"\n".join(cmake_lines) +
"\n", encoding=
"utf-8")
635 print(f
"Updated {block.cmake_path.relative_to(PROJECT_ROOT)} :: {block.variable}")
636 missing = sorted(expected_set - set(existing_entries))
637 removed = sorted(set(existing_entries) - expected_set)
639 print(f
" Added: {', '.join(missing)}")
641 print(f
" Removed: {', '.join(removed)}")
646 for ext
in HEADER_EXTENSIONS:
647 candidate = source.with_suffix(ext)
648 if candidate.exists():
653def has_include(lines: Sequence[str], header_variants: Iterable[str]) -> bool:
654 """Check if any line includes one of the header variants (with any path or quote style)."""
656 header_names = {Path(variant).name
for variant
in header_variants}
659 stripped = line.strip()
660 if not stripped.startswith(
'#include'):
664 match = re.match(
r'^\s*#include\s+[<"]([^>"]+)[>"]', stripped)
666 included_path = match.group(1)
667 included_name = Path(included_path).name
670 if included_name
in header_names:
677 include_block_start =
None
678 for idx, line
in enumerate(lines):
679 if line.startswith(
"#include"):
680 include_block_start = idx
683 if include_block_start
is not None:
684 return include_block_start
688 in_block_comment =
False
689 while index < len(lines):
690 stripped = lines[index].strip()
694 if stripped.startswith(
"/*")
and not stripped.endswith(
"*/"):
695 in_block_comment =
True
700 in_block_comment =
False
703 if stripped.startswith(
"//"):
712 Ensure a source file includes its corresponding header file.
715 - Are explicitly ignored
716 - Have no corresponding header
717 - Already include their header (in any path format)
718 - Are test files or main entry points (typically don't include own header)
724 source_name = source.name.lower()
725 if any(pattern
in source_name
for pattern
in [
'_test.cc',
'_main.cc',
'_benchmark.cc',
'main.cc']):
734 lines = source.read_text(encoding=
"utf-8").splitlines()
735 except UnicodeDecodeError:
740 header_rel_path = header.relative_to(SOURCE_ROOT)
741 header_path_str = str(header_rel_path).replace(
"\\",
"/")
744 header_path_str = header.name
750 str(header.relative_to(source.parent)).replace(
"\\",
"/")
if source.parent != header.parent
else header.name,
759 code_lines = [l
for l
in lines
if l.strip()
and not l.strip().startswith(
'//')
and not l.strip().startswith(
'/*')]
760 if len(code_lines) < 3:
764 include_line = f
'#include "{header_path_str}"'
767 lines.insert(insert_idx, include_line)
770 rel = source.relative_to(PROJECT_ROOT)
771 print(f
"[DRY-RUN] Would insert self-header include into {rel}")
774 source.write_text(
"\n".join(lines) +
"\n", encoding=
"utf-8")
775 print(f
"Inserted self-header include into {source.relative_to(PROJECT_ROOT)}")
780 """Add missing headers based on IWYU-style analysis."""
785 if not missing_headers:
789 lines = source.read_text(encoding=
"utf-8").splitlines()
790 except UnicodeDecodeError:
797 while insert_idx < len(lines)
and lines[insert_idx].strip().startswith(
'#include'):
801 for header
in missing_headers:
802 lines.insert(insert_idx, f
'#include {header}')
806 rel = source.relative_to(PROJECT_ROOT)
807 print(f
"[DRY-RUN] Would add missing headers to {rel}: {', '.join(missing_headers)}")
810 source.write_text(
"\n".join(lines) +
"\n", encoding=
"utf-8")
811 print(f
"Added missing headers to {source.relative_to(PROJECT_ROOT)}: {', '.join(missing_headers)}")
816 """Collect all source files from the given configuration, respecting .gitignore patterns."""
817 managed_dirs: Set[Path] = set()
820 for directory
in block.directories:
821 managed_dirs.add(directory.path)
823 result: Set[Path] = set()
824 for directory
in managed_dirs:
825 if not directory.exists():
827 for file_path
in directory.rglob(
"*"):
828 if file_path.is_file()
and file_path.suffix
in SUPPORTED_EXTENSIONS:
830 result.add(file_path)
834def get_config(auto_discover: bool =
False) -> List[CMakeSourceBlock]:
835 """Get the full configuration, optionally including auto-discovered libraries."""
837 config = list(STATIC_CONFIG)
842 static_vars = {block.variable
for block
in STATIC_CONFIG}
844 for block
in discovered:
845 if block.variable
not in static_vars:
847 print(f
" Auto-discovered: {block.variable} in {block.cmake_path.name}")
852def run(dry_run: bool, cmake_only: bool, includes_only: bool, iwyu_mode: bool, auto_discover: bool) -> int:
853 if cmake_only
and includes_only:
854 raise ValueError(
"Cannot use --cmake-only and --includes-only together")
859 print(
"ā Loaded .gitignore patterns")
867 print(f
"ā Using {len(config)} library configurations (with auto-discovery)")
869 print(f
"ā Using {len(config)} library configurations")
871 if not includes_only:
872 print(
"\nš Updating CMake source lists...")
877 print(
"\nš Checking self-header includes...")
879 print(f
" Scanning {len(source_files)} source files")
881 for source
in source_files:
885 print(
"\nš Running IWYU-style header analysis...")
886 for source
in source_files:
889 if dry_run
and not changed:
890 print(
"\nā
No changes required (dry-run)")
891 elif not dry_run
and not changed:
892 print(
"\nā
No changes required")
894 print(
"\nā
Dry-run complete - use without --dry-run to apply changes")
896 print(
"\nā
All changes applied successfully")
902 parser = argparse.ArgumentParser(
903 description=
"Maintain CMake source lists and ensure proper header includes (IWYU-style).",
904 formatter_class=argparse.RawDescriptionHelpFormatter,
907 # Dry-run to see what would change:
910 # Auto-discover libraries and update CMake files:
911 %(prog)s --auto-discover
913 # Run IWYU-style header analysis:
916 # Update only CMake source lists:
917 %(prog)s --cmake-only
919 # Update only header includes:
920 %(prog)s --includes-only
923 parser.add_argument(
"--dry-run", action=
"store_true",
924 help=
"Report prospective changes without editing files")
925 parser.add_argument(
"--cmake-only", action=
"store_true",
926 help=
"Only update CMake source lists")
927 parser.add_argument(
"--includes-only", action=
"store_true",
928 help=
"Only ensure self-header includes")
929 parser.add_argument(
"--iwyu", action=
"store_true",
930 help=
"Run IWYU-style analysis to add missing headers")
931 parser.add_argument(
"--auto-discover", action=
"store_true",
932 help=
"Auto-discover CMake library files (*.cmake, *_library.cmake)")
933 args = parser.parse_args()
936 return run(args.dry_run, args.cmake_only, args.includes_only, args.iwyu, args.auto_discover)
937 except Exception
as exc:
939 print(f
"ā build_cleaner failed: {exc}")
941 traceback.print_exc()
945if __name__ ==
"__main__":
946 raise SystemExit(
main())
Iterable[Path] iter_files(self)
bool ensure_self_header_include(Path source, bool dry_run)
Set[str] extract_symbols(Path file_path)
List[str] gather_expected_sources(CMakeSourceBlock block, Any gitignore_spec=None)
Path relative_to_source(Path path)
bool has_include(Sequence[str] lines, Iterable[str] header_variants)
bool update_cmake_block(CMakeSourceBlock block, bool dry_run, Any gitignore_spec=None)
Optional[Path] find_self_header(Path source)
List[str] find_conditional_blocks_after(List[str] cmake_lines, int end_idx, str variable)
Optional[str] parse_entry(str line)
List[CMakeSourceBlock] get_config(bool auto_discover=False)
bool is_ignored(Path path, gitignore_spec)
Set[str] extract_includes(Path file_path)
int parse_block(List[str] lines, int start_idx)
bool add_missing_headers(Path source, bool dry_run, bool iwyu_mode)
bool should_ignore_path(Path path)
List[str] find_missing_headers(Path source)
List[CMakeSourceBlock] discover_cmake_libraries()
Set[str] extract_conditional_files(Path cmake_path, str variable)
int run(bool dry_run, bool cmake_only, bool includes_only, bool iwyu_mode, bool auto_discover)
Set[Path] collect_source_files(List[CMakeSourceBlock] config, Any gitignore_spec=None)
int find_insert_index(List[str] lines)