1# Copyright 2022 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14"""Evaluates target expressions within a GN build context.""" 15 16import argparse 17from dataclasses import dataclass 18import enum 19import logging 20import os 21import re 22import sys 23from pathlib import Path 24from typing import ( 25 Callable, 26 Dict, 27 List, 28 Iterable, 29 Iterator, 30 NamedTuple, 31 Optional, 32 Tuple, 33) 34 35_LOG = logging.getLogger(__name__) 36 37 38def abspath(path: Path) -> Path: 39 """Turns a path into an absolute path, not resolving symlinks.""" 40 return Path(os.path.abspath(path)) 41 42 43class GnPaths(NamedTuple): 44 """The set of paths needed to resolve GN paths to filesystem paths.""" 45 46 root: Path 47 build: Path 48 cwd: Path 49 50 # Toolchain label or '' if using the default toolchain 51 toolchain: str 52 53 def resolve(self, gn_path: str) -> Path: 54 """Resolves a GN path to a filesystem path.""" 55 if gn_path.startswith('//'): 56 return abspath(self.root.joinpath(gn_path.lstrip('/'))) 57 58 return abspath(self.cwd.joinpath(gn_path)) 59 60 def resolve_paths(self, gn_paths: str, sep: str = ';') -> str: 61 """Resolves GN paths to filesystem paths in a delimited string.""" 62 return sep.join(str(self.resolve(path)) for path in gn_paths.split(sep)) 63 64 65@dataclass(frozen=True) 66class Label: 67 """Represents a GN label.""" 68 69 name: str 70 dir: Path 71 relative_dir: Path 72 toolchain: Optional['Label'] 73 out_dir: Path 74 gen_dir: Path 75 76 def __init__(self, paths: GnPaths, label: str): 77 # Use this lambda to set attributes on this frozen dataclass. 78 set_attr = lambda attr, val: object.__setattr__(self, attr, val) 79 80 # Handle explicitly-specified toolchains 81 if label.endswith(')'): 82 label, toolchain = label[:-1].rsplit('(', 1) 83 else: 84 # Prevent infinite recursion for toolchains 85 toolchain = paths.toolchain if paths.toolchain != label else '' 86 87 set_attr('toolchain', Label(paths, toolchain) if toolchain else None) 88 89 # Split off the :target, if provided, or use the last part of the path. 90 try: 91 directory, name = label.rsplit(':', 1) 92 except ValueError: 93 directory, name = label, label.rsplit('/', 1)[-1] 94 95 set_attr('name', name) 96 97 # Resolve the directory to an absolute path 98 set_attr('dir', paths.resolve(directory)) 99 set_attr('relative_dir', self.dir.relative_to(abspath(paths.root))) 100 101 set_attr( 102 'out_dir', 103 paths.build / self.toolchain_name() / 'obj' / self.relative_dir, 104 ) 105 set_attr( 106 'gen_dir', 107 paths.build / self.toolchain_name() / 'gen' / self.relative_dir, 108 ) 109 110 def gn_label(self) -> str: 111 label = f'//{self.relative_dir.as_posix()}:{self.name}' 112 return f'{label}({self.toolchain!r})' if self.toolchain else label 113 114 def toolchain_name(self) -> str: 115 return self.toolchain.name if self.toolchain else '' 116 117 def __repr__(self) -> str: 118 return self.gn_label() 119 120 121class _Artifact(NamedTuple): 122 path: Path 123 variables: Dict[str, str] 124 125 126# Matches a non-phony build statement. 127_GN_NINJA_BUILD_STATEMENT = re.compile(r'^build (.+):[ \n](?!phony\b)') 128 129_OBJECTS_EXTENSIONS = ('.o',) 130 131# Extensions used for compilation artifacts. 132_MAIN_ARTIFACTS = '', '.elf', '.a', '.so', '.dylib', '.exe', '.lib', '.dll' 133 134 135def _get_artifact(entries: List[str]) -> _Artifact: 136 """Attempts to resolve which artifact to use if there are multiple. 137 138 Selects artifacts based on extension. This will not work if a toolchain 139 creates multiple compilation artifacts from one command (e.g. .a and .elf). 140 """ 141 assert entries, "There should be at least one entry here!" 142 143 if len(entries) == 1: 144 return _Artifact(Path(entries[0]), {}) 145 146 filtered = [p for p in entries if Path(p).suffix in _MAIN_ARTIFACTS] 147 148 if len(filtered) == 1: 149 return _Artifact(Path(filtered[0]), {}) 150 151 raise ExpressionError( 152 f'Expected 1, but found {len(filtered)} artifacts, after filtering for ' 153 f'extensions {", ".join(repr(e) for e in _MAIN_ARTIFACTS)}: {entries}' 154 ) 155 156 157def _parse_build_artifacts(fd) -> Iterator[_Artifact]: 158 """Partially parses the build statements in a Ninja file.""" 159 lines = iter(fd) 160 161 def next_line(): 162 try: 163 return next(lines) 164 except StopIteration: 165 return None 166 167 # Serves as the parse state (only two states) 168 artifact: Optional[_Artifact] = None 169 170 line = next_line() 171 172 while line is not None: 173 if artifact: 174 if line.startswith(' '): # build variable statements are indented 175 key, value = (a.strip() for a in line.split('=', 1)) 176 artifact.variables[key] = value 177 line = next_line() 178 else: 179 yield artifact 180 artifact = None 181 else: 182 match = _GN_NINJA_BUILD_STATEMENT.match(line) 183 if match: 184 artifact = _get_artifact(match.group(1).split()) 185 186 line = next_line() 187 188 if artifact: 189 yield artifact 190 191 192def _search_target_ninja( 193 ninja_file: Path, target: Label 194) -> Tuple[Optional[Path], List[Path]]: 195 """Parses the main output file and object files from <target>.ninja.""" 196 197 artifact: Optional[Path] = None 198 objects: List[Path] = [] 199 200 _LOG.debug('Parsing target Ninja file %s for %s', ninja_file, target) 201 202 with ninja_file.open() as fd: 203 for path, _ in _parse_build_artifacts(fd): 204 # Older GN used .stamp files when there is no build artifact. 205 if path.suffix == '.stamp': 206 continue 207 208 if str(path).endswith(_OBJECTS_EXTENSIONS): 209 objects.append(Path(path)) 210 else: 211 assert not artifact, f'Multiple artifacts for {target}!' 212 artifact = Path(path) 213 214 return artifact, objects 215 216 217def _search_toolchain_ninja( 218 ninja_file: Path, paths: GnPaths, target: Label 219) -> Optional[Path]: 220 """Searches the toolchain.ninja file for outputs from the provided target. 221 222 Files created by an action appear in toolchain.ninja instead of in their own 223 <target>.ninja. If the specified target has a single output file in 224 toolchain.ninja, this function returns its path. 225 """ 226 227 _LOG.debug('Searching toolchain Ninja file %s for %s', ninja_file, target) 228 229 # Older versions of GN used a .stamp file to signal completion of a target. 230 stamp_dir = target.out_dir.relative_to(paths.build).as_posix() 231 stamp_tool = 'stamp' 232 if target.toolchain_name() != '': 233 stamp_tool = f'{target.toolchain_name()}_stamp' 234 stamp_statement = f'build {stamp_dir}/{target.name}.stamp: {stamp_tool} ' 235 236 # Newer GN uses a phony Ninja target to signal completion of a target. 237 phony_dir = Path( 238 target.toolchain_name(), 'phony', target.relative_dir 239 ).as_posix() 240 phony_statement = f'build {phony_dir}/{target.name}: phony ' 241 242 with ninja_file.open() as fd: 243 for line in fd: 244 for statement in (phony_statement, stamp_statement): 245 if line.startswith(statement): 246 output_files = line[len(statement) :].strip().split() 247 if len(output_files) == 1: 248 return Path(output_files[0]) 249 250 break 251 252 return None 253 254 255def _search_ninja_files( 256 paths: GnPaths, target: Label 257) -> Tuple[bool, Optional[Path], List[Path]]: 258 ninja_file = target.out_dir / f'{target.name}.ninja' 259 if ninja_file.exists(): 260 return (True, *_search_target_ninja(ninja_file, target)) 261 262 ninja_file = paths.build / target.toolchain_name() / 'toolchain.ninja' 263 if ninja_file.exists(): 264 return True, _search_toolchain_ninja(ninja_file, paths, target), [] 265 266 return False, None, [] 267 268 269@dataclass(frozen=True) 270class TargetInfo: 271 """Provides information about a target parsed from a .ninja file.""" 272 273 label: Label 274 generated: bool # True if the Ninja files for this target were generated. 275 artifact: Optional[Path] 276 object_files: Tuple[Path] 277 278 def __init__(self, paths: GnPaths, target: str): 279 object.__setattr__(self, 'label', Label(paths, target)) 280 281 generated, artifact, objects = _search_ninja_files(paths, self.label) 282 283 object.__setattr__(self, 'generated', generated) 284 object.__setattr__(self, 'artifact', artifact) 285 object.__setattr__(self, 'object_files', tuple(objects)) 286 287 def __repr__(self) -> str: 288 return repr(self.label) 289 290 291class ExpressionError(Exception): 292 """An error occurred while parsing an expression.""" 293 294 295class _ArgAction(enum.Enum): 296 APPEND = 0 297 OMIT = 1 298 EMIT_NEW = 2 299 300 301class _Expression: 302 def __init__(self, match: re.Match, ending: int): 303 self._match = match 304 self._ending = ending 305 306 @property 307 def string(self): 308 return self._match.string 309 310 @property 311 def end(self) -> int: 312 return self._ending + len(_ENDING) 313 314 def contents(self) -> str: 315 return self.string[self._match.end() : self._ending] 316 317 def expression(self) -> str: 318 return self.string[self._match.start() : self.end] 319 320 321_Actions = Iterator[Tuple[_ArgAction, str]] 322 323 324def _target_file(paths: GnPaths, expr: _Expression) -> _Actions: 325 target = TargetInfo(paths, expr.contents()) 326 327 if not target.generated: 328 raise ExpressionError(f'Target {target} has not been generated by GN!') 329 330 if target.artifact is None: 331 raise ExpressionError(f'Target {target} has no output file!') 332 333 yield _ArgAction.APPEND, str(target.artifact) 334 335 336def _target_file_if_exists(paths: GnPaths, expr: _Expression) -> _Actions: 337 target = TargetInfo(paths, expr.contents()) 338 339 if target.generated: 340 if target.artifact is None: 341 raise ExpressionError(f'Target {target} has no output file!') 342 343 if paths.build.joinpath(target.artifact).exists(): 344 yield _ArgAction.APPEND, str(target.artifact) 345 return 346 347 yield _ArgAction.OMIT, '' 348 349 350def _target_objects(paths: GnPaths, expr: _Expression) -> _Actions: 351 if expr.expression() != expr.string: 352 raise ExpressionError( 353 f'The expression "{expr.expression()}" in "{expr.string}" may ' 354 'expand to multiple arguments, so it cannot be used alongside ' 355 'other text or expressions' 356 ) 357 358 target = TargetInfo(paths, expr.contents()) 359 if not target.generated: 360 raise ExpressionError(f'Target {target} has not been generated by GN!') 361 362 for obj in target.object_files: 363 yield _ArgAction.EMIT_NEW, str(obj) 364 365 366# TODO(b/234886742): Replace expressions with native GN features when possible. 367_FUNCTIONS: Dict['str', Callable[[GnPaths, _Expression], _Actions]] = { 368 'TARGET_FILE': _target_file, 369 'TARGET_FILE_IF_EXISTS': _target_file_if_exists, 370 'TARGET_OBJECTS': _target_objects, 371} 372 373_START_EXPRESSION = re.compile(fr'<({"|".join(_FUNCTIONS)})\(') 374_ENDING = ')>' 375 376 377def _expand_arguments(paths: GnPaths, string: str) -> _Actions: 378 pos = 0 379 380 for match in _START_EXPRESSION.finditer(string): 381 if pos != match.start(): 382 yield _ArgAction.APPEND, string[pos : match.start()] 383 384 ending = string.find(_ENDING, match.end()) 385 if ending == -1: 386 raise ExpressionError( 387 f'Parse error: no terminating "{_ENDING}" ' 388 f'was found for "{string[match.start():]}"' 389 ) 390 391 expression = _Expression(match, ending) 392 yield from _FUNCTIONS[match.group(1)](paths, expression) 393 394 pos = expression.end 395 396 if pos < len(string): 397 yield _ArgAction.APPEND, string[pos:] 398 399 400def expand_expressions(paths: GnPaths, arg: str) -> Iterable[str]: 401 """Expands <FUNCTION(...)> expressions; yields zero or more arguments.""" 402 if arg == '': 403 return [''] 404 405 expanded_args: List[List[str]] = [[]] 406 407 for action, piece in _expand_arguments(paths, arg): 408 if action is _ArgAction.OMIT: 409 return [] 410 411 expanded_args[-1].append(piece) 412 if action is _ArgAction.EMIT_NEW: 413 expanded_args.append([]) 414 415 return (''.join(arg) for arg in expanded_args if arg) 416 417 418def _parse_args() -> argparse.Namespace: 419 file_pair = lambda s: tuple(Path(p) for p in s.split(':')) 420 421 parser = argparse.ArgumentParser(description=__doc__) 422 parser.add_argument( 423 '--gn-root', 424 type=Path, 425 required=True, 426 help=( 427 'Path to the root of the GN tree; ' 428 'value of rebase_path("//", root_build_dir)' 429 ), 430 ) 431 parser.add_argument( 432 '--current-path', 433 type=Path, 434 required=True, 435 help='Value of rebase_path(".", root_build_dir)', 436 ) 437 parser.add_argument( 438 '--default-toolchain', required=True, help='Value of default_toolchain' 439 ) 440 parser.add_argument( 441 '--current-toolchain', required=True, help='Value of current_toolchain' 442 ) 443 parser.add_argument( 444 'files', 445 metavar='FILE', 446 nargs='+', 447 type=file_pair, 448 help='Pairs of src:dest files to scan for expressions to evaluate', 449 ) 450 return parser.parse_args() 451 452 453def _resolve_expressions_in_file(src: Path, dst: Path, paths: GnPaths): 454 dst.write_text(''.join(expand_expressions(paths, src.read_text()))) 455 456 457def main( 458 gn_root: Path, 459 current_path: Path, 460 default_toolchain: str, 461 current_toolchain: str, 462 files: Iterable[Tuple[Path, Path]], 463) -> int: 464 """Evaluates GN target expressions within a list of files. 465 466 Modifies the files in-place with their resolved contents. 467 """ 468 tool = current_toolchain if current_toolchain != default_toolchain else '' 469 paths = GnPaths( 470 root=abspath(gn_root), 471 build=Path.cwd(), 472 cwd=abspath(current_path), 473 toolchain=tool, 474 ) 475 476 for src, dst in files: 477 try: 478 _resolve_expressions_in_file(src, dst, paths) 479 except ExpressionError as err: 480 _LOG.error('Error evaluating expressions in %s:', src) 481 _LOG.error(' %s', err) 482 return 1 483 484 return 0 485 486 487if __name__ == '__main__': 488 sys.exit(main(**vars(_parse_args()))) 489