• 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
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