ballistica/tools/batools/pruneincludes.py

217 lines
7.8 KiB
Python
Executable File

# Released under the MIT License. See LICENSE for details.
#
"""Utility to scan for unnecessary includes in c++ files."""
from __future__ import annotations
import os
import json
import tempfile
from typing import TYPE_CHECKING
from dataclasses import dataclass
import subprocess
from efro.error import CleanError
from efro.terminal import Clr
from efro.dataclassio import dataclass_from_dict, ioprepped
if TYPE_CHECKING:
pass
@ioprepped
@dataclass
class _CompileCommandsEntry:
directory: str
command: str
file: str
class Pruner:
"""Wrangles a prune operation."""
def __init__(self, commit: bool, paths: list[str]) -> None:
self.commit = commit
self.paths = paths
# Files we're ok checking despite them containing #ifs.
self.ifdef_check_whitelist = {
'src/ballistica/shared/python/python.cc',
'src/ballistica/base/assets/assets.cc',
'src/ballistica/ui_v1/python/methods/python_methods_ui_v1.cc',
'src/ballistica/scene_v1/support/scene.cc',
'src/ballistica/scene_v1/support/scene_v1_app_mode.cc',
}
# Exact lines we never flag as removable.
self.line_whitelist = {
'#include "ballistica/mgen/pyembed/binding_ba.inc"'
}
def run(self) -> None:
"""Do the thing."""
cwd = os.getcwd()
if self.commit:
print(f'{Clr.MAG}{Clr.BLD}RUNNING IN COMMIT MODE!!!{Clr.RST}')
self._prep_paths()
entries = self._get_entries()
processed_paths = set[str]()
with tempfile.TemporaryDirectory() as tempdir:
for entry in entries:
# Entries list might have repeats.
if entry.file in processed_paths:
continue
processed_paths.add(entry.file)
if not entry.file.startswith(cwd):
raise CleanError(
f'compile-commands file {entry.file}'
f' does not start with cwd "{cwd}".'
)
relpath = entry.file.removeprefix(cwd + '/')
# Only process our stuff under the ballistica dir.
if not relpath.startswith('src/ballistica/'):
continue
# If we were given a list of paths, constrain to those.
if self.paths:
if os.path.abspath(entry.file) not in self.paths:
continue
# See what file the command will write so we can prep its dir.
splits = entry.command.split(' ')
outpath = splits[splits.index('-o') + 1]
outdir = os.path.dirname(outpath)
cmd = (
f'cd "{tempdir}" && mkdir -p "{outdir}" && {entry.command}'
)
self._check_file(relpath, cmd)
def _prep_paths(self) -> None:
# First off, make sure all our whitelist files still exist.
# This will be a nice reminder to keep the list updated with
# any changes.
for wpath in self.ifdef_check_whitelist:
if not os.path.isfile(wpath):
raise CleanError(
f"ifdef-check-whitelist entry does not exist: '{wpath}'."
)
# If we were given paths, make sure they exist and convert to absolute.
if self.paths:
for path in self.paths:
if not os.path.exists(path):
raise CleanError(f'path not found: "{path}"')
self.paths = [os.path.abspath(p) for p in self.paths]
def _get_entries(self) -> list[_CompileCommandsEntry]:
cmdspath = '.cache/compile_commands_db/compile_commands.json'
if not os.path.isfile(cmdspath):
raise CleanError(
f'Compile-commands not found at "{cmdspath}".'
f' do you have the irony build db enabled? (see Makefile)'
)
with open(cmdspath, encoding='utf-8') as infile:
cmdsraw = json.loads(infile.read())
if not isinstance(cmdsraw, list):
raise CleanError(
f'Expected list for compile-commands;'
f' found {type(cmdsraw)}.'
)
return [dataclass_from_dict(_CompileCommandsEntry, e) for e in cmdsraw]
def _check_file(self, path: str, cmd: str) -> None:
"""Run all checks on an individual file."""
# pylint: disable=too-many-locals
with open(path, encoding='utf-8') as infile:
orig_contents = infile.read()
orig_lines = orig_contents.splitlines(keepends=True)
# If there's any conditional compilation in there, skip. Code that
# isn't getting compiled by default could be using something from
# an include.
for i, line in enumerate(orig_lines):
if (
line.startswith('#if')
and path not in self.ifdef_check_whitelist
):
print(
f'Skipping {Clr.YLW}{path}{Clr.RST} due to line'
f' {i+1}: {line[:-1]}'
)
return
includelines: list[int] = []
for i, line in enumerate(orig_lines):
if line.startswith('#include "') and line.strip().endswith('.h"'):
includelines.append(i)
# Remove any includes of our associated header file.
# (we want to leave those in even if its technically not necessary).
bpath = path.removeprefix('src/')
our_header = '#include "' + os.path.splitext(bpath)[0] + '.h"\n'
includelines = [h for h in includelines if orig_lines[h] != our_header]
print(f'Processing {Clr.BLD}{Clr.BLU}{path}{Clr.RST}...')
working_lines = orig_lines
completed = False
# First run the compile unmodified just to be sure it works.
success = (
subprocess.run(
cmd, shell=True, check=False, capture_output=True
).returncode
== 0
)
if not success:
print(
f' {Clr.RED}{Clr.BLD}Initial test compile failed;'
f' something is probably wrong.{Clr.RST}'
)
try:
# Go through backwards because then removing a line doesn't
# invalidate our next lines to check.
for i, lineno in enumerate(reversed(includelines)):
test_lines = working_lines.copy()
print(f' Checking include {i+1} of {len(includelines)}...')
removed_line = test_lines.pop(lineno).removesuffix('\n')
with open(path, 'w', encoding='utf-8') as outfile:
outfile.write(''.join(test_lines))
success = (
subprocess.run(
cmd, shell=True, check=False, capture_output=True
).returncode
== 0
and removed_line not in self.line_whitelist
)
if success:
working_lines = test_lines
print(
f' {Clr.GRN}{Clr.BLD}Line {lineno+1}'
f' seems to be removable:{Clr.RST} {removed_line}'
)
completed = True
finally:
if not completed:
print(f' {Clr.RED}{Clr.BLD}Error processing file.{Clr.RST}')
# Restore original if we're not committing or something went wrong.
if not self.commit or not completed:
with open(path, 'w', encoding='utf-8') as outfile:
outfile.write(orig_contents)
# Otherwise restore the latest working version if committing.
elif self.commit:
with open(path, 'w', encoding='utf-8') as outfile:
outfile.write(''.join(working_lines))