# Released under the MIT License. See LICENSE for details.#"""Utility to scan for unnecessary includes in c++ files."""from__future__importannotationsimportosimportjsonimporttempfilefromtypingimportTYPE_CHECKINGfromdataclassesimportdataclassimportsubprocessfromefro.errorimportCleanErrorfromefro.terminalimportClrfromefro.dataclassioimportdataclass_from_dict,iopreppedifTYPE_CHECKING:pass@ioprepped@dataclassclass_CompileCommandsEntry:directory:strcommand:strfile:str
[docs]classPruner:"""Wrangles a prune operation."""def__init__(self,commit:bool,paths:list[str])->None:self.commit=commitself.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]defrun(self)->None:"""Do the thing."""cwd=os.getcwd()ifself.commit:print(f'{Clr.MAG}{Clr.BLD}RUNNING IN COMMIT MODE!!!{Clr.RST}')self._prep_paths()entries=self._get_entries()processed_paths=set[str]()withtempfile.TemporaryDirectory()astempdir:forentryinentries:# Entries list might have repeats.ifentry.fileinprocessed_paths:continueprocessed_paths.add(entry.file)ifnotentry.file.startswith(cwd):raiseCleanError(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.ifnotrelpath.startswith('src/ballistica/'):continue# If we were given a list of paths, constrain to those.ifself.paths:ifos.path.abspath(entry.file)notinself.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.forwpathinself.ifdef_check_whitelist:ifnotos.path.isfile(wpath):raiseCleanError(f"ifdef-check-whitelist entry does not exist: '{wpath}'.")# If we were given paths, make sure they exist and convert to absolute.ifself.paths:forpathinself.paths:ifnotos.path.exists(path):raiseCleanError(f'path not found: "{path}"')self.paths=[os.path.abspath(p)forpinself.paths]def_get_entries(self)->list[_CompileCommandsEntry]:cmdspath='.cache/compile_commands_db/compile_commands.json'ifnotos.path.isfile(cmdspath):raiseCleanError(f'Compile-commands not found at "{cmdspath}".'f' do you have the irony build db enabled? (see Makefile)')withopen(cmdspath,encoding='utf-8')asinfile:cmdsraw=json.loads(infile.read())ifnotisinstance(cmdsraw,list):raiseCleanError(f'Expected list for compile-commands;'f' found {type(cmdsraw)}.')return[dataclass_from_dict(_CompileCommandsEntry,e)foreincmdsraw]def_check_file(self,path:str,cmd:str)->None:"""Run all checks on an individual file."""# pylint: disable=too-many-localswithopen(path,encoding='utf-8')asinfile: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.fori,lineinenumerate(orig_lines):if(line.startswith('#if')andpathnotinself.ifdef_check_whitelist):print(f'Skipping {Clr.YLW}{path}{Clr.RST} due to line'f' {i+1}: {line[:-1]}')returnincludelines:list[int]=[]fori,lineinenumerate(orig_lines):ifline.startswith('#include "')andline.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=[hforhinincludelinesiforig_lines[h]!=our_header]print(f'Processing {Clr.BLD}{Clr.BLU}{path}{Clr.RST}...')working_lines=orig_linescompleted=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)ifnotsuccess: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.fori,linenoinenumerate(reversed(includelines)):test_lines=working_lines.copy()print(f' Checking include {i+1} of {len(includelines)}...')removed_line=test_lines.pop(lineno).removesuffix('\n')withopen(path,'w',encoding='utf-8')asoutfile:outfile.write(''.join(test_lines))success=(subprocess.run(cmd,shell=True,check=False,capture_output=True).returncode==0andremoved_linenotinself.line_whitelist)ifsuccess:working_lines=test_linesprint(f' {Clr.GRN}{Clr.BLD}Line {lineno+1}'f' seems to be removable:{Clr.RST}{removed_line}')completed=Truefinally:ifnotcompleted:print(f' {Clr.RED}{Clr.BLD}Error processing file.{Clr.RST}')# Restore original if we're not committing or something went wrong.ifnotself.commitornotcompleted:withopen(path,'w',encoding='utf-8')asoutfile:outfile.write(orig_contents)# Otherwise restore the latest working version if committing.elifself.commit:withopen(path,'w',encoding='utf-8')asoutfile:outfile.write(''.join(working_lines))