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