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