• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2020 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"""Functions for building code during presubmit checks."""
15
16import contextlib
17import itertools
18import json
19import logging
20import os
21from pathlib import Path
22import re
23import subprocess
24from shutil import which
25import sys
26from typing import (
27    Any,
28    Callable,
29    Collection,
30    Container,
31    ContextManager,
32    Dict,
33    Iterable,
34    List,
35    Mapping,
36    Optional,
37    Sequence,
38    Set,
39    Tuple,
40    Union,
41)
42
43from pw_presubmit import (
44    bazel_parser,
45    call,
46    Check,
47    FileFilter,
48    filter_paths,
49    format_code,
50    install_package,
51    Iterator,
52    log_run,
53    ninja_parser,
54    plural,
55    PresubmitContext,
56    PresubmitFailure,
57    PresubmitResult,
58    SubStep,
59    tools,
60)
61
62_LOG = logging.getLogger(__name__)
63
64
65def bazel(ctx: PresubmitContext, cmd: str, *args: str) -> None:
66    """Invokes Bazel with some common flags set.
67
68    Intended for use with bazel build and test. May not work with others.
69    """
70
71    num_jobs: List[str] = []
72    if ctx.num_jobs is not None:
73        num_jobs.extend(('--jobs', str(ctx.num_jobs)))
74
75    keep_going: List[str] = []
76    if ctx.continue_after_build_error:
77        keep_going.append('--keep_going')
78
79    bazel_stdout = ctx.output_dir / 'bazel.stdout'
80    try:
81        with bazel_stdout.open('w') as outs:
82            call(
83                'bazel',
84                cmd,
85                '--verbose_failures',
86                '--verbose_explanations',
87                '--worker_verbose',
88                f'--symlink_prefix={ctx.output_dir / ".bazel-"}',
89                *num_jobs,
90                *keep_going,
91                *args,
92                cwd=ctx.root,
93                env=env_with_clang_vars(),
94                tee=outs,
95            )
96
97    except PresubmitFailure as exc:
98        failure = bazel_parser.parse_bazel_stdout(bazel_stdout)
99        if failure:
100            with ctx.failure_summary_log.open('w') as outs:
101                outs.write(failure)
102
103        raise exc
104
105
106def _gn_value(value) -> str:
107    if isinstance(value, bool):
108        return str(value).lower()
109
110    if (
111        isinstance(value, str)
112        and '"' not in value
113        and not value.startswith("{")
114        and not value.startswith("[")
115    ):
116        return f'"{value}"'
117
118    if isinstance(value, (list, tuple)):
119        return f'[{", ".join(_gn_value(a) for a in value)}]'
120
121    # Fall-back case handles integers as well as strings that already
122    # contain double quotation marks, or look like scopes or lists.
123    return str(value)
124
125
126def gn_args(**kwargs) -> str:
127    """Builds a string to use for the --args argument to gn gen.
128
129    Currently supports bool, int, and str values. In the case of str values,
130    quotation marks will be added automatically, unless the string already
131    contains one or more double quotation marks, or starts with a { or [
132    character, in which case it will be passed through as-is.
133    """
134    transformed_args = []
135    for arg, val in kwargs.items():
136        transformed_args.append(f'{arg}={_gn_value(val)}')
137
138    # Use ccache if available for faster repeat presubmit runs.
139    if which('ccache'):
140        transformed_args.append('pw_command_launcher="ccache"')
141
142    return '--args=' + ' '.join(transformed_args)
143
144
145def gn_gen(
146    ctx: PresubmitContext,
147    *args: str,
148    gn_check: bool = True,
149    gn_fail_on_unused: bool = True,
150    export_compile_commands: Union[bool, str] = True,
151    preserve_args_gn: bool = False,
152    **gn_arguments,
153) -> None:
154    """Runs gn gen in the specified directory with optional GN args."""
155    all_gn_args = dict(gn_arguments)
156    all_gn_args.update(ctx.override_gn_args)
157    _LOG.debug('%r', all_gn_args)
158    args_option = gn_args(**all_gn_args)
159
160    if not preserve_args_gn:
161        # Delete args.gn to ensure this is a clean build.
162        args_gn = ctx.output_dir / 'args.gn'
163        if args_gn.is_file():
164            args_gn.unlink()
165
166    export_commands_arg = ''
167    if export_compile_commands:
168        export_commands_arg = '--export-compile-commands'
169        if isinstance(export_compile_commands, str):
170            export_commands_arg += f'={export_compile_commands}'
171
172    call(
173        'gn',
174        'gen',
175        ctx.output_dir,
176        '--color=always',
177        *(['--fail-on-unused-args'] if gn_fail_on_unused else []),
178        *([export_commands_arg] if export_commands_arg else []),
179        *args,
180        *([args_option] if all_gn_args else []),
181        cwd=ctx.root,
182    )
183
184    if gn_check:
185        call(
186            'gn',
187            'check',
188            ctx.output_dir,
189            '--check-generated',
190            '--check-system',
191            cwd=ctx.root,
192        )
193
194
195def ninja(
196    ctx: PresubmitContext, *args, save_compdb=True, save_graph=True, **kwargs
197) -> None:
198    """Runs ninja in the specified directory."""
199
200    num_jobs: List[str] = []
201    if ctx.num_jobs is not None:
202        num_jobs.extend(('-j', str(ctx.num_jobs)))
203
204    keep_going: List[str] = []
205    if ctx.continue_after_build_error:
206        keep_going.extend(('-k', '0'))
207
208    if save_compdb:
209        proc = subprocess.run(
210            ['ninja', '-C', ctx.output_dir, '-t', 'compdb', *args],
211            capture_output=True,
212            **kwargs,
213        )
214        (ctx.output_dir / 'ninja.compdb').write_bytes(proc.stdout)
215
216    if save_graph:
217        proc = subprocess.run(
218            ['ninja', '-C', ctx.output_dir, '-t', 'graph', *args],
219            capture_output=True,
220            **kwargs,
221        )
222        (ctx.output_dir / 'ninja.graph').write_bytes(proc.stdout)
223
224    ninja_stdout = ctx.output_dir / 'ninja.stdout'
225    try:
226        with ninja_stdout.open('w') as outs:
227            if sys.platform == 'win32':
228                # Windows doesn't support pw-wrap-ninja.
229                ninja_command = ['ninja']
230            else:
231                ninja_command = ['pw-wrap-ninja', '--log-actions']
232
233            call(
234                *ninja_command,
235                '-C',
236                ctx.output_dir,
237                *num_jobs,
238                *keep_going,
239                *args,
240                tee=outs,
241                propagate_sigterm=True,
242                **kwargs,
243            )
244
245    except PresubmitFailure as exc:
246        failure = ninja_parser.parse_ninja_stdout(ninja_stdout)
247        if failure:
248            with ctx.failure_summary_log.open('w') as outs:
249                outs.write(failure)
250
251        raise exc
252
253
254def get_gn_args(directory: Path) -> List[Dict[str, Dict[str, str]]]:
255    """Dumps GN variables to JSON."""
256    proc = subprocess.run(
257        ['gn', 'args', directory, '--list', '--json'], stdout=subprocess.PIPE
258    )
259    return json.loads(proc.stdout)
260
261
262def cmake(
263    ctx: PresubmitContext,
264    *args: str,
265    env: Optional[Mapping['str', 'str']] = None,
266) -> None:
267    """Runs CMake for Ninja on the given source and output directories."""
268    call(
269        'cmake',
270        '-B',
271        ctx.output_dir,
272        '-S',
273        ctx.root,
274        '-G',
275        'Ninja',
276        *args,
277        env=env,
278    )
279
280
281def env_with_clang_vars() -> Mapping[str, str]:
282    """Returns the environment variables with CC, CXX, etc. set for clang."""
283    env = os.environ.copy()
284    env['CC'] = env['LD'] = env['AS'] = 'clang'
285    env['CXX'] = 'clang++'
286    return env
287
288
289def _get_paths_from_command(source_dir: Path, *args, **kwargs) -> Set[Path]:
290    """Runs a command and reads Bazel or GN //-style paths from it."""
291    process = log_run(args, capture_output=True, cwd=source_dir, **kwargs)
292
293    if process.returncode:
294        _LOG.error(
295            'Build invocation failed with return code %d!', process.returncode
296        )
297        _LOG.error(
298            '[COMMAND] %s\n%s\n%s',
299            *tools.format_command(args, kwargs),
300            process.stderr.decode(),
301        )
302        raise PresubmitFailure
303
304    files = set()
305
306    for line in process.stdout.splitlines():
307        path = line.strip().lstrip(b'/').replace(b':', b'/').decode()
308        path = source_dir.joinpath(path)
309        if path.is_file():
310            files.add(path)
311
312    return files
313
314
315# Finds string literals with '.' in them.
316_MAYBE_A_PATH = re.compile(
317    r'"'  # Starting double quote.
318    # Start capture group 1 - the whole filename:
319    #   File basename, a single period, file extension.
320    r'([^\n" ]+\.[^\n" ]+)'
321    # Non-capturing group 2 (optional).
322    r'(?: > [^\n"]+)?'  # pw_zip style string "input_file.txt > output_file.txt"
323    r'"'  # Ending double quote.
324)
325
326
327def _search_files_for_paths(build_files: Iterable[Path]) -> Iterable[Path]:
328    for build_file in build_files:
329        directory = build_file.parent
330
331        for string in _MAYBE_A_PATH.finditer(build_file.read_text()):
332            path = directory / string.group(1)
333            if path.is_file():
334                yield path
335
336
337def _read_compile_commands(compile_commands: Path) -> dict:
338    with compile_commands.open('rb') as fd:
339        return json.load(fd)
340
341
342def compiled_files(compile_commands: Path) -> Iterable[Path]:
343    for command in _read_compile_commands(compile_commands):
344        file = Path(command['file'])
345        if file.is_absolute():
346            yield file
347        else:
348            yield file.joinpath(command['directory']).resolve()
349
350
351def check_compile_commands_for_files(
352    compile_commands: Union[Path, Iterable[Path]],
353    files: Iterable[Path],
354    extensions: Collection[str] = format_code.CPP_SOURCE_EXTS,
355) -> List[Path]:
356    """Checks for paths in one or more compile_commands.json files.
357
358    Only checks C and C++ source files by default.
359    """
360    if isinstance(compile_commands, Path):
361        compile_commands = [compile_commands]
362
363    compiled = frozenset(
364        itertools.chain.from_iterable(
365            compiled_files(cmds) for cmds in compile_commands
366        )
367    )
368    return [f for f in files if f not in compiled and f.suffix in extensions]
369
370
371def check_bazel_build_for_files(
372    bazel_extensions_to_check: Container[str],
373    files: Iterable[Path],
374    bazel_dirs: Iterable[Path] = (),
375) -> List[Path]:
376    """Checks that source files are in the Bazel builds.
377
378    Args:
379        bazel_extensions_to_check: which file suffixes to look for in Bazel
380        files: the files that should be checked
381        bazel_dirs: directories in which to run bazel query
382
383    Returns:
384        a list of missing files; will be empty if there were no missing files
385    """
386
387    # Collect all paths in the Bazel builds.
388    bazel_builds: Set[Path] = set()
389    for directory in bazel_dirs:
390        bazel_builds.update(
391            _get_paths_from_command(
392                directory, 'bazel', 'query', 'kind("source file", //...:*)'
393            )
394        )
395
396    missing: List[Path] = []
397
398    if bazel_dirs:
399        for path in (p for p in files if p.suffix in bazel_extensions_to_check):
400            if path not in bazel_builds:
401                # TODO(b/234883555) Replace this workaround for fuzzers.
402                if 'fuzz' not in str(path):
403                    missing.append(path)
404
405    if missing:
406        _LOG.warning(
407            '%s missing from the Bazel build:\n%s',
408            plural(missing, 'file', are=True),
409            '\n'.join(str(x) for x in missing),
410        )
411
412    return missing
413
414
415def check_gn_build_for_files(
416    gn_extensions_to_check: Container[str],
417    files: Iterable[Path],
418    gn_dirs: Iterable[Tuple[Path, Path]] = (),
419    gn_build_files: Iterable[Path] = (),
420) -> List[Path]:
421    """Checks that source files are in the GN build.
422
423    Args:
424        gn_extensions_to_check: which file suffixes to look for in GN
425        files: the files that should be checked
426        gn_dirs: (source_dir, output_dir) tuples with which to run gn desc
427        gn_build_files: paths to BUILD.gn files to directly search for paths
428
429    Returns:
430        a list of missing files; will be empty if there were no missing files
431    """
432
433    # Collect all paths in GN builds.
434    gn_builds: Set[Path] = set()
435
436    for source_dir, output_dir in gn_dirs:
437        gn_builds.update(
438            _get_paths_from_command(source_dir, 'gn', 'desc', output_dir, '*')
439        )
440
441    gn_builds.update(_search_files_for_paths(gn_build_files))
442
443    missing: List[Path] = []
444
445    if gn_dirs or gn_build_files:
446        for path in (p for p in files if p.suffix in gn_extensions_to_check):
447            if path not in gn_builds:
448                missing.append(path)
449
450    if missing:
451        _LOG.warning(
452            '%s missing from the GN build:\n%s',
453            plural(missing, 'file', are=True),
454            '\n'.join(str(x) for x in missing),
455        )
456
457    return missing
458
459
460def check_builds_for_files(
461    bazel_extensions_to_check: Container[str],
462    gn_extensions_to_check: Container[str],
463    files: Iterable[Path],
464    bazel_dirs: Iterable[Path] = (),
465    gn_dirs: Iterable[Tuple[Path, Path]] = (),
466    gn_build_files: Iterable[Path] = (),
467) -> Dict[str, List[Path]]:
468    """Checks that source files are in the GN and Bazel builds.
469
470    Args:
471        bazel_extensions_to_check: which file suffixes to look for in Bazel
472        gn_extensions_to_check: which file suffixes to look for in GN
473        files: the files that should be checked
474        bazel_dirs: directories in which to run bazel query
475        gn_dirs: (source_dir, output_dir) tuples with which to run gn desc
476        gn_build_files: paths to BUILD.gn files to directly search for paths
477
478    Returns:
479        a dictionary mapping build system ('Bazel' or 'GN' to a list of missing
480        files; will be empty if there were no missing files
481    """
482
483    bazel_missing = check_bazel_build_for_files(
484        bazel_extensions_to_check=bazel_extensions_to_check,
485        files=files,
486        bazel_dirs=bazel_dirs,
487    )
488    gn_missing = check_gn_build_for_files(
489        gn_extensions_to_check=gn_extensions_to_check,
490        files=files,
491        gn_dirs=gn_dirs,
492        gn_build_files=gn_build_files,
493    )
494
495    result = {}
496    if bazel_missing:
497        result['Bazel'] = bazel_missing
498    if gn_missing:
499        result['GN'] = gn_missing
500    return result
501
502
503@contextlib.contextmanager
504def test_server(executable: str, output_dir: Path):
505    """Context manager that runs a test server executable.
506
507    Args:
508        executable: name of the test server executable
509        output_dir: path to the output directory (for logs)
510    """
511
512    with open(output_dir / 'test_server.log', 'w') as outs:
513        try:
514            proc = subprocess.Popen(
515                [executable, '--verbose'],
516                stdout=outs,
517                stderr=subprocess.STDOUT,
518            )
519
520            yield
521
522        finally:
523            proc.terminate()  # pylint: disable=used-before-assignment
524
525
526@filter_paths(
527    file_filter=FileFilter(endswith=('.bzl', '.bazel'), name=('WORKSPACE',))
528)
529def bazel_lint(ctx: PresubmitContext):
530    """Runs buildifier with lint on Bazel files.
531
532    Should be run after bazel_format since that will give more useful output
533    for formatting-only issues.
534    """
535
536    failure = False
537    for path in ctx.paths:
538        try:
539            call('buildifier', '--lint=warn', '--mode=check', path)
540        except PresubmitFailure:
541            failure = True
542
543    if failure:
544        raise PresubmitFailure
545
546
547@Check
548def gn_gen_check(ctx: PresubmitContext):
549    """Runs gn gen --check to enforce correct header dependencies."""
550    gn_gen(ctx, gn_check=True)
551
552
553_CtxMgrLambda = Callable[[PresubmitContext], ContextManager]
554_CtxMgrOrLambda = Union[ContextManager, _CtxMgrLambda]
555
556
557class GnGenNinja(Check):
558    """Thin wrapper of Check for steps that just call gn/ninja."""
559
560    def __init__(
561        self,
562        *args,
563        packages: Sequence[str] = (),
564        gn_args: Optional[  # pylint: disable=redefined-outer-name
565            Dict[str, Any]
566        ] = None,
567        ninja_contexts: Sequence[_CtxMgrOrLambda] = (),
568        ninja_targets: Union[str, Sequence[str], Sequence[Sequence[str]]] = (),
569        **kwargs,
570    ):
571        """Initializes a GnGenNinja object.
572
573        Args:
574            *args: Passed on to superclass.
575            packages: List of 'pw package' packages to install.
576            gn_args: Dict of GN args.
577            ninja_contexts: List of context managers to apply around ninja
578                calls.
579            ninja_targets: Single ninja target, list of Ninja targets, or list
580                of list of ninja targets. If a list of a list, ninja will be
581                called multiple times with the same build directory.
582            **kwargs: Passed on to superclass.
583        """
584        super().__init__(self._substeps(), *args, **kwargs)
585        self.packages: Sequence[str] = packages
586        self.gn_args: Dict[str, Any] = gn_args or {}
587        self.ninja_contexts: Tuple[_CtxMgrOrLambda, ...] = tuple(ninja_contexts)
588
589        if isinstance(ninja_targets, str):
590            ninja_targets = (ninja_targets,)
591        ninja_targets = list(ninja_targets)
592        all_strings = all(isinstance(x, str) for x in ninja_targets)
593        any_strings = any(isinstance(x, str) for x in ninja_targets)
594        if ninja_targets and all_strings != any_strings:
595            raise ValueError(repr(ninja_targets))
596
597        self.ninja_target_lists: Tuple[Tuple[str, ...], ...]
598        if all_strings:
599            targets: List[str] = []
600            for target in ninja_targets:
601                targets.append(target)  # type: ignore
602            self.ninja_target_lists = (tuple(targets),)
603        else:
604            self.ninja_target_lists = tuple(tuple(x) for x in ninja_targets)
605
606    def _install_package(  # pylint: disable=no-self-use
607        self,
608        ctx: PresubmitContext,
609        package: str,
610    ) -> PresubmitResult:
611        install_package(ctx, package)
612        return PresubmitResult.PASS
613
614    def _gn_gen(self, ctx: PresubmitContext) -> PresubmitResult:
615        Item = Union[int, str]
616        Value = Union[Item, Sequence[Item]]
617        ValueCallable = Callable[[PresubmitContext], Value]
618        InputItem = Union[Item, ValueCallable]
619        InputValue = Union[InputItem, Sequence[InputItem]]
620
621        # TODO(mohrr) Use typing.TypeGuard instead of "type: ignore"
622
623        def value(val: InputValue) -> Value:
624            if isinstance(val, (str, int)):
625                return val
626            if callable(val):
627                return val(ctx)
628
629            result: List[Item] = []
630            for item in val:
631                if callable(item):
632                    call_result = item(ctx)
633                    if isinstance(item, (int, str)):
634                        result.append(call_result)
635                    else:  # Sequence.
636                        result.extend(call_result)  # type: ignore
637                elif isinstance(item, (int, str)):
638                    result.append(item)
639                else:  # Sequence.
640                    result.extend(item)
641            return result
642
643        args = {k: value(v) for k, v in self.gn_args.items()}
644        gn_gen(ctx, **args)  # type: ignore
645        return PresubmitResult.PASS
646
647    def _ninja(
648        self, ctx: PresubmitContext, targets: Sequence[str]
649    ) -> PresubmitResult:
650        with contextlib.ExitStack() as stack:
651            for mgr in self.ninja_contexts:
652                if isinstance(mgr, contextlib.AbstractContextManager):
653                    stack.enter_context(mgr)
654                else:
655                    stack.enter_context(mgr(ctx))  # type: ignore
656            ninja(ctx, *targets)
657        return PresubmitResult.PASS
658
659    def _substeps(self) -> Iterator[SubStep]:
660        for package in self.packages:
661            yield SubStep(
662                f'install {package} package',
663                self._install_package,
664                (package,),
665            )
666
667        yield SubStep('gn gen', self._gn_gen)
668
669        targets_parts = set()
670        for targets in self.ninja_target_lists:
671            targets_part = " ".join(targets)
672            maxlen = 70
673            if len(targets_part) > maxlen:
674                targets_part = f'{targets_part[0:maxlen-3]}...'
675            assert targets_part not in targets_parts
676            targets_parts.add(targets_part)
677            yield SubStep(f'ninja {targets_part}', self._ninja, (targets,))
678