Source code for batools.pruneincludes
# 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
[docs]
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"'
}
[docs]
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))
# Docs-generation hack; import some stuff that we likely only forward-declared
# in our actual source code so that docs tools can find it.
from typing import (Coroutine, Any, Literal, Callable,
Generator, Awaitable, Sequence, Self)
import asyncio
from concurrent.futures import Future
from pathlib import Path
from enum import Enum