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