xref: /aosp_15_r20/external/pigweed/pw_presubmit/py/pw_presubmit/ninja_parser.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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
15# Inspired by
16# https://fuchsia.googlesource.com/infra/recipes/+/336933647862a1a9718b4ca18f0a67e89c2419f8/recipe_modules/ninja/resources/ninja_wrapper.py
17"""Extracts a concise error from a ninja log."""
18
19import argparse
20import logging
21from pathlib import Path
22import re
23from typing import IO
24import sys
25
26_LOG: logging.Logger = logging.getLogger(__name__)
27
28# Assume any of these lines could be prefixed with ANSI color codes.
29_COLOR_CODES_PREFIX = r'^(?:\x1b)?(?:\[\d+m\s*)?'
30
31_GOOGLETEST_FAILED, _GOOGLETEST_RUN, _GOOGLETEST_OK, _GOOGLETEST_DISABLED = (
32    '[  FAILED  ]',
33    '[ RUN      ]',
34    '[       OK ]',
35    '[ DISABLED ]',
36)
37
38
39def _remove_passing_tests(failure_lines: list[str]) -> list[str]:
40    test_lines: list[str] = []
41    result = []
42    for line in failure_lines:
43        if test_lines:
44            if _GOOGLETEST_OK in line:
45                test_lines = []
46            elif _GOOGLETEST_FAILED in line:
47                result.extend(test_lines)
48                test_lines = []
49                result.append(line)
50            else:
51                test_lines.append(line)
52        elif _GOOGLETEST_RUN in line:
53            test_lines.append(line)
54        elif _GOOGLETEST_DISABLED in line:
55            pass
56        else:
57            result.append(line)
58    result.extend(test_lines)
59    return result
60
61
62def _parse_ninja(ins: IO) -> str:
63    failure_lines: list[str] = []
64    last_line: str = ''
65
66    for line in ins:
67        _LOG.debug('processing %r', line)
68        # Trailing whitespace isn't significant, as it doesn't affect the
69        # way the line shows up in the logs. However, leading whitespace may
70        # be significant, especially for compiler error messages.
71        line = line.rstrip()
72
73        if failure_lines:
74            _LOG.debug('inside failure block')
75
76            if re.match(_COLOR_CODES_PREFIX + r'\[\d+/\d+\] (\S+)', line):
77                _LOG.debug('next rule started, ending failure block')
78                break
79
80            if re.match(_COLOR_CODES_PREFIX + r'ninja: build stopped:.*', line):
81                _LOG.debug('ninja build stopped, ending failure block')
82                break
83            failure_lines.append(line)
84
85        else:
86            if re.match(_COLOR_CODES_PREFIX + r'FAILED: (.*)$', line):
87                _LOG.debug('starting failure block')
88                failure_lines.extend([last_line, line])
89            elif 'FAILED' in line:
90                _LOG.debug('not a match')
91        last_line = line
92
93    failure_lines = _remove_passing_tests(failure_lines)
94
95    # Remove "Requirement already satisfied:" lines since many of those might
96    # be printed during Python installation, and they usually have no relevance
97    # to the actual error.
98    failure_lines = [
99        x
100        for x in failure_lines
101        if not x.lstrip().startswith('Requirement already satisfied:')
102    ]
103
104    # Trim paths so the output is less likely to wrap. Specifically, if the
105    # path has six or more segments trim it down to three.
106    path_re = re.compile(
107        r'(?P<prefix>\b|\s)'
108        r'[-\w._]+/[-\w._]+(?:/[-\w._]+)+'
109        r'(?P<core>/[-\w._]+/[-\w._]+/[-\w._]+)'
110        r'(?P<suffix>\b|\s)'
111    )
112
113    def replace(m: re.Match):
114        return ''.join(
115            (m.group('prefix'), '[...]', m.group('core'), m.group('suffix'))
116        )
117
118    failure_lines = [path_re.sub(replace, x) for x in failure_lines]
119
120    result: str = '\n'.join(failure_lines)
121    return re.sub(r'\n+', '\n', result)
122
123
124def parse_ninja_stdout(ninja_stdout: Path) -> str:
125    """Extract an error summary from ninja output."""
126    with ninja_stdout.open() as ins:
127        return _parse_ninja(ins)
128
129
130def main(argv=None):
131    parser = argparse.ArgumentParser()
132    parser.add_argument('input', type=argparse.FileType('r'))
133    parser.add_argument('output', type=argparse.FileType('w'))
134    args = parser.parse_args(argv)
135
136    for line in _parse_ninja(args.input):
137        args.output.write(line)
138
139
140if __name__ == '__main__':
141    sys.exit(main())
142