xref: /aosp_15_r20/external/pigweed/pw_compilation_testing/py/pw_compilation_testing/generator.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"""Generates compile fail test GN targets.
15
16Scans source files for PW_NC_TEST(...) statements and generates a
17BUILD.gn file with a target for each test. This allows the compilation failure
18tests to run in parallel in Ninja.
19
20This file is executed during gn gen, so it cannot rely on any setup that occurs
21during the build.
22"""
23
24from __future__ import annotations
25
26import argparse
27import base64
28from collections import defaultdict
29from dataclasses import dataclass
30from enum import Enum
31from pathlib import Path
32import pickle
33import re
34import sys
35from typing import (
36    Iterable,
37    Iterator,
38    NamedTuple,
39    NoReturn,
40    Pattern,
41    Sequence,
42    Set,
43)
44
45# Matches the #if or #elif statement that starts a compile fail test.
46_TEST_START = re.compile(r'^[ \t]*#[ \t]*(?:el)?if[ \t]+PW_NC_TEST\([ \t]*')
47
48# Matches the name of a test case.
49_TEST_NAME = re.compile(
50    r'(?P<name>[a-zA-Z0-9_]+)[ \t]*\)[ \t]*(?://.*|/\*.*)?$'
51)
52
53# Negative compilation test commands take the form PW_NC_EXPECT("regex"),
54# PW_NC_EXPECT_GCC("regex"), or PW_NC_EXPECT_CLANG("regex"). PW_NC_EXPECT() is
55# an error.
56_EXPECT_START = re.compile(r'^[ \t]*PW_NC_EXPECT(?P<compiler>_GCC|_CLANG)?\(')
57
58# EXPECT statements are regular expressions that must match the compiler output.
59# They must fit on a single line.
60_EXPECT_REGEX = re.compile(r'(?P<regex>"[^\n]+")\);[ \t]*(?://.*|/\*.*)?$')
61
62
63class Compiler(Enum):
64    ANY = 0
65    GCC = 1
66    CLANG = 2
67
68    @staticmethod
69    def from_command(command: str) -> Compiler:
70        if command.endswith(('clang', 'clang++')):
71            return Compiler.CLANG
72
73        if command.endswith(('gcc', 'g++')):
74            return Compiler.GCC
75
76        raise ValueError(
77            f"Unrecognized compiler '{command}'; update the Compiler enum "
78            f'in {Path(__file__).name} to account for this'
79        )
80
81    def matches(self, other: Compiler) -> bool:
82        return self is other or self is Compiler.ANY or other is Compiler.ANY
83
84
85@dataclass(frozen=True)
86class Expectation:
87    compiler: Compiler
88    pattern: Pattern[str]
89    line: int
90
91
92@dataclass(frozen=True)
93class TestCase:
94    suite: str
95    case: str
96    expectations: tuple[Expectation, ...]
97    source: Path
98    line: int
99
100    def name(self) -> str:
101        return f'{self.suite}.{self.case}'
102
103    def serialize(self) -> str:
104        return base64.b64encode(pickle.dumps(self)).decode()
105
106    @classmethod
107    def deserialize(cls, serialized: str) -> Expectation:
108        return pickle.loads(base64.b64decode(serialized))
109
110
111class ParseError(Exception):
112    """Failed to parse a PW_NC_TEST."""
113
114    def __init__(
115        self,
116        message: str,
117        file: Path,
118        lines: Sequence[str],
119        error_lines: Sequence[int],
120    ) -> None:
121        for i in error_lines:
122            message += f'\n{file.name}:{i + 1}: {lines[i]}'
123        super().__init__(message)
124
125
126class _ExpectationParser:
127    """Parses expecatations from 'PW_NC_EXPECT(' to the final ');'."""
128
129    class _State:
130        SPACE = 0  # Space characters, which are ignored
131        COMMENT_START = 1  # First / in a //-style comment
132        COMMENT = 2  # Everything after // on a line
133        OPEN_QUOTE = 3  # Starting quote for a string literal
134        CHARACTERS = 4  # Characters within a string literal
135        ESCAPE = 5  # \ within a string literal, which may escape a "
136        CLOSE_PAREN = 6  # Closing parenthesis to the PW_NC_EXPECT statement.
137
138    def __init__(self, index: int, compiler: Compiler) -> None:
139        self.index = index
140        self._compiler = compiler
141        self._state = self._State.SPACE
142        self._contents: list[str] = []
143
144    def parse(self, chars: str) -> Expectation | None:
145        """State machine that parses characters in PW_NC_EXPECT()."""
146        for char in chars:
147            if self._state is self._State.SPACE:
148                if char == '"':
149                    self._state = self._State.CHARACTERS
150                elif char == ')':
151                    self._state = self._State.CLOSE_PAREN
152                elif char == '/':
153                    self._state = self._State.COMMENT_START
154                elif not char.isspace():
155                    raise ValueError(f'Unexpected character "{char}"')
156            elif self._state is self._State.COMMENT_START:
157                if char == '*':
158                    raise ValueError(
159                        '"/* */" comments are not supported; use // instead'
160                    )
161                if char != '/':
162                    raise ValueError(f'Unexpected character "{char}"')
163                self._state = self._State.COMMENT
164            elif self._state is self._State.COMMENT:
165                if char == '\n':
166                    self._state = self._State.SPACE
167            elif self._state is self._State.CHARACTERS:
168                if char == '"':
169                    self._state = self._State.SPACE
170                elif char == '\\':
171                    self._state = self._State.ESCAPE
172                else:
173                    self._contents.append(char)
174            elif self._state is self._State.ESCAPE:
175                # Include escaped " directly. Restore the \ for other chars.
176                if char != '"':
177                    self._contents.append('\\')
178                self._contents.append(char)
179                self._state = self._State.CHARACTERS
180            elif self._state is self._State.CLOSE_PAREN:
181                if char != ';':
182                    raise ValueError(f'Expected ";", found "{char}"')
183
184                return self._expectation(''.join(self._contents))
185
186        return None
187
188    def _expectation(self, regex: str) -> Expectation:
189        if '"""' in regex:
190            raise ValueError('The regular expression cannot contain """')
191
192        # Evaluate the string from the C++ source as a raw literal.
193        re_string = eval(f'r"""{regex}"""')  # pylint: disable=eval-used
194        if not isinstance(re_string, str):
195            raise ValueError('The regular expression must be a string!')
196
197        try:
198            return Expectation(
199                self._compiler, re.compile(re_string), self.index + 1
200            )
201        except re.error as error:
202            raise ValueError('Invalid regular expression: ' + error.msg)
203
204
205class _NegativeCompilationTestSource:
206    def __init__(self, file: Path) -> None:
207        self._file = file
208        self._lines = self._file.read_text().splitlines(keepends=True)
209
210        self._parsed_expectations: Set[int] = set()
211
212    def _error(self, message: str, *error_lines: int) -> NoReturn:
213        raise ParseError(message, self._file, self._lines, error_lines)
214
215    def _parse_expectations(self, start: int) -> Iterator[Expectation]:
216        expectation: _ExpectationParser | None = None
217
218        for index in range(start, len(self._lines)):
219            line = self._lines[index]
220
221            # Skip empty or comment lines
222            if not line or line.isspace() or line.lstrip().startswith('//'):
223                continue
224
225            # Look for a 'PW_NC_EXPECT(' in the code.
226            if not expectation:
227                expect_match = _EXPECT_START.match(line)
228                if not expect_match:
229                    break  # No expectation found, stop processing.
230
231                compiler = expect_match['compiler'] or 'ANY'
232                expectation = _ExpectationParser(
233                    index, Compiler[compiler.lstrip('_')]
234                )
235
236                self._parsed_expectations.add(index)
237
238                # Remove the 'PW_NC_EXPECT(' so the line starts with the regex.
239                line = line[expect_match.end() :]
240
241            # Find the regex after previously finding 'PW_NC_EXPECT('.
242            try:
243                if parsed_expectation := expectation.parse(line.lstrip()):
244                    yield parsed_expectation
245
246                    expectation = None
247            except ValueError as err:
248                self._error(
249                    f'Failed to parse PW_NC_EXPECT() statement:\n\n  {err}.\n\n'
250                    'PW_NC_EXPECT() statements must contain only a string '
251                    'literal with a valid Python regular expression and '
252                    'optional //-style comments.',
253                    index,
254                )
255
256        if expectation:
257            self._error(
258                'Unterminated PW_NC_EXPECT() statement!', expectation.index
259            )
260
261    def _check_for_stray_expectations(self) -> None:
262        all_expectations = frozenset(
263            i
264            for i in range(len(self._lines))
265            if _EXPECT_START.match(self._lines[i])
266        )
267        stray = all_expectations - self._parsed_expectations
268        if stray:
269            self._error(
270                f'Found {len(stray)} stray PW_NC_EXPECT() statements! '
271                'PW_NC_EXPECT() statements must follow immediately after a '
272                'PW_NC_TEST() declaration.',
273                *sorted(stray),
274            )
275
276    def parse(self, suite: str) -> Iterator[TestCase]:
277        """Finds all negative compilation tests in this source file."""
278        for index, line in enumerate(self._lines):
279            case_match = _TEST_START.match(line)
280            if not case_match:
281                continue
282
283            name_match = _TEST_NAME.match(line, case_match.end())
284            if not name_match:
285                self._error(
286                    'Negative compilation test syntax error. '
287                    f"Expected test name, found '{line[case_match.end():]}'",
288                    index,
289                )
290
291            expectations = tuple(self._parse_expectations(index + 1))
292            yield TestCase(
293                suite, name_match['name'], expectations, self._file, index + 1
294            )
295
296        self._check_for_stray_expectations()
297
298
299def enumerate_tests(suite: str, paths: Iterable[Path]) -> Iterator[TestCase]:
300    """Parses PW_NC_TEST statements from a file."""
301    for path in paths:
302        yield from _NegativeCompilationTestSource(path).parse(suite)
303
304
305class SourceFile(NamedTuple):
306    gn_path: str
307    file_path: Path
308
309
310def generate_gn_target(
311    base: str, source_list: str, test: TestCase, all_tests: str
312) -> Iterator[str]:
313    yield f'''\
314pw_python_action("{test.name()}.negative_compilation_test") {{
315  script = "$dir_pw_compilation_testing/py/pw_compilation_testing/runner.py"
316  inputs = [{source_list}]
317  args = [
318    "--toolchain-ninja=$_toolchain_ninja",
319    "--target-ninja=$_target_ninja",
320    "--test-data={test.serialize()}",
321    "--all-tests={all_tests}",
322  ]
323  deps = ["{base}"]
324  python_deps = [
325    "$dir_pw_cli/py",
326    "$dir_pw_compilation_testing/py",
327  ]
328  stamp = true
329}}
330'''
331
332
333def generate_gn_build(
334    base: str,
335    sources: Iterable[SourceFile],
336    tests: list[TestCase],
337    all_tests: str,
338) -> Iterator[str]:
339    """Generates the BUILD.gn file with compilation failure test targets."""
340    _, base_name = base.rsplit(':', 1)
341
342    yield 'import("//build_overrides/pigweed.gni")'
343    yield ''
344    yield 'import("$dir_pw_build/python_action.gni")'
345    yield ''
346    yield (
347        '_toolchain_ninja = '
348        'rebase_path("$root_out_dir/toolchain.ninja", root_build_dir)'
349    )
350    yield (
351        '_target_ninja = '
352        f'rebase_path(get_label_info("{base}", "target_out_dir") +'
353        f'"/{base_name}.ninja", root_build_dir)'
354    )
355    yield ''
356
357    gn_source_list = ', '.join(f'"{gn_path}"' for gn_path, _ in sources)
358    for test in tests:
359        yield from generate_gn_target(base, gn_source_list, test, all_tests)
360
361
362def _main(
363    name: str, base: str, sources: Iterable[SourceFile], output: Path
364) -> int:
365    def print_stderr(s):
366        return print(s, file=sys.stderr)
367
368    try:
369        tests = list(enumerate_tests(name, (s.file_path for s in sources)))
370    except ParseError as error:
371        print_stderr(f'ERROR: {error}')
372        return 1
373
374    if not tests:
375        print_stderr(f'The test "{name}" has no negative compilation tests!')
376        print_stderr(
377            'Add PW_NC_TEST() cases or remove this negative ' 'compilation test'
378        )
379        return 1
380
381    tests_by_case = defaultdict(list)
382    for test in tests:
383        tests_by_case[test.case].append(test)
384
385    duplicates = [tests for tests in tests_by_case.values() if len(tests) > 1]
386    if duplicates:
387        print_stderr('There are duplicate negative compilation test cases!')
388        print_stderr('The following test cases appear more than once:')
389        for tests in duplicates:
390            print_stderr(f'\n    {tests[0].case} ({len(tests)} occurrences):')
391            for test in tests:
392                print_stderr(f'        {test.source.name}:{test.line}')
393        return 1
394
395    output.mkdir(parents=True, exist_ok=True)
396    build_gn = output.joinpath('BUILD.gn')
397    with build_gn.open('w') as fd:
398        for line in generate_gn_build(
399            base, sources, tests, output.joinpath('tests.txt').as_posix()
400        ):
401            print(line, file=fd)
402
403    with output.joinpath('tests.txt').open('w') as fd:
404        for test in tests:
405            print(test.case, file=fd)
406
407    # Print the test case names to stdout for consumption by GN.
408    for test in tests:
409        print(test.case)
410
411    return 0
412
413
414def _parse_args() -> dict:
415    """Parses command-line arguments."""
416
417    def source_file(arg: str) -> SourceFile:
418        gn_path, file_path = arg.split(';', 1)
419        return SourceFile(gn_path, Path(file_path))
420
421    parser = argparse.ArgumentParser(
422        description='Emits an error when a facade has a null backend'
423    )
424    parser.add_argument('--output', type=Path, help='Output directory')
425    parser.add_argument('--name', help='Name of the NC test')
426    parser.add_argument('--base', help='GN label for the base target to build')
427    parser.add_argument(
428        'sources',
429        nargs='+',
430        type=source_file,
431        help='Source file with the no-compile tests',
432    )
433    return vars(parser.parse_args())
434
435
436if __name__ == '__main__':
437    sys.exit(_main(**_parse_args()))
438