• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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