| # Copyright 2022 The Pigweed Authors |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); you may not |
| # use this file except in compliance with the License. You may obtain a copy of |
| # the License at |
| # |
| # https://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| # License for the specific language governing permissions and limitations under |
| # the License. |
| """Generates compile fail test GN targets. |
| |
| Scans source files for PW_NC_TEST(...) statements and generates a |
| BUILD.gn file with a target for each test. This allows the compilation failure |
| tests to run in parallel in Ninja. |
| |
| This file is executed during gn gen, so it cannot rely on any setup that occurs |
| during the build. |
| """ |
| |
| import argparse |
| import base64 |
| from collections import defaultdict |
| from dataclasses import dataclass |
| from enum import Enum |
| from pathlib import Path |
| import pickle |
| import re |
| import sys |
| from typing import (Iterable, Iterator, List, NamedTuple, NoReturn, Optional, |
| Pattern, Sequence, Set, Tuple) |
| |
| # Matches the #if or #elif statement that starts a compile fail test. |
| _TEST_START = re.compile(r'^[ \t]*#[ \t]*(?:el)?if[ \t]+PW_NC_TEST\([ \t]*') |
| |
| # Matches the name of a test case. |
| _TEST_NAME = re.compile( |
| r'(?P<name>[a-zA-Z0-9_]+)[ \t]*\)[ \t]*(?://.*|/\*.*)?$') |
| |
| # Negative compilation test commands take the form PW_NC_EXPECT("regex"), |
| # PW_NC_EXPECT_GCC("regex"), or PW_NC_EXPECT_CLANG("regex"). PW_NC_EXPECT() is |
| # an error. |
| _EXPECT_START = re.compile(r'^[ \t]*PW_NC_EXPECT(?P<compiler>_GCC|_CLANG)?\(') |
| |
| # EXPECT statements are regular expressions that must match the compiler output. |
| # They must fit on a single line. |
| _EXPECT_REGEX = re.compile(r'(?P<regex>"[^\n]+")\);[ \t]*(?://.*|/\*.*)?$') |
| |
| |
| class Compiler(Enum): |
| ANY = 0 |
| GCC = 1 |
| CLANG = 2 |
| |
| @staticmethod |
| def from_command(command: str) -> 'Compiler': |
| if command.endswith(('clang', 'clang++')): |
| return Compiler.CLANG |
| |
| if command.endswith(('gcc', 'g++')): |
| return Compiler.GCC |
| |
| raise ValueError( |
| f"Unrecognized compiler '{command}'; update the Compiler enum " |
| f'in {Path(__file__).name} to account for this') |
| |
| def matches(self, other: 'Compiler') -> bool: |
| return self is other or self is Compiler.ANY or other is Compiler.ANY |
| |
| |
| @dataclass(frozen=True) |
| class Expectation: |
| compiler: Compiler |
| pattern: Pattern[str] |
| line: int |
| |
| |
| @dataclass(frozen=True) |
| class TestCase: |
| suite: str |
| case: str |
| expectations: Tuple[Expectation, ...] |
| source: Path |
| line: int |
| |
| def name(self) -> str: |
| return f'{self.suite}.{self.case}' |
| |
| def serialize(self) -> str: |
| return base64.b64encode(pickle.dumps(self)).decode() |
| |
| @classmethod |
| def deserialize(cls, serialized: str) -> 'Expectation': |
| return pickle.loads(base64.b64decode(serialized)) |
| |
| |
| class ParseError(Exception): |
| """Failed to parse a PW_NC_TEST.""" |
| def __init__(self, message: str, file: Path, lines: Sequence[str], |
| error_lines: Sequence[int]) -> None: |
| for i in error_lines: |
| message += f'\n{file.name}:{i + 1}: {lines[i]}' |
| super().__init__(message) |
| |
| |
| class _ExpectationParser: |
| """Parses expecatations from 'PW_NC_EXPECT(' to the final ');'.""" |
| class _State: |
| SPACE = 0 # Space characters, which are ignored |
| COMMENT_START = 1 # First / in a //-style comment |
| COMMENT = 2 # Everything after // on a line |
| OPEN_QUOTE = 3 # Starting quote for a string literal |
| CHARACTERS = 4 # Characters within a string literal |
| ESCAPE = 5 # \ within a string literal, which may escape a " |
| CLOSE_PAREN = 6 # Closing parenthesis to the PW_NC_EXPECT statement. |
| |
| def __init__(self, index: int, compiler: Compiler) -> None: |
| self.index = index |
| self._compiler = compiler |
| self._state = self._State.SPACE |
| self._contents: List[str] = [] |
| |
| def parse(self, chars: str) -> Optional[Expectation]: |
| """State machine that parses characters in PW_NC_EXPECT().""" |
| for char in chars: |
| if self._state is self._State.SPACE: |
| if char == '"': |
| self._state = self._State.CHARACTERS |
| elif char == ')': |
| self._state = self._State.CLOSE_PAREN |
| elif char == '/': |
| self._state = self._State.COMMENT_START |
| elif not char.isspace(): |
| raise ValueError(f'Unexpected character "{char}"') |
| elif self._state is self._State.COMMENT_START: |
| if char == '*': |
| raise ValueError( |
| '"/* */" comments are not supported; use // instead') |
| if char != '/': |
| raise ValueError(f'Unexpected character "{char}"') |
| self._state = self._State.COMMENT |
| elif self._state is self._State.COMMENT: |
| if char == '\n': |
| self._state = self._State.SPACE |
| elif self._state is self._State.CHARACTERS: |
| if char == '"': |
| self._state = self._State.SPACE |
| elif char == '\\': |
| self._state = self._State.ESCAPE |
| else: |
| self._contents.append(char) |
| elif self._state is self._State.ESCAPE: |
| # Include escaped " directly. Restore the \ for other chars. |
| if char != '"': |
| self._contents.append('\\') |
| self._contents.append(char) |
| self._state = self._State.CHARACTERS |
| elif self._state is self._State.CLOSE_PAREN: |
| if char != ';': |
| raise ValueError(f'Expected ";", found "{char}"') |
| |
| return self._expectation(''.join(self._contents)) |
| |
| return None |
| |
| def _expectation(self, regex: str) -> Expectation: |
| if '"""' in regex: |
| raise ValueError('The regular expression cannot contain """') |
| |
| # Evaluate the string from the C++ source as a raw literal. |
| re_string = eval(f'r"""{regex}"""') # pylint: disable=eval-used |
| if not isinstance(re_string, str): |
| raise ValueError('The regular expression must be a string!') |
| |
| try: |
| return Expectation(self._compiler, re.compile(re_string), |
| self.index + 1) |
| except re.error as error: |
| raise ValueError('Invalid regular expression: ' + error.msg) |
| |
| |
| class _NegativeCompilationTestSource: |
| def __init__(self, file: Path) -> None: |
| self._file = file |
| self._lines = self._file.read_text().splitlines(keepends=True) |
| |
| self._parsed_expectations: Set[int] = set() |
| |
| def _error(self, message: str, *error_lines: int) -> NoReturn: |
| raise ParseError(message, self._file, self._lines, error_lines) |
| |
| def _parse_expectations(self, start: int) -> Iterator[Expectation]: |
| expectation: Optional[_ExpectationParser] = None |
| |
| for index in range(start, len(self._lines)): |
| line = self._lines[index] |
| |
| # Skip empty or comment lines |
| if not line or line.isspace() or line.lstrip().startswith('//'): |
| continue |
| |
| # Look for a 'PW_NC_EXPECT(' in the code. |
| if not expectation: |
| expect_match = _EXPECT_START.match(line) |
| if not expect_match: |
| break # No expectation found, stop processing. |
| |
| compiler = expect_match['compiler'] or 'ANY' |
| expectation = _ExpectationParser( |
| index, Compiler[compiler.lstrip('_')]) |
| |
| self._parsed_expectations.add(index) |
| |
| # Remove the 'PW_NC_EXPECT(' so the line starts with the regex. |
| line = line[expect_match.end():] |
| |
| # Find the regex after previously finding 'PW_NC_EXPECT('. |
| try: |
| if parsed_expectation := expectation.parse(line.lstrip()): |
| yield parsed_expectation |
| |
| expectation = None |
| except ValueError as err: |
| self._error( |
| f'Failed to parse PW_NC_EXPECT() statement:\n\n {err}.\n\n' |
| 'PW_NC_EXPECT() statements must contain only a string ' |
| 'literal with a valid Python regular expression and ' |
| 'optional //-style comments.', index) |
| |
| if expectation: |
| self._error('Unterminated PW_NC_EXPECT() statement!', |
| expectation.index) |
| |
| def _check_for_stray_expectations(self) -> None: |
| all_expectations = frozenset(i for i in range(len(self._lines)) |
| if _EXPECT_START.match(self._lines[i])) |
| stray = all_expectations - self._parsed_expectations |
| if stray: |
| self._error(f'Found {len(stray)} stray PW_NC_EXPECT() commands!', |
| *sorted(stray)) |
| |
| def parse(self, suite: str) -> Iterator[TestCase]: |
| """Finds all negative compilation tests in this source file.""" |
| for index, line in enumerate(self._lines): |
| case_match = _TEST_START.match(line) |
| if not case_match: |
| continue |
| |
| name_match = _TEST_NAME.match(line, case_match.end()) |
| if not name_match: |
| self._error( |
| 'Negative compilation test syntax error. ' |
| f"Expected test name, found '{line[case_match.end():]}'", |
| index) |
| |
| expectations = tuple(self._parse_expectations(index + 1)) |
| yield TestCase(suite, name_match['name'], expectations, self._file, |
| index + 1) |
| |
| self._check_for_stray_expectations() |
| |
| |
| def enumerate_tests(suite: str, paths: Iterable[Path]) -> Iterator[TestCase]: |
| """Parses PW_NC_TEST statements from a file.""" |
| for path in paths: |
| yield from _NegativeCompilationTestSource(path).parse(suite) |
| |
| |
| class SourceFile(NamedTuple): |
| gn_path: str |
| file_path: Path |
| |
| |
| def generate_gn_target(base: str, source_list: str, test: TestCase, |
| all_tests: str) -> Iterator[str]: |
| yield f'''\ |
| pw_python_action("{test.name()}.negative_compilation_test") {{ |
| script = "$dir_pw_compilation_testing/py/pw_compilation_testing/runner.py" |
| inputs = [{source_list}] |
| args = [ |
| "--toolchain-ninja=$_toolchain_ninja", |
| "--target-ninja=$_target_ninja", |
| "--test-data={test.serialize()}", |
| "--all-tests={all_tests}", |
| ] |
| deps = ["{base}"] |
| python_deps = [ |
| "$dir_pw_cli/py", |
| "$dir_pw_compilation_testing/py", |
| ] |
| stamp = true |
| }} |
| ''' |
| |
| |
| def generate_gn_build(base: str, sources: Iterable[SourceFile], |
| tests: List[TestCase], all_tests: str) -> Iterator[str]: |
| """Generates the BUILD.gn file with compilation failure test targets.""" |
| _, base_name = base.rsplit(':', 1) |
| |
| yield 'import("//build_overrides/pigweed.gni")' |
| yield '' |
| yield 'import("$dir_pw_build/python_action.gni")' |
| yield '' |
| yield ('_toolchain_ninja = ' |
| 'rebase_path("$root_out_dir/toolchain.ninja", root_build_dir)') |
| yield ('_target_ninja = ' |
| f'rebase_path(get_label_info("{base}", "target_out_dir") +' |
| f'"/{base_name}.ninja", root_build_dir)') |
| yield '' |
| |
| gn_source_list = ', '.join(f'"{gn_path}"' for gn_path, _ in sources) |
| for test in tests: |
| yield from generate_gn_target(base, gn_source_list, test, all_tests) |
| |
| |
| def _main(name: str, base: str, sources: Iterable[SourceFile], |
| output: Path) -> int: |
| print_stderr = lambda s: print(s, file=sys.stderr) |
| |
| try: |
| tests = list(enumerate_tests(name, (s.file_path for s in sources))) |
| except ParseError as error: |
| print_stderr(f'ERROR: {error}') |
| return 1 |
| |
| if not tests: |
| print_stderr(f'The test "{name}" has no negative compilation tests!') |
| print_stderr('Add PW_NC_TEST() cases or remove this negative ' |
| 'compilation test') |
| return 1 |
| |
| tests_by_case = defaultdict(list) |
| for test in tests: |
| tests_by_case[test.case].append(test) |
| |
| duplicates = [tests for tests in tests_by_case.values() if len(tests) > 1] |
| if duplicates: |
| print_stderr('There are duplicate negative compilation test cases!') |
| print_stderr('The following test cases appear more than once:') |
| for tests in duplicates: |
| print_stderr(f'\n {tests[0].case} ({len(tests)} occurrences):') |
| for test in tests: |
| print_stderr(f' {test.source.name}:{test.line}') |
| return 1 |
| |
| output.mkdir(parents=True, exist_ok=True) |
| build_gn = output.joinpath('BUILD.gn') |
| with build_gn.open('w') as fd: |
| for line in generate_gn_build(base, sources, tests, |
| output.joinpath('tests.txt').as_posix()): |
| print(line, file=fd) |
| |
| with output.joinpath('tests.txt').open('w') as fd: |
| for test in tests: |
| print(test.case, file=fd) |
| |
| # Print the test case names to stdout for consumption by GN. |
| for test in tests: |
| print(test.case) |
| |
| return 0 |
| |
| |
| def _parse_args() -> dict: |
| """Parses command-line arguments.""" |
| def source_file(arg: str) -> SourceFile: |
| gn_path, file_path = arg.split(';', 1) |
| return SourceFile(gn_path, Path(file_path)) |
| |
| parser = argparse.ArgumentParser( |
| description='Emits an error when a facade has a null backend') |
| parser.add_argument('--output', type=Path, help='Output directory') |
| parser.add_argument('--name', help='Name of the NC test') |
| parser.add_argument('--base', help='GN label for the base target to build') |
| parser.add_argument('sources', |
| nargs='+', |
| type=source_file, |
| help='Source file with the no-compile tests') |
| return vars(parser.parse_args()) |
| |
| |
| if __name__ == '__main__': |
| sys.exit(_main(**_parse_args())) |