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