diff --git a/SConstruct b/SConstruct index a12bec51a2..be67382284 100755 --- a/SConstruct +++ b/SConstruct @@ -1527,11 +1527,12 @@ def CommandTestFileCheck(env, name, cmd, check_file): of stdout will then be piped to the file_check.py tool which will search for the regexes specified in |check_file|. """ + env = env.Clone() + env['ENV']['PYTHONPATH'] = Dir('#/src/third_party/python-filecheck').abspath return env.CommandTest( name, ['${PYTHON}', env.File('${SCONSTRUCT_DIR}/tools/llvm_file_check_wrapper.py'), - '${FILECHECK}', check_file] + cmd, direct_emulation=False) diff --git a/src/third_party/python-filecheck/LICENSE.txt b/src/third_party/python-filecheck/LICENSE.txt new file mode 100644 index 0000000000..f3a9516c44 --- /dev/null +++ b/src/third_party/python-filecheck/LICENSE.txt @@ -0,0 +1,13 @@ +Copyright 2024 Anton Lydike + +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 + +http://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. diff --git a/src/third_party/python-filecheck/README.md b/src/third_party/python-filecheck/README.md new file mode 100644 index 0000000000..8aab35cca1 --- /dev/null +++ b/src/third_party/python-filecheck/README.md @@ -0,0 +1,127 @@ +# filecheck - A Python-native clone of LLVMs FileCheck tool + +**Note:** This project is the successor of [mull-project/FileCheck.py](https://github.com/mull-project/FileCheck.py). + +This tries to be as close a clone of LLVMs FileCheck as possible, without going crazy. It currently passes 1576 out of +1645 (95.8%) of LLVMs MLIR filecheck tests. We are tracking all 69 remaining test failures in GitHub issues. + +There are some features that are left out for now (e.g. parts of +[numeric substitution](https://llvm.org/docs/CommandGuide/FileCheck.html#filecheck-numeric-substitution-blocks) +). + +The codebase is fully type checked by `pyright`, and automatically formatted using `black`. We aim to have tests +covering everything from normal matching down to error messages. + +Install by running `pip install filecheck`. + +## Features: +Here's an overview of all FileCheck features and their implementation status. + +- **Checks:** + - [X] `CHECK` + - [X] `CHECK-NEXT` + - [X] `CHECK-NOT` (Bug: [#10](https://github.com/AntonLydike/filecheck/issues/10)) + - [X] `CHECK-LABEL` (Bug: [#8](https://github.com/AntonLydike/filecheck/issues/8)) + - [X] `CHECK-EMPTY` + - [X] `CHECK-SAME` + - [X] `CHECK-DAG` + - [X] `CHECK-COUNT` +- **Flags:** + - [X] `--check-prefix` + - [X] `--check-prefixes` + - [X] `--comment-prefixes` + - [ ] `--allow-unused-prefixes` + - [X] `--input-file` + - [X] `--match-full-lines` + - [X] `--strict-whitespace` (Bug: [#6](https://github.com/AntonLydike/filecheck/issues/6)) + - [ ] `--ignore-case` + - [ ] `--implicit-check-not` (Tracked: [#20](https://github.com/AntonLydike/filecheck/issues/20)) + - [X] `--dump-input` (only `fail` and `never` supported) + - [ ] `--dump-input-context` + - [ ] `--dump-input-filter` + - [X] `--enable-var-scope` + - [X] `-D` + - [ ] `-D#,=` + - [X] `-version` + - [ ] `-v` + - [ ] `-vv` + - [ ] `--allow-deprecated-dag-overlap` + - [X] `--allow-empty` + - [ ] `--color` Colored output is supported and automatically detected. No support for the flag. +- **Base Features:** + - [X] Regex patterns + - [X] Captures and Capture Matches (Bug: [#11](https://github.com/AntonLydike/filecheck/issues/11)) + - [X] Numeric Captures + - [ ] Numeric Substitutions (jesus christ, wtf man) (Tracked: [#45](https://github.com/AntonLydike/filecheck/issues/45)) + - [X] Literal matching (`CHECK{LITERAL}`) + - [X] Weird regex features (`[:xdigits:]` and friends) + - [X] Correct(?) handling of matching check lines (Bug: [#22](https://github.com/AntonLydike/filecheck/issues/22)) +- **Testing:** + - [X] Base cases + - [X] Negative tests + - [ ] Error messages (started) + - [ ] Lots of edge cases + - [ ] MLIR/xDSL integration tests +- **UX:** + - Good error messages: Error messages are on an okay level, not great, but not terrible either. + - [X] Parse errors + - [X] Matching errors + - [X] Print possible intended matches (could be better still) + - [X] Malformed regexes + - [ ] Wrong/unkown command line arguments + - [ ] Print variables and their origin in error messages +- **Infrastructure:** + - [X] Formatting: black + - [X] Pyright + - [X] `pre-commit` + - [X] CI for everything + +We are open to PRs for bugfixes or any features listed here. + +## Differences to LLVMs FileCheck: +We want to be as close as possible to the original FileCheck, and document our differences very clearly. + +If you encounter a difference that is not documented here, feel free to file a bug report. + +### Better Regexes: +We use pythons regex library, which is a flavour of a Perl compatible regular expression (PCRE), instead of FileChecks +POSIX regex flavour. + +**Example:** +``` +// LLVM filecheck: +// CHECK: %{{[[:alnum:]]+}}, %{{[[:digit:]]+}} + +// our fileheck: +// CHECK: %{{[a-zA-Z0-9]+}}, %{{\d+}} +``` + +Some effort is made to translate character classes from POSIX to PCRE, although it might be wrong in edge cases. + +### Relaxed Matching: + +We relax some of the matching rules, like: + +- Allow a file to start with `CHECK-NEXT` + + +### No Numerical Substitution: + +This is used in 2 out of 1645 tests in our benchmark (upstream MLIR tests). + +While our filecheck supports [numeric capture](https://llvm.org/docs/CommandGuide/FileCheck.html#filecheck-numeric-substitution-blocks) +(`[[#%.3x,VAR:]]` will capture a three-digit hex number), we don't support arithmetic expressions on these captured +values at the moment. + +### Special Feature Flags: + +This version of filecheck implements some non-standard extensions to LLVMs filecheck. These are disabled by default but +can be enabled through the environment variable `FILECHECK_FEATURE_ENABLE=...`. Avialable extensions are documented here: + +- `MLIR_REGEX_CLS`: Add additional special regex matchers to match MLIR/LLVM constructs: + - `\V` will match any SSA value name + +### Reject Empty Captures: + +We introduce a new flag called `reject-empty-vars` that throws an error when a capture expression captures an empty +string. diff --git a/src/third_party/python-filecheck/filecheck/__init__.py b/src/third_party/python-filecheck/filecheck/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/third_party/python-filecheck/filecheck/__main__.py b/src/third_party/python-filecheck/filecheck/__main__.py new file mode 100644 index 0000000000..73faf30bc0 --- /dev/null +++ b/src/third_party/python-filecheck/filecheck/__main__.py @@ -0,0 +1,4 @@ +from filecheck.main import main + +if __name__ == "__main__": + main() diff --git a/src/third_party/python-filecheck/filecheck/colors.py b/src/third_party/python-filecheck/filecheck/colors.py new file mode 100644 index 0000000000..fa158da2c4 --- /dev/null +++ b/src/third_party/python-filecheck/filecheck/colors.py @@ -0,0 +1,46 @@ +import sys +from enum import Flag, auto + +COLOR_SUPPORT = hasattr(sys.stdout, "isatty") and sys.stdout.isatty() + + +class FMT(Flag): + RED = auto() + BLUE = auto() + YELLOW = auto() + GREEN = auto() + ORANGE = auto() + BOLD = auto() + GRAY = auto() + UNDERLINE = auto() + RESET = auto() + + def __str__(self) -> str: + if not COLOR_SUPPORT: + return "" + fmt_str: list[str] = [] + + if FMT.RED in self: + fmt_str.append("\033[31m") + if FMT.ORANGE in self: + fmt_str.append("\033[33m") + if FMT.GRAY in self: + fmt_str.append("\033[37m") + if FMT.GREEN in self: + fmt_str.append("\033[32m") + if FMT.BLUE in self: + fmt_str.append("\033[34m") + if FMT.YELLOW in self: + fmt_str.append("\033[93m") + if FMT.BOLD in self: + fmt_str.append("\033[1m") + if FMT.RESET in self: + fmt_str.append("\033[0m") + if FMT.UNDERLINE in self: + fmt_str.append("\033[4m") + + return "".join(fmt_str) + + +WARN = FMT.ORANGE | FMT.UNDERLINE +ERR = FMT.RED | FMT.BOLD diff --git a/src/third_party/python-filecheck/filecheck/compiler.py b/src/third_party/python-filecheck/filecheck/compiler.py new file mode 100644 index 0000000000..d93667cecb --- /dev/null +++ b/src/third_party/python-filecheck/filecheck/compiler.py @@ -0,0 +1,100 @@ +import re + +from filecheck.error import CheckError +from filecheck.ops import ( + Literal, + RE, + Capture, + NumSubst, + PseudoVar, + Subst, + CheckOp, + VALUE_MAPPER_T, +) +from filecheck.options import Options + +UNESCAPED_BRACKETS = re.compile(r"^\(|[^\\]\(") + +CHECK_EMPTY_EXPR = re.compile(r"[^\n]*\n\n") + + +def compile_uops( + check: CheckOp, variables: dict[str, str | int], opts: Options +) -> tuple[re.Pattern[str], dict[str, tuple[int, VALUE_MAPPER_T]]]: + """ + Compile a series of uops, given a set of variables, to a regex pattern and an + extraction dictionary. + + The extraction dictionary tells you which Capture can be found in which regex + group. + """ + groups = 0 + expr: list[str] = [] + if check.name == "NEXT": + expr.append(r"\n?[^\n]*") + elif check.name == "EMPTY": + return CHECK_EMPTY_EXPR, dict() + + captures: dict[str, tuple[int, VALUE_MAPPER_T]] = dict() + + for uop in check.uops: + if isinstance(uop, Literal): + # literals are matched as is + if opts.strict_whitespace: + expr.append(re.escape(uop.content)) + else: + # TODO: fix this mess + # basically, I need to replace all whitespaces in the original literal by "\s+" + # but I still want to regex escape them + # and re.sub doesn't let me insert \s into the new string for some reason...... + expr.append( + re.sub(r"(\\ )+", " ", re.escape(uop.content)).replace( + " ", r"[ \t\v]+" + ), + ) + + elif isinstance(uop, RE): + # For regexes, we must make sure that we count the number of capture groups + # present, so that we know which ones contain the Captures, and which ones + # contain "user supplied" groups. + + # count number of capture groups by counting unescaped brackets + groups += len(UNESCAPED_BRACKETS.findall(uop.content)) + # add brackets sorrounding patterns that contain ORs (fixes #3) + if "|" in uop.content: + expr.append(f"({uop.content})") + groups += 1 + else: + expr.append(uop.content) + elif isinstance(uop, Capture): + # record the group we capture in the dictionary + captures[uop.name] = (groups + 1, uop.value_mapper) + # this is used to match same-line use of variables + # like this: + # // CHECK: alloc [[REG:[a-z]+]], [[REG]] + # here we can't insert the value now, as it will only be determined when we + # match the pattern. Luckily, python regex has backwards references. + expr.append(f"({uop.pattern})") + groups += len(UNESCAPED_BRACKETS.findall(uop.pattern)) + 1 + elif isinstance(uop, Subst): + # if we have substitutions, check if the variable is defined in this line + if uop.variable in captures: + expr.append(f"\\{captures[uop.variable][0]}") + else: + # otherwise match immediate + if uop.variable not in variables: + raise CheckError( + f"Variable {uop.variable} referenced before assignment", + check, + ) + expr.append(re.escape(str(variables[uop.variable]))) + elif isinstance(uop, PseudoVar): + expr.append(f"{check.source_line + uop.offset}") + elif isinstance(uop, NumSubst): + # we don't do numerical substitutions yet + raise NotImplementedError("Numerical substitutions not supported!") + try: + # compile with MULTILINE flag, so that `^` and `$` can match start/end of line correctly + return re.compile("".join(expr), flags=re.MULTILINE), captures + except re.error: + raise CheckError(f"Malformed regex expression: '{''.join(expr)}'", check) diff --git a/src/third_party/python-filecheck/filecheck/error.py b/src/third_party/python-filecheck/filecheck/error.py new file mode 100644 index 0000000000..62cc91bdea --- /dev/null +++ b/src/third_party/python-filecheck/filecheck/error.py @@ -0,0 +1,38 @@ +import re +from dataclasses import dataclass +from typing import Any + +from filecheck.ops import CheckOp + + +class CheckError(Exception): + message: str + op: CheckOp + + def __init__(self, msg: str, op: CheckOp, *args: Any): + super().__init__(*args) + self.message = msg + self.op = op + + +@dataclass +class ErrorOnMatch(Exception): + """ + Signal error on the provided match + """ + + message: str + op: CheckOp + match: re.Match[str] + + +@dataclass +class ParseError(Exception): + """ + Signal an error during parsing + """ + + message: str + line_no: int + offset: int + offending_line: str diff --git a/src/third_party/python-filecheck/filecheck/finput.py b/src/third_party/python-filecheck/filecheck/finput.py new file mode 100644 index 0000000000..eeb36265bf --- /dev/null +++ b/src/third_party/python-filecheck/filecheck/finput.py @@ -0,0 +1,332 @@ +""" +Manages the file input and the position we are at +""" + +from __future__ import annotations + +import math +import re +import sys +from dataclasses import dataclass, field +from typing import Iterable + +from filecheck.colors import FMT +from filecheck.options import Options + +ANY_NEWLINES = re.compile(r"\n*") + + +@dataclass(slots=True) +class InputRange: + start: int + end: int + + def ranges(self) -> Iterable[tuple[int, int]]: + yield (self.start, self.end) + + def restrict_end(self, new_end: int): + return InputRange(self.start, new_end) + + def split_at(self, match: re.Match[str]): + """ + Split this range at match by truncating the end of this range to the start of + the match, and returning a new range starting at the end of the match, until + the original end of this range. + """ + next_range = InputRange(match.end(0), self.end) + self.end = match.start(0) + return next_range + + +@dataclass(slots=True) +class DiscontigousRange(InputRange): + """ + A range with holes in it. + """ + + _holes: list[InputRange] = field(default_factory=list, init=False, repr=False) + """ + This will only ever contain InputRange, never DiscontigousRange. + + These holes are non-overlapping and sorted in ascending start positions. + """ + + def ranges(self) -> Iterable[tuple[int, int]]: + start = self.start + for hole in self._holes: + if start < hole.start: + yield start, hole.start + start = hole.end + if start < self.end: + yield start, self.end + + def add_hole(self, range: InputRange | DiscontigousRange): + may_have_overlap = False + for start, end in range.ranges(): + for i, hole in enumerate(self._holes): + # check if they are disjunct: + if end < hole.start: + # if it comes before, insert it + self._holes.insert(i, InputRange(start, end)) + break + if hole.end < start: + # if it comes later, continue + continue + # we must have overlap, widen the hole! + hole.start = min(start, hole.start) + hole.end = max(end, hole.end) + may_have_overlap = True + break + else: + # append to the end otherwise + self._holes.append(InputRange(start, end)) + if may_have_overlap: + # if we widened a hole, we need to check for overlap: + remove: list[InputRange] = list() + # iterate over pairs + for h1, h2 in zip(self._holes, self._holes[1:]): + # if we find overlap: + if h1.end >= h2.start: + # widen the *second* hole + h2.start = h1.start + h2.end = max(h1.end, h2.end) + # remove the first one + remove.append(h1) + for r in remove: + self._holes.remove(r) + + def end_of_last_hole(self) -> int: + if self._holes: + return max(self._holes[-1].end, self.start) + return self.start + + def start_of_first_hole(self) -> int: + if self._holes: + return min(self._holes[0].start, self.end) + return self.end + + def remainder_to_normal_range(self) -> InputRange: + return InputRange(self.end_of_last_hole(), self.end) + + +@dataclass +class FInput: + """ + A wrapper around file input. + + Handles position keeping and regex searching. + """ + + fname: str + content: str + + line_no: int = field(default=0) + + range: InputRange = field(default_factory=lambda: InputRange(0, sys.maxsize)) + ranges: list[InputRange] = field(default_factory=list) + + @staticmethod + def canonicalize_line_ends(text: str) -> str: + return text.replace("\r\n", "\n") + + @staticmethod + def from_opts(opts: Options) -> FInput: + """ + Create a FInput object from options objects + """ + # treat - as stding + if opts.input_file == "-": + f = sys.stdin + else: + f = open(opts.input_file, "r") + return FInput(opts.input_file, FInput.canonicalize_line_ends(f.read())) + + def advance_by(self, dist: int): + """ + Move forward by dist characters in the input + """ + assert dist >= 0 + self.line_no += self.content.count( + "\n", self.range.start, self.range.start + dist + ) + self.range.start += dist + if self.range.end < self.range.start: + raise RuntimeError("Ran out of range!") + + def move_to(self, new_pos: int): + """ + Move forwards to a specific point + """ + self.advance_by(new_pos - self.range.start) + + def match(self, pattern: re.Pattern[str]) -> re.Match[str] | None: + """ + Match (exactly from the current position) + """ + return pattern.match(self.content, pos=self.range.start, endpos=self.range.end) + + def find( + self, + pattern: re.Pattern[str], + this_line: bool = False, + ) -> re.Match[str] | None: + """ + Find the first occurance of a pattern, might be far away. + + If this_line is given, match only until the next newline. + """ + irange = self.range + + newline = ( + self.content.find("\n", irange.start, irange.end) + if this_line + else irange.end + ) + if newline != -1: + irange = irange.restrict_end(newline) + + return pattern.search(self.content, pos=irange.start, endpos=irange.end) + + def find_between( + self, pattern: re.Pattern[str], irange: InputRange + ) -> re.Match[str] | None: + """ + Find the first occurance of a pattern, might be far away. + """ + for start, end in irange.ranges(): + match = pattern.search(self.content, pos=start, endpos=end) + if match is not None: + return match + + def print_line( + self, pos_override: int | None = None, end_pos: int | None = None + ) -> str: + """ + Print a position (defaults to current pos) of the file. + + If end_pos is provided, it will highlight from pos_override to end_pos. + + Otherwise, it will only highlight the position. + """ + fname = self.fname if self.fname != "-" else "" + pos = self.range.start if pos_override is None else pos_override + next_newline_at = self.content.find("\n", pos) + line = self.line_no + # account for line number changes when using pos_override + if pos_override: + line += math.copysign( + pos_override - self.range.start, + self.content.count( + "\n", + min(pos_override, self.range.start), + max(pos_override, self.range.start), + ), + ) + + # print the next line if we are pointing at a line end. + if next_newline_at == pos: + pos += 1 + line += 1 + next_newline_at = self.content.find("\n", pos) + + last_newline_at = self.start_of_line(pos) + char_pos = pos - last_newline_at + num_chars = end_pos - pos if end_pos is not None else 1 + line_content = self.content[last_newline_at:next_newline_at].strip("\n") + return ( + f"{fname}:{line}:{char_pos}\n" + f"{line_content}\n" + f"{'^' * num_chars:>{char_pos}}" + ) + + def start_of_line(self, pos: int | None = None) -> int: + """ + Find the start of the line at position pos (defaults to current position) + """ + if pos is None: + pos = self.range.start + return max(self.content.rfind("\n", 0, pos), 0) + + def skip_to_end_of_line(self): + """ + Move to the next \n token (might be at cursor already, then it's a nop) + """ + if self.range.start == 0: + return + next_newline = self.content.find("\n", self.range.start) + self.move_to(next_newline) + + def is_end_of_line(self) -> bool: + """ + Check if line ending or EOF has been reached + """ + # line ending check + if self.content.startswith("\n", self.range.start): + return True + # eof check + if self.range.start == len(self.content) - 1: + return True + return False + + def is_end_of_file(self) -> bool: + """ + Check if only whitespace characters are left in the file + """ + return ANY_NEWLINES.fullmatch(self.content, self.range.start) is not None + + def starts_with(self, expr: str) -> bool: + return self.content.startswith(expr, self.range.start) + + def print_range(self, frange: InputRange): + print(f"{self.fname}: ({frange.start} to {frange.end})") + print(self.content[frange.start : frange.end]) + + def start_discontigous_region(self): + """ + Starts a discontigous matching region, replacing the current range with a + discontigous one. + """ + assert not isinstance(self.range, DiscontigousRange) + self.range = DiscontigousRange(self.range.start, self.range.end) + + def match_and_add_hole(self, pattern: re.Pattern[str]) -> re.Match[str] | None: + """ + Find the first occurance of a pattern in a discontigous range. + + Adds the matched region as a hole to the range. + + Can only be called *after* start_discontigous_region + """ + assert isinstance(self.range, DiscontigousRange) + match = self.find_between(pattern, self.range) + if match is not None: + self.range.add_hole(InputRange(match.start(0), match.end(0))) + return match + + def print_current_range(self): + end = self.range.start + for a, b in self.range.ranges(): + yield f"{FMT.GRAY}{self.content[end:a]}{FMT.RESET}" + yield f"{self.content[a:b]}" + end = b + yield f"{FMT.GRAY}{self.content[end:self.range.end]}{FMT.RESET}" + + def advance_to_last_hole(self): + """ + Advance to end of the last hole in the discontigous region + """ + assert isinstance(self.range, DiscontigousRange) + print(f"moving to {self.range.end_of_last_hole()} of {self.range}") + new_range = self.range.remainder_to_normal_range() + self.move_to(new_range.start) + self.range = new_range + + def is_discontigous(self) -> DiscontigousRange | None: + if isinstance(self.range, DiscontigousRange): + return self.range + + def advance_range(self): + """ + Move to the next input range. + """ + assert self.ranges + self.range = self.ranges.pop(0) diff --git a/src/third_party/python-filecheck/filecheck/help.py b/src/third_party/python-filecheck/filecheck/help.py new file mode 100644 index 0000000000..0594c951b1 --- /dev/null +++ b/src/third_party/python-filecheck/filecheck/help.py @@ -0,0 +1,27 @@ +HELP_TEXT = """filecheck - A Python-native clone of LLVMs FileCheck tool +usage: filecheck FLAGS check-file + +FLAGS: +--input-file : Specify an input file, default is stdin (-) +--check-prefix : Check line prefix (default is CHECK) +--strict-whitespace : Don't ignore indentation +--comment-prefixes ,, : Ignore lines starting with these prefixes, even if + they contain check lines (default RUN,COM) +--enable-var-scope : Enables scope for regex variables. Variables not + starting with $ are deleted after each CHECK-LABEL + match. +--match-full-lines : Expect every check line to match the whole line. +--reject-empty-vars : Raise an error when a value captures an empty string. +--dump-input : Dump the input to stderr annotated with helpful + information depending on the context. Allowed values + are help, always, never, fail. Default is fail. + Only fail and never is currently supported in this + version of filecheck. + +ARGUMENTS: +check-file : The file from which the check lines are to be read + +This tries to be as close a clone of LLVMs FileCheck as possible. We use it in xDSL +for our tests. If a feature is missing, or behaviour deviates from LLVMs FileCheck, +please file a bug at github.com/AntonLydike/filecheck. +""" diff --git a/src/third_party/python-filecheck/filecheck/logging.py b/src/third_party/python-filecheck/filecheck/logging.py new file mode 100644 index 0000000000..9c8a1c725d --- /dev/null +++ b/src/third_party/python-filecheck/filecheck/logging.py @@ -0,0 +1,20 @@ +import sys + +from filecheck.colors import WARN, FMT +from filecheck.ops import CheckOp +from filecheck.options import Options + + +def warn( + msg: str, + *, + op: CheckOp | None = None, + input_loc: str | None = None, + opts: Options, +): + print(f"{WARN}Warning: {msg}{FMT.RESET}", end="", file=sys.stderr) + if input_loc: + print(f" at {input_loc}", end="", file=sys.stderr) + print("", file=sys.stderr) + if op: + print(op.source_repr(opts), file=sys.stderr) diff --git a/src/third_party/python-filecheck/filecheck/main.py b/src/third_party/python-filecheck/filecheck/main.py new file mode 100644 index 0000000000..3c74d8bb87 --- /dev/null +++ b/src/third_party/python-filecheck/filecheck/main.py @@ -0,0 +1,23 @@ +import sys +import importlib.metadata + +from filecheck.help import HELP_TEXT +from filecheck.matcher import Matcher +from filecheck.options import parse_argv_options + + +def main(argv: list[str] | None = None): + if argv is None: + argv = sys.argv + + if "--help" in argv or len(argv) < 2: + print(HELP_TEXT) + return + + if "--version" in argv or "-version" in argv: + print(f"filecheck version {importlib.metadata.version('filecheck')}") + return + + opts = parse_argv_options(argv) + m = Matcher.from_opts(opts) + sys.exit(m.run()) diff --git a/src/third_party/python-filecheck/filecheck/matcher.py b/src/third_party/python-filecheck/filecheck/matcher.py new file mode 100644 index 0000000000..7e62281640 --- /dev/null +++ b/src/third_party/python-filecheck/filecheck/matcher.py @@ -0,0 +1,396 @@ +import re +import os +import sys +from collections.abc import Sequence +from dataclasses import dataclass, field +from typing import Callable, TextIO + +from filecheck.compiler import compile_uops +from filecheck.error import CheckError, ParseError, ErrorOnMatch +from filecheck.finput import FInput, InputRange +from filecheck.logging import warn +from filecheck.colors import ERR, FMT +from filecheck.ops import CheckOp, CountOp, Literal, Subst, UOp, RE, Capture +from filecheck.options import Options, DumpInputKind +from filecheck.parser import Parser +from filecheck.preprocess import Preprocessor + + +@dataclass +class Context: + """ + A context object, carrying life variables and such + """ + + live_variables: dict[str, str | int] = field(default_factory=dict) + + negative_matches_stack: list[CheckOp] = field(default_factory=list) + """ + Keep a stack of CHECK-NOTs around, as we only know the range on which to match + once we hit the next non-negative check-line. + """ + + negative_matches_start: int | None = field(default=None) + + +@dataclass +class Matcher: + """ + The matcher contains the logic for applying the matching, recording variables and + advancing the file position accordingly. + """ + + opts: Options + file: FInput + operations: Parser + + ctx: Context = field(default_factory=Context) + + stderr: TextIO = field(init=False) + + @classmethod + def from_opts(cls, opts: Options): + """ + Construct a matcher from an options object. + """ + ops = Parser.from_opts(opts) + fin = FInput.from_opts(opts) + return Matcher(opts, fin, ops) + + def __post_init__(self): + self.ctx.live_variables.update(self.opts.variables) + if self.opts.dump_input == DumpInputKind.NEVER: + self.stderr = open(os.devnull, "w") + else: + self.stderr = sys.stderr + if self.opts.dump_input in (DumpInputKind.ALWAYS, DumpInputKind.HELP): + warn( + f"Unsupported dump-input flag: {self.opts.dump_input.name.lower()}", + opts=self.opts, + ) + + def run(self) -> int: + """ + Run the matching, returns the exit code of the program. + + Prints a nice message when it fails. + """ + if not self.opts.allow_empty: + if self.file.content in ("", "\n"): + print( + f"{ERR}filecheck error:{FMT.RESET} '{self.opts.readable_input_file()}' is empty.", + file=self.stderr, + ) + return 1 + + try: + ops = tuple(self.operations) + if not ops: + # fix plural case for multiple prefixes + if len(self.opts.check_prefixes) == 1: + pref = f"prefix {self.opts.check_prefixes[0]}" + else: + pref = f"prefixes {', '.join(self.opts.check_prefixes)}" + print( + f"{ERR}filecheck error:{FMT.RESET} No check strings found with {pref}:", + file=self.stderr, + ) + return 2 + except ParseError as ex: + print( + f"{self.opts.match_filename}:{ex.line_no}:{ex.offset} {ex.message}", + file=self.stderr, + ) + print(ex.offending_line.rstrip("\n"), file=self.stderr) + print(" " * (ex.offset - 1) + "^", file=self.stderr) + return 1 + + function_table: dict[str, Callable[[CheckOp], None]] = { + "DAG": self.check_dag, + "COUNT": self.check_count, + "NOT": self.enqueue_not, + "EMPTY": self.check_empty, + "NEXT": self.match_immediately, + "SAME": self.match_eventually, + "LABEL": self.check_label, + "CHECK": self.match_eventually, + } + + op: CheckOp | None = None + try: + # run the preprocessor + Preprocessor(self.opts, self.file, ops).run() + + # then run the checks + for op in ops: + self._pre_check(op) + function_table.get(op.name, self.fail_op)(op) + self._post_check(op) + + # run the post-check one last time to make sure all NOT checks are taken + # care of. + self.file.range.start = len(self.file.content) - 1 + self._post_check(CheckOp("SYNTH", "NOP", "", -1, [])) + except CheckError as ex: + print( + f"{self.opts.match_filename}:{ex.op.source_line}: {ERR}error:{FMT.RESET} {ex.message}", + file=self.stderr, + ) + print("Current position at " + self.file.print_line(), file=self.stderr) + + if self.file.is_discontigous(): + print( + "\nCurrently matching in range (grey is already matched):", + file=self.stderr, + ) + print("".join(self.file.print_current_range()), file=self.stderr) + + # try to look for a shorter match, and print that if possible + prefix_match = self.find_prefix_match_for(ex.op) + if prefix_match is not None: + print("Possible intended match at:", file=self.stderr) + print(self.file.print_line(prefix_match.start(0)), file=self.stderr) + + return 1 + except ErrorOnMatch as ex: + print( + f"{self.opts.match_filename}:{ex.op.source_line}: {ERR}error:{FMT.RESET} {ex.message}", + file=self.stderr, + ) + print("Matching at: ", end="", file=self.stderr) + print( + self.file.print_line(ex.match.start(0), ex.match.end(0)), + file=self.stderr, + ) + if self.file.is_discontigous(): + print( + "\nCurrently matching in range (grey is already matched):", + file=self.stderr, + ) + print("".join(self.file.print_current_range()), file=self.stderr) + return 1 + + return 0 + + def _pre_check(self, op: CheckOp): + if self.file.is_discontigous() and op.name != "DAG": + self.file.advance_to_last_hole() + if op.name == "NEXT": + self.file.skip_to_end_of_line() + elif op.name == "LABEL" and self.ctx.negative_matches_start is not None: + # we sadly need to special case the check-label here + # run through all statements + search_range = InputRange( + self.ctx.negative_matches_start, self.file.range.end + ) + for check in self.ctx.negative_matches_stack: + self.check_not(check, search_range) + # reset the state + self.ctx.negative_matches_start = None + self.ctx.negative_matches_stack = [] + + def _post_check(self, op: CheckOp): + if op.name != "NOT": + if self.ctx.negative_matches_start is not None: + end = self.file.range.start + # catch cases where the CHECK-NOT is followed by CHECK-DAG + # in that case, go until the first "hole" in the region + disc = self.file.is_discontigous() + if disc: + end = disc.start_of_first_hole() + # work through CHECK-NOT checks + # this is the range we are searching in: + search_range = InputRange( + self.ctx.negative_matches_start, + end, + ) + # run through all statements + for check in self.ctx.negative_matches_stack: + self.check_not(check, search_range) + # reset the state + self.ctx.negative_matches_start = None + self.ctx.negative_matches_stack = [] + if self.opts.match_full_lines: + if not self.file.is_end_of_line(): + raise CheckError( + "Didn't match whole line", + op, + ) + + def check_dag(self, op: CheckOp) -> None: + if not self.file.is_discontigous(): + self.file.start_discontigous_region() + pattern, capture = compile_uops(op, self.ctx.live_variables, self.opts) + match = self.file.match_and_add_hole(pattern) + if match is None: + raise CheckError( + f"{op.check_name}: Can't find match ('{op.arg}')", + op, + ) + self.capture_results(match, capture, op) + + def check_count(self, op: CheckOp) -> None: + # invariant preserved by parser + assert isinstance(op, CountOp) + for _ in range(op.count): + self.match_eventually(op) + + def check_not(self, op: CheckOp, search_range: InputRange): + """ + Check that op doesn't match between start and current position. + """ + pattern, _ = compile_uops(op, self.ctx.live_variables, self.opts) + if match := self.file.find_between(pattern, search_range): + raise ErrorOnMatch( + f"{op.check_name}: excluded string found in input ('{op.arg}')", + op, + match, + ) + + def enqueue_not(self, op: CheckOp): + """ + Enqueue a CHECK-NOT operation to be checked at a later time. + + Since CHECK-NOT matches between the last matched line, and the next matched + line, we need to postpone matching until we know when the next checked line is. + + """ + if self.ctx.negative_matches_start is None: + self.ctx.negative_matches_start = self.file.range.start + self.ctx.negative_matches_stack.append(op) + + def check_label(self, op: CheckOp): + """ + https://llvm.org/docs/CommandGuide/FileCheck.html#the-check-label-directive + + Looks for a uniquely identified line in the source file. + + CHECK-LABEL: directives cannot contain variable definitions or uses. + """ + # label checking was done by the preprocessing step, all we need to do is + # move to the next range. + self.file.advance_range() + + def check_empty(self, op: CheckOp): + # check immediately + if not self.opts.match_full_lines: + self.file.skip_to_end_of_line() + if not (self.file.starts_with("\n\n") or self.file.is_end_of_file()): + raise CheckError( + f"{op.check_name}: is not on the line after the previous match", + op, + ) + # consume single newline + self.file.advance_by(1) + + def match_immediately(self, op: CheckOp): + """ + Check that the ops pattern is matched immediately. + + Uses file.match + """ + pattern, repl = compile_uops(op, self.ctx.live_variables, self.opts) + if match := self.file.match(pattern): + self.file.move_to(match.end(0)) + self.capture_results(match, repl, op) + else: + raise CheckError(f'Couldn\'t match "{op.arg}".', op) + + def match_eventually(self, op: CheckOp): + """ + Check that the ops pattern is matched eventually. + + Uses file.find + """ + pattern, repl = compile_uops(op, self.ctx.live_variables, self.opts) + if match := self.file.find(pattern, op.name == "SAME"): + self.file.move_to(match.end()) + self.capture_results(match, repl, op) + else: + raise CheckError(f'Couldn\'t match "{op.arg}".', op) + + def fail_op(self, op: CheckOp): + """ + Helper used for unknown operations. Should not occur as we check when parsing. + """ + raise RuntimeError(f"Unknown operation: {op}") + + def purge_variables(self): + """ + Purge variables not starting with '$' + """ + for key in tuple(self.ctx.live_variables.keys()): + if not key.startswith("$"): + self.ctx.live_variables.pop(key) + + def capture_results( + self, + match: re.Match[str], + capture: dict[str, tuple[int, Callable[[str], int] | Callable[[str], str]]], + op: CheckOp, + ): + """ + Capture the results of a match into variables for string substitution + """ + for name, (group, mapper) in capture.items(): + str_val = match.group(group) + self.ctx.live_variables[name] = mapper(str_val) + if not str_val: + warn( + "Empty pattern capture", + op=op, + opts=self.opts, + ) + if self.opts.reject_empty_vars: + raise CheckError(f'Empty value captured for variable "{name}"', op) + + def find_prefix_match_for(self, op: CheckOp) -> re.Match[str] | None: + """ + Tries to construct a smaller pattern that matches a prefix of op. + + This can help in debugging a potential match. + """ + prefix = op.uops[:-1] + + # as long as we have at least ~5 characters to match on, try them + while self._approximate_uop_length(prefix) >= 5: + faux_op = CheckOp( + op.prefix, + op.name, + op.arg, + op.source_line, + prefix, + is_literal=op.is_literal, + ) + prefix_pattern, _ = compile_uops( + faux_op, self.ctx.live_variables, self.opts + ) + if match := self.file.find(prefix_pattern): + return match + else: + last = prefix.pop() + # if it's a literal and more than 5 characters long, try to shorten it. + if isinstance(last, Literal) and len(last.content) > 5: + prefix.append(Literal(last.content[: len(last.content) // 2])) + # otherwise retry without that uop + + return None + + def _approximate_uop_length(self, uops: Sequence[UOp]) -> int: + """ + Calculate a rough approximation of the content length of a series of uops + """ + count = 0 + for op in uops: + match op: + case Subst(variable): + count += len(str(self.ctx.live_variables.get(variable, ""))) + case Literal(content): + count += len(content) + case RE(content): + count += len(content) + case Capture(pattern): + count += len(pattern) + case _: + continue + return count diff --git a/src/third_party/python-filecheck/filecheck/ops.py b/src/third_party/python-filecheck/filecheck/ops.py new file mode 100644 index 0000000000..e3791a40bc --- /dev/null +++ b/src/third_party/python-filecheck/filecheck/ops.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable, TypeAlias + +from filecheck.options import Options + +OP_KINDS = ("DAG", "COUNT", "NOT", "EMPTY", "NEXT", "SAME", "LABEL", "CHECK") + +VALUE_MAPPER_T: TypeAlias = Callable[[str], int] | Callable[[str], str] + + +@dataclass(slots=True) +class CheckOp: + """ + Represents a concrete check instruction (e.g. CHECK-NEXT) + """ + + prefix: str + name: str + arg: str + source_line: int + uops: list[UOp] + is_literal: bool = field(default=False, kw_only=True) + + def check_line_repr(self): + return f"{self.check_name}: {self.arg}" + + def source_repr(self, opts: Options) -> str: + return ( + f"Check rule at {opts.match_filename}:{self.source_line}\n" + f"{self.check_line_repr()}" + ) + + def _suffix(self): + suffix = "{LITERAL}" if self.is_literal else "" + if self.name == "CHECK": + return suffix + return f"-{self.name}{suffix}" + + @property + def check_name(self): + return f"{self.prefix}{self._suffix()}" + + +@dataclass(slots=True) +class CountOp(CheckOp): + """ + special case for COUNT- + """ + + count: int = field(kw_only=True) + + def _suffix(self): + suffix = "{LITERAL}" if self.is_literal else "" + return f"-COUNT{self.count}{suffix}" + + +@dataclass(frozen=True, slots=True) +class UOp: + """ + micro-ops, these make up the filecheck matching logic + """ + + pass + + +@dataclass(frozen=True, slots=True) +class Literal(UOp): + """ + literal match + """ + + content: str + + +@dataclass(frozen=True, slots=True) +class RE(UOp): + """ + Regular expression matching + """ + + content: str + + +@dataclass(frozen=True, slots=True) +class Capture(UOp): + """ + Variable capture expression + + Stuff like this: + ``` + ; CHECK: test5: + ; CHECK: notw [[REGISTER:%[a-z]+]] + ; CHECK: andw {{.*}}[[REGISTER]] + ``` + """ + + name: str + pattern: str + value_mapper: VALUE_MAPPER_T + + +@dataclass(frozen=True, slots=True) +class Subst(UOp): + """ + Variable substitution, e.g. expect the contents of a variable here. + + Stuff like this: + ``` + ; CHECK: test5: + ; CHECK: notw [[REGISTER:%[a-z]+]] + ; CHECK: andw {{.*}}[[REGISTER]] + ```` + """ + + variable: str + + +@dataclass(frozen=True, slots=True) +class NumSubst(UOp): + """ + Numeric substitution (substitute variable with derived expression). + + Stuff like this: + ``` + ; CHECK: load r[[#REG:]], [r0] + ; CHECK: load r[[#REG+1]], [r1] + ; CHECK: Loading from 0x[[#%x,ADDR:]] + ; CHECK-SAME: to 0x[[#ADDR + 7]] + ``` + """ + + variable: str + expr: str + + +@dataclass(frozen=True, slots=True) +class PseudoVar(UOp): + """ + Pseudo Numeric Variables (substitute @line with actual line number). + + Stuff like this: + ``` + ; CHECK: [[# @line]]: error: ... + ; CHECK: [[# @line + 1]]: warning: ... + ``` + """ + + offset: int diff --git a/src/third_party/python-filecheck/filecheck/options.py b/src/third_party/python-filecheck/filecheck/options.py new file mode 100644 index 0000000000..173ddfccc5 --- /dev/null +++ b/src/third_party/python-filecheck/filecheck/options.py @@ -0,0 +1,161 @@ +from dataclasses import dataclass, field +from enum import Enum, auto +from typing import Iterable +import os + + +class Extension(Enum): + MLIR_REGEX_CLS = "MLIR_REGEX_CLS" + + +class DumpInputKind(Enum): + HELP = auto() + ALWAYS = auto() + NEVER = auto() + FAIL = auto() + + +@dataclass +class Options: + match_filename: str + input_file: str = "-" + check_prefixes: list[str] = "CHECK" # type: ignore[reportAssignmentType] + comment_prefixes: list[str] = "COM,RUN" # type: ignore[reportAssignmentType] + strict_whitespace: bool = False + enable_var_scope: bool = False + match_full_lines: bool = False + allow_empty: bool = False + reject_empty_vars: bool = False + dump_input: DumpInputKind = DumpInputKind.FAIL + variables: dict[str, str | int] = field(default_factory=dict) + + extensions: set[Extension] = field(default_factory=set) + + def __post_init__(self): + # make sure we split the comment prefixes + if isinstance(self.comment_prefixes, str): + self.comment_prefixes = self.comment_prefixes.split(",") + if isinstance(self.check_prefixes, str): + self.check_prefixes = self.check_prefixes.split(",") + extensions: set[Extension] = set() + for ext in self.extensions: + if isinstance(ext, str): + if ext in set(e.value for e in Extension): + extensions.add( + Extension[ext] # pyright: ignore[reportArgumentType] + ) + else: + print( + f"Unknown filecheck extension: {ext}, supported are {list(e.name for e in Extension)}" + ) + else: + extensions.add(ext) + self.extensions = extensions + if isinstance(self.dump_input, str): + name: str = self.dump_input.upper() + if name in set(d.name for d in DumpInputKind): + self.dump_input = DumpInputKind[name] + else: + raise RuntimeError( + f'Unknown value supplied for dump-input flag: "{name}"' + ) + + def readable_input_file(self): + if self.input_file == "-": + return "" + return self.input_file + + +def parse_argv_options(argv: list[str]) -> Options: + # pop the name off of argv + _ = argv.pop(0) + + # create a set of valid fields + valid_fields = set(Options.__dataclass_fields__) + # add check-prefix as another valid field (merged with check-prefixes) + valid_fields.add("check_prefix") + + # final options to return + opts: dict[str, str | bool] = {} + variables: dict[str, str | int] = {} + # args that were consumed + remove: set[int] = set() + argv = list(normalise_args(argv)) + # grab extensions from env: + extensions = set(os.getenv("FILECHECK_FEATURE_ENABLE", "").split(",")) + # remove empty extension in case it made it in there + if "" in extensions: + extensions.remove("") + + for i, arg in enumerate(argv): + # skip consumed args + if i in remove: + continue + if arg.startswith("-D"): + if i == len(argv) - 1: + raise RuntimeError("Out of range arguments") + key, val = arg[2:], argv[i + 1] + variables[key] = val + remove.update((i, i + 1)) + elif arg.startswith("--"): + arg = arg[2:].replace("-", "_") + elif arg.startswith("-"): + arg = arg[1:].replace("-", "_") + else: + continue + if arg not in valid_fields: + continue + + remove.add(i) + if (f := Options.__dataclass_fields__.get(arg, None)) and f.type == bool: + opts[arg] = True + else: + if i == len(argv) - 1: + raise RuntimeError("Out of range arguments") + # make sure to append check and comment prefixes if flag is used multiple times + if arg in ("check_prefix", "comment_prefixes") and arg in opts: + existing_opts = opts[arg] + assert isinstance(existing_opts, str) + opts[arg] = existing_opts + "," + argv[i + 1] + else: + opts[arg] = argv[i + 1] + remove.add(i + 1) + + if "check_prefix" in opts: + prefixes = opts.pop("check_prefix") + assert isinstance(prefixes, str) + if "check_prefixes" in opts: + more_prefixes = opts.pop("check_prefixes") + assert isinstance(more_prefixes, str) + prefixes += "," + more_prefixes + opts["check_prefixes"] = prefixes + + for idx in sorted(remove, reverse=True): + argv.pop(idx) + + if len(argv) > 1: + raise RuntimeError( + f"Unconsumed arguments: {argv}, expected one remaining arg, the match-filename." + ) + if len(argv) != 1: + raise RuntimeError(f"Missing argument: check-file") + + opts["match_filename"] = argv[0] + + return Options( + **opts, # pyright: ignore[reportArgumentType] + variables=variables, + extensions=extensions, # pyright: ignore[reportArgumentType] + ) + + +def normalise_args(argv: list[str]) -> Iterable[str]: + """ + Normalize arguments by splitting "--key=value" pairs into separate "--key" and + "value" entries. + """ + for arg in argv: + if arg.startswith("-") and "=" in arg: + yield from arg.split("=", 1) + else: + yield arg diff --git a/src/third_party/python-filecheck/filecheck/parser.py b/src/third_party/python-filecheck/filecheck/parser.py new file mode 100644 index 0000000000..d53e050b12 --- /dev/null +++ b/src/third_party/python-filecheck/filecheck/parser.py @@ -0,0 +1,258 @@ +""" +parser for filecheck syntax +""" + +import re +from dataclasses import dataclass, field +from typing import Iterator, TextIO + +from filecheck.error import ParseError +from filecheck.ops import ( + CheckOp, + Literal, + RE, + Capture, + PseudoVar, + Subst, + NumSubst, + UOp, + CountOp, +) +from filecheck.options import Options, parse_argv_options, Extension +from filecheck.regex import ( + posix_to_python_regex, + pattern_from_num_subst_spec, + mlir_regex_extensions, +) + + +def pattern_for_opts(opts: Options) -> tuple[re.Pattern[str], re.Pattern[str]]: + prefixes = f"({'|'.join(map(re.escape, opts.check_prefixes))})" + return re.compile( + r"(^|[^a-zA-Z-_])" + + prefixes + + r"(-(DAG|COUNT-\d+|NOT|EMPTY|NEXT|SAME|LABEL))?(\{LITERAL})?:\s?([^\n]*)\n?" + ), re.compile(f"({'|'.join(map(re.escape, opts.comment_prefixes))}).*{prefixes}") + + +# see https://llvm.org/docs/CommandGuide/FileCheck.html#filecheck-string-substitution-blocks +VAR_CAPTURE_PATTERN = re.compile(r"\[\[(\$?[a-zA-Z_][a-zA-Z0-9_]*):([^\n]*)]]") + +# see https://llvm.org/docs/CommandGuide/FileCheck.html#filecheck-string-substitution-blocks +VAR_SUBST_PATTERN = re.compile(r"\[\[(\$?[a-zA-Z_][a-zA-Z0-9_]*)]]") + +# numeric substitution blocks, see: +# https://llvm.org/docs/CommandGuide/FileCheck.html#filecheck-numeric-substitution-blocks +NUMERIC_CAPTURE_PATTERN = re.compile( + r"\[\[#(%(\.[0-9]+)?([udxX])?,)?((\$?[a-zA-Z_][a-zA-Z0-9_]+):(\d+)?)?]]" +) + +# numeric substitution blocks, see: +# https://llvm.org/docs/CommandGuide/FileCheck.html#filecheck-numeric-substitution-blocks +NUMERIC_SUBST_PATTERN = re.compile( + r"\[\[#(\$?[a-zA-Z_][a-zA-Z0-9_]*)([a-z0-9 +\-()]*)]]" +) + +# pseudo numeric variable, see: +# https://llvm.org/docs/CommandGuide/FileCheck.html#filecheck-pseudo-numeric-variables +PSEUDO_NUMERIC_VARIABLE = re.compile(r"\[\[# @LINE ?(([+-]) (\d+))?]]") + +LINE_SPLIT_RE = split = re.compile(r"(\{\{|\[\[\$?[#a-zA-Z_]|]|})") + + +@dataclass +class Parser(Iterator[CheckOp]): + """ + parse a file containing filecheck directives and returns all found check directives + """ + + opts: Options + + input: TextIO + + check_line_regexp: re.Pattern[str] + comment_line_regexp: re.Pattern[str] + + line_no: int = field(default=0) + line: str = field(default="") + + @classmethod + def from_opts(cls, opts: Options): + return Parser(opts, open(opts.match_filename), *pattern_for_opts(opts)) + + def __next__(self) -> CheckOp: + """ + Scans forward in self.input until a line matching self.check_line_regexp is + found. Returns the kind (CHECK/NEXT/DAG/EMPTY/NOT) and the arg (whatever comes + after the colon). + + Note that for all CHECK-: it returns as kind, but for CHECK: it + returns CHECK. + """ + while True: + self.line_no += 1 + line = self.input.readline() + if line == "": + raise StopIteration() + # skip any lines containing comment markers before checks + if self.comment_line_regexp.search(line) is not None: + continue + match = self.check_line_regexp.search(line) + # no check line = skip + if match is None: + continue + prefix = match.group(2) + kind = match.group(4) + literal = match.group(5) + arg = match.group(6) + if kind is None: + kind = "CHECK" + if arg is None: + arg = "" + # verify that non-empty checks have an actual thing to match + if kind != "EMPTY": + if not arg: + raise ParseError( + f"found empty check string with prefix '{kind}:'", + self.line_no, + match.start(4), + line, + ) + if not self.opts.strict_whitespace: + arg = arg.strip() + + # parse the uops, but only if we are not in LITERAL mode + uops: list[UOp] + if literal is None: + uops = self.parse_args(arg, line) + else: + uops = [Literal(arg)] + + # special case for COUNT ops + if kind.startswith("COUNT"): + count = int(kind[6:]) + if count == 0: + raise ParseError( + f"invalid count in -COUNT specification on prefix '{prefix}' " + f"(count can't be 0)", + self.line_no, + match.end(2), + line, + ) + return CountOp( + prefix, + "COUNT", + arg, + self.line_no, + uops, + is_literal=literal is not None, + count=count, + ) + return CheckOp( + prefix, + kind, + arg, + self.line_no, + uops, + is_literal=literal is not None, + ) + + def parse_args(self, arg: str, line: str) -> list[UOp]: + """ + parse check args into uops that can later be compiled into a regex + + Returns a list of uops + """ + uops: list[UOp] = [] + parts = LINE_SPLIT_RE.split(arg) + offset = len(line) - len(arg) + while parts: + part = parts.pop(0) + if part.startswith("[["): + brackets = 2 + # grab parts greedily until we hit a ]] + while brackets > 0: + if not parts: + raise ParseError( + "Invalid substitution block, no ]]", + self.line_no, + offset, + line, + ) + addition = parts.pop(0) + brackets += addition.count("[") - addition.count("\\[") + brackets -= addition.count("]") + addition.count("\\]") + part += addition + # check if we are a simple capture pattern [[:]] + if match := VAR_CAPTURE_PATTERN.fullmatch(part): + re_expr = posix_to_python_regex(match.group(2)) + if Extension.MLIR_REGEX_CLS in Extension: + re_expr = mlir_regex_extensions(re_expr) + uops.append( + Capture( + match.group(1), + re_expr, + str, + ) + ) + # check if we are a simple substitution pattern: [[<>]] + elif match := VAR_SUBST_PATTERN.fullmatch(part): + uops.append(Subst(match.group(1))) + # check if we are a numeric substitution pattern [[#()?]] + elif match := NUMERIC_SUBST_PATTERN.fullmatch(part): + # simplify to non-numeric substitution if expr is empty + if match.group(2) is None or match.group(2).strip() == "": + uops.append(Subst(match.group(1))) + else: + uops.append(NumSubst(match.group(1), match.group(2))) + # numeric capture patterns are a tricky bunch + # see https://llvm.org/docs/CommandGuide/FileCheck.html#filecheck-numeric-substitution-blocks + elif match := NUMERIC_CAPTURE_PATTERN.fullmatch(part): + pattern, mapper = pattern_from_num_subst_spec( + match.group(2), match.group(3) + ) + uops.append(Capture(match.group(5), pattern, mapper)) + # check if we are a pseudo numeric variable: [[# @line [+-] ]] + # see https://llvm.org/docs/CommandGuide/FileCheck.html#filecheck-pseudo-numeric-variables + elif match := PSEUDO_NUMERIC_VARIABLE.fullmatch(part): + if match.group(1): + offset = int(match.group(3)) + if match.group(2) == "-": + offset *= -1 + uops.append(PseudoVar(offset)) + else: + uops.append(PseudoVar(0)) + else: + raise ParseError( + f"Invalid substitution block, unknown format: {part}", + self.line_no, + offset, + line, + ) + elif part == "{{": + while not part.endswith("}}"): + if not parts: + raise ParseError( + "Invalid regex block, no }}", self.line_no, offset, line + ) + part += parts.pop(0) + + pattern = part[2:-2] + + re_expr = posix_to_python_regex(pattern) + if Extension.MLIR_REGEX_CLS in Extension: + re_expr = mlir_regex_extensions(re_expr) + + uops.append(RE(re_expr)) + elif part != "": + uops.append(Literal(part)) + offset += len(part) + return uops + + +if __name__ == "__main__": + import sys + + opts = parse_argv_options(sys.argv) + for p in Parser.from_opts(opts): + print(p) diff --git a/src/third_party/python-filecheck/filecheck/preprocess.py b/src/third_party/python-filecheck/filecheck/preprocess.py new file mode 100644 index 0000000000..810b89aa37 --- /dev/null +++ b/src/third_party/python-filecheck/filecheck/preprocess.py @@ -0,0 +1,37 @@ +from collections.abc import Sequence +from dataclasses import dataclass, field + +from filecheck.error import CheckError +from filecheck.finput import FInput, InputRange +from filecheck.ops import CheckOp +from filecheck.options import Options +from filecheck.compiler import compile_uops + + +@dataclass +class Preprocessor: + opts: Options + input: FInput + checks: Sequence[CheckOp] + range: InputRange = field( + default=None, init=False # pyright: ignore[reportArgumentType] + ) + + def __post_init__(self): + self.range = self.input.range + + def run(self): + for op in self.checks: + if op.name == "LABEL": + self.preprocess_label(op) + + def preprocess_label(self, op: CheckOp): + pattern, _ = compile_uops(op, dict(), self.opts) + match = self.input.find_between(pattern, self.range) + if not match: + raise CheckError( + f"{op.check_name}: Could not find label '{op.arg}' in input", op + ) + + self.range = self.range.split_at(match) + self.input.ranges.append(self.range) diff --git a/src/third_party/python-filecheck/filecheck/regex.py b/src/third_party/python-filecheck/filecheck/regex.py new file mode 100644 index 0000000000..873982ef8a --- /dev/null +++ b/src/third_party/python-filecheck/filecheck/regex.py @@ -0,0 +1,82 @@ +import re +from typing import Callable + +POSIX_REGEXP_PATTERN = re.compile( + r"\[:(alpha|upper|lower|digit|alnum|xdigit|space|blank|print|punct|graph|word|ascii|cntrl):]" +) +POSIX_REGEXP_REPLACEMENTS = { + "alpha": "A-Za-z", + "upper": "A-Z", + "lower": "a-z", + "digit": "0-9", + "alnum": "A-Za-z0-9", + "xdigit": "A-Fa-f0-9", + "space": r"\s", + "blank": r" \t", +} + +NEGATED_SET_WITHOUT_NEWLINES = re.compile(r"([^\\]|^)\[\^((?!\\n))") + + +def posix_to_python_regex(expr: str) -> str: + """ + We need to translate things like `[:alpha:]` to `[A-Za-z]`, etc. + + This also takes care of a little known fact about the llvm::Regex implementation: + + ``` + enum llvm::Regex::RegexFlags::Newline = 2U + + Compile for newline-sensitive matching. With this flag '[^' bracket + expressions and '.' never match newline. A ^ anchor matches the + null string after any newline in the string in addition to its normal + function, and the $ anchor matches the null string before any + newline in the string in addition to its normal function. + ``` + + This bad boy is enabled in all FileCheck cases, meaning we need to also add `\n` to all + negative bracket expressions, otherwise we'll eat *so* many newlines. + + LLVM supports them, but pythons regex doesn't. + """ + while (match := POSIX_REGEXP_PATTERN.search(expr)) is not None: + if match.group(1) not in POSIX_REGEXP_REPLACEMENTS: + raise ValueError( + f"Can't translate posix regex, unknown character set: {match.group(1)}" + ) + expr = expr.replace(match.group(0), POSIX_REGEXP_REPLACEMENTS[match.group(1)]) + + expr = NEGATED_SET_WITHOUT_NEWLINES.sub(r"\1[^\\n\2", expr) + + return expr + + +def mlir_regex_extensions(expr: str) -> str: + """ + This implements the special extensions for MLIR regex classes, enabled with the + FILECHECK_FEATURE_ENABLE=MLIR_REGEX_CLS feature flag. + """ + return expr.replace(r"\V", r"%([0-9]+|[A-Za-z_.$-][A-Za-z_.$0-9-]*)(#\d+)?") + + +ENCODINGS_MAP = { + "u": r"\d", + "d": r"[+-]?\d", + "x": "[a-f0-9]", + "X": "[A-F0-9]", +} + + +def pattern_from_num_subst_spec( + digits: str | None, encoding: str | None +) -> tuple[str, Callable[[str], int]]: + digits_expr = "+" if digits is None else f"{{{int(digits[1:])}}}" + if encoding is None: + encoding = "u" + return f"{ENCODINGS_MAP[encoding]}{digits_expr}", ( + hex_int if encoding.lower() == "x" else int + ) + + +def hex_int(v: str): + return int(v, base=16) diff --git a/src/trusted/service_runtime/build.scons b/src/trusted/service_runtime/build.scons index ab6c0d3cd0..d7dd1b2156 100644 --- a/src/trusted/service_runtime/build.scons +++ b/src/trusted/service_runtime/build.scons @@ -457,18 +457,18 @@ gtest_env = env.MakeGTestEnv() unittest_inputs = [ 'filename_util_test.cc', - 'mmap_unittest.cc', - 'unittest_main.cc', 'sel_memory_unittest.cc', 'sel_mem_test.cc', 'sel_ldr_test.cc', 'thread_suspension_test.cc', ] +unittest_main = gtest_env.Object('service_runtime_test_main', 'unittest_main.cc') + if not env.Bit('coverage_enabled') or not env.Bit('windows'): unit_tests_exe = gtest_env.ComponentProgram( 'service_runtime_tests', - unittest_inputs, + unittest_inputs + [unittest_main], EXTRA_LIBS=['sel', 'env_cleanser', 'nrd_xfer', @@ -483,11 +483,22 @@ if not env.Bit('coverage_enabled') or not env.Bit('windows'): node = gtest_env.CommandTest( 'gtest_output.xml.out', - size='large', # This test suite is fairly slow on Windows XP. command=[unit_tests_exe, '--gtest_output=xml:${TARGET}']) gtest_env.AddNodeToTestSuite(node, ['small_tests'], 'run_service_runtime_tests') + # Takes several minutes on Windows only + mmap_test_size = 'huge' if env.Bit('windows') else 'small' + mmap_unit_test_exe = gtest_env.ComponentProgram( + 'mmap_unit_test', + ['mmap_unittest.cc', unittest_main], + EXTRA_LIBS=['nacl_base', 'sel', 'nrd_xfer', 'imc', 'sel_test']) + node = gtest_env.CommandTest( + 'mmap_gtest_output.xml.out', + command=[mmap_unit_test_exe, '--gtest_output=xml:${TARGET}'], + size=mmap_test_size) + gtest_env.AddNodeToTestSuite(node, [mmap_test_size + '_tests'], + 'run_mmap_unit_test') if not env.Bit('coverage_enabled') or not env.Bit('windows'): format_string_test_exe = env.ComponentProgram( @@ -637,7 +648,10 @@ if env.Bit('windows') and env.Bit('build_x86_64'): ]) node = env.CommandTest('patch_ntdll_test.out', command=[test_prog], declares_exit_status=True) - env.AddNodeToTestSuite(node, ['small_tests'], 'run_patch_ntdll_test') + # This is broken in opt but not dbg mode. The difference is + # /MT vs. /MTd. + env.AddNodeToTestSuite(node, ['small_tests'], 'run_patch_ntdll_test', + is_broken=True) intercept_test_prog = env.ComponentProgram( 'ntdll_intercept_test', 'win/exception_patch/intercept_test.c', @@ -752,8 +766,9 @@ nullptr_nexe = untrusted_env.GetTranslatedNexe( node = env.CommandSelLdrTestNacl( 'fuzz_nullptr_test.out', nullptr_nexe, + size='large', sel_ldr_flags=['-F']) -env.AddNodeToTestSuite(node, ['small_tests'], 'run_fuzz_nullptr_test') +env.AddNodeToTestSuite(node, ['large_tests'], 'run_fuzz_nullptr_test') if env.Bit('build_mips32'): text_region_start = 0x00020000 diff --git a/tests/app_lib/nacl.scons b/tests/app_lib/nacl.scons index dfc8883f14..9cddbb8355 100644 --- a/tests/app_lib/nacl.scons +++ b/tests/app_lib/nacl.scons @@ -25,6 +25,7 @@ node = env.CommandSelLdrTestNacl( stdin=env.File('app_lib_test.stdin'), stdout_golden=env.File('app_lib_test.stdout'), sel_ldr_flags=['-a'], + size='medium', ) -env.AddNodeToTestSuite(node, ['small_tests'], 'run_app_lib_test') +env.AddNodeToTestSuite(node, ['medium_tests'], 'run_app_lib_test') diff --git a/tests/multiple_sandboxes/nacl.scons b/tests/multiple_sandboxes/nacl.scons index 367777506d..abbf7d6dea 100644 --- a/tests/multiple_sandboxes/nacl.scons +++ b/tests/multiple_sandboxes/nacl.scons @@ -25,6 +25,7 @@ test_prog = env.ComponentProgram( test_prog = env.GetTranslatedNexe(test_prog) node = env.CommandTest('multidomain_test.out', [runner, test_prog], + size='medium', # Increase verbosity to get more information in # the event of a crash. osenv='NACLVERBOSITY=4', @@ -41,5 +42,5 @@ is_broken = (env.Bit('build_arm') or env.Bit('build_mips32') or env.Bit('nacl_glibc')) -env.AddNodeToTestSuite(node, ['small_tests'], 'run_multidomain_test', +env.AddNodeToTestSuite(node, ['medium_tests'], 'run_multidomain_test', is_broken=is_broken) diff --git a/tests/nanosleep/nacl.scons b/tests/nanosleep/nacl.scons index 17c12966aa..0589c2c4c5 100644 --- a/tests/nanosleep/nacl.scons +++ b/tests/nanosleep/nacl.scons @@ -6,7 +6,6 @@ Import('env') -test_is_broken_on_this_os=False slop_args = [] if 'TRUSTED_ENV' in env and (env['TRUSTED_ENV'].Bit('windows') or @@ -16,9 +15,6 @@ if 'TRUSTED_ENV' in env and (env['TRUSTED_ENV'].Bit('windows') or # early, after invoking nanosleep (on Windows, implemnted via # Sleep()). On WinXP, Sleep sometimes return early. -if 'TRUSTED_ENV' in env and env['TRUSTED_ENV'].Bit('windows'): - test_is_broken_on_this_os=True - nexe = env.ComponentProgram('nanosleep_test', 'nanosleep_test.c', EXTRA_LIBS=['${PTHREAD_LIBS}', '${NONIRT_LIBS}']) @@ -31,4 +27,4 @@ node = env.CommandSelLdrTestNacl( nexe, args=slop_args) env.AddNodeToTestSuite(node, ['small_tests'], 'run_nanosleep_test', - is_broken=is_on_vm or test_is_broken_on_this_os) + is_flaky=True) diff --git a/tests/syscalls/nacl.scons b/tests/syscalls/nacl.scons index 8d7b0fdf43..a897970174 100644 --- a/tests/syscalls/nacl.scons +++ b/tests/syscalls/nacl.scons @@ -8,10 +8,6 @@ import os Import('env') -time_test_is_broken_on_this_os=False -if 'TRUSTED_ENV' in env and env['TRUSTED_ENV'].Bit('windows'): - time_test_is_broken_on_this_os=True - env.ComponentLibrary('syscall_test_framework', ['test.cc']) env.Append(CPPDEFINES=[['NONSFI_MODE', @@ -185,9 +181,6 @@ if nonstable_env.SetNonStableBitcodeIfAllowed(): ['small_tests', 'sel_ldr_tests'], 'run_sysbrk_test') -# These are timing tests, so we only run on real hardware -is_on_vm = env.Bit('running_on_vm') - # additions to add syscall tests 40-42 timefuncs_test_nexe = env.ComponentProgram( 'timefuncs_test', @@ -201,7 +194,7 @@ node = env.CommandSelLdrTestNacl( env.AddNodeToTestSuite(node, ['small_tests', 'sel_ldr_tests'], 'run_timefuncs_test', - is_broken=is_on_vm or time_test_is_broken_on_this_os + is_flaky=True, ) raw_syscall_timefunc_objects = env.RawSyscallObjects(['timefuncs_test.cc']) @@ -215,7 +208,7 @@ node = env.CommandSelLdrTestNacl('raw_timefuncs_test.out', env.AddNodeToTestSuite(node, ['small_tests', 'sel_ldr_tests'], 'run_raw_timefuncs_test', - is_broken=is_on_vm or time_test_is_broken_on_this_os) + is_flaky=True) sysconf_pagesize_nexe = env.ComponentProgram('sysconf_pagesize_test', ['sysconf_pagesize.c'], diff --git a/tools/llvm_file_check_wrapper.py b/tools/llvm_file_check_wrapper.py index eb0bbee2d3..154eeee300 100755 --- a/tools/llvm_file_check_wrapper.py +++ b/tools/llvm_file_check_wrapper.py @@ -7,6 +7,7 @@ # Without this wrapper for FileCheck, the command interpreter takes # everything on the left of '|' and pipes it to everything on the right, # which is not what we want. +# DAEMON: llvm FileCheck replaced with a Python clone. from __future__ import print_function @@ -15,19 +16,18 @@ def Main(args): if len(args) < 3: - print("llvm_file_check_wrapper ") + print("llvm_file_check_wrapper ") print("Where:") - print(" is the path to FileCheck") print(" is a text file containing 'CHECK' statements") print(" is the command line to use as input to FileCheck") return 1 - file_check=args[0] - check_file=args[1] - what_to_run=args[2:] + check_file=args[0] + what_to_run=args[1:] what_to_run_proc = subprocess.Popen(what_to_run, stdout=subprocess.PIPE) - file_check_proc = subprocess.check_output((file_check, check_file), + file_check_proc = subprocess.check_output( + [sys.executable, '-m', 'filecheck', check_file], stdin=what_to_run_proc.stdout) what_to_run_proc.wait()