From d629a0089acff3f33dcf4055117b95cc578fd848 Mon Sep 17 00:00:00 2001 From: "Henrik G. Olsson" Date: Tue, 4 Nov 2025 22:16:30 -0800 Subject: [PATCH 1/3] [utils] add update-verify-tests.py from clang This script is used to automatically update test cases using -verify in clang's test suite. Swift also has a similar -verify test functionality, and while it can automatically fix some of them using -verify-apply-all, this functionality only updates existing checks - it doesn't add or remove any, and it doesn't handle newer and more complex things like expected-expansion. Handling that type of complexity feels out of scope to embed in the compiler, so let's copy clang's approach. This commit adds this script as is from clang. It doesn't work at all for Swift in its current form, as the output from Swift's -verify is formatted differently than in clang. This will be fixed in subsequent commits. This could have been done by adapting the script as-is in the llvm-project repository (since it only exists in the Swift fork, not upstream), but tests using swift-frontend would have to reside in the swift repo, and modifying a script in one repo with tests in a different repo sounds like a recipe for endless CI issues. --- utils/update-verify-tests.py | 39 +++ utils/update_verify_tests/core.py | 453 ++++++++++++++++++++++++++++++ 2 files changed, 492 insertions(+) create mode 100644 utils/update-verify-tests.py create mode 100644 utils/update_verify_tests/core.py diff --git a/utils/update-verify-tests.py b/utils/update-verify-tests.py new file mode 100644 index 0000000000000..88701aaff34d2 --- /dev/null +++ b/utils/update-verify-tests.py @@ -0,0 +1,39 @@ +import sys +import argparse +from UpdateVerifyTests.core import check_expectations + +""" + Pipe output from clang's -verify into this script to have the test case updated to expect the actual diagnostic output. + When inserting new expected-* checks it will place them on the line before the location of the diagnostic, with an @+1, + or @+N for some N if there are multiple diagnostics emitted on the same line. If the current checks are using @-N for + this line, the new check will follow that convention also. + Existing checks will be left untouched as much as possible, including their location and whitespace content, to minimize + diffs. If inaccurate their count will be updated, or the check removed entirely. + + Missing features: + - multiple prefixes on the same line (-verify=my-prefix,my-other-prefix) + - multiple prefixes on separate RUN lines (RUN: -verify=my-prefix\nRUN: -verify my-other-prefix) + - regexes with expected-*-re: existing ones will be left untouched if accurate, but the script will abort if there are any + diagnostic mismatches on the same line. + - multiple checks targeting the same line are supported, but a line may only contain one check + - if multiple checks targeting the same line are failing the script is not guaranteed to produce a minimal diff + +Example usage: + clang -verify [file] | python3 update-verify-tests.py + clang -verify=check [file] | python3 update-verify-tests.py --prefix check +""" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--prefix", default="expected", help="The prefix passed to -verify" + ) + args = parser.parse_args() + output = check_expectations(sys.stdin.readlines(), args.prefix) + print(output) + + +if __name__ == "__main__": + main() + diff --git a/utils/update_verify_tests/core.py b/utils/update_verify_tests/core.py new file mode 100644 index 0000000000000..74210ea12cb7a --- /dev/null +++ b/utils/update_verify_tests/core.py @@ -0,0 +1,453 @@ +import sys +import re + +DEBUG = False + + +def dprint(*args): + if DEBUG: + print(*args, file=sys.stderr) + + +class KnownException(Exception): + pass + + +def parse_error_category(s, prefix): + if "no expected directives found" in s: + return None + parts = s.split("diagnostics") + diag_category = parts[0] + category_parts = parts[0].strip().strip("'").split("-") + expected = category_parts[0] + if expected != prefix: + raise Exception( + f"expected prefix '{prefix}', but found '{expected}'. Multiple verify prefixes are not supported." + ) + diag_category = category_parts[1] + if "seen but not expected" in parts[1]: + seen = True + elif "expected but not seen" in parts[1]: + seen = False + else: + raise KnownException(f"unexpected category '{parts[1]}'") + return (diag_category, seen) + + +diag_error_re = re.compile(r"File (\S+) Line (\d+): (.+)") +diag_error_re2 = re.compile(r"File \S+ Line \d+ \(directive at (\S+):(\d+)\): (.+)") + + +def parse_diag_error(s): + m = diag_error_re2.match(s) + if not m: + m = diag_error_re.match(s) + if not m: + return None + return (m.group(1), int(m.group(2)), m.group(3)) + + +class Line: + def __init__(self, content, line_n): + self.content = content + self.diag = None + self.line_n = line_n + self.targeting_diags = [] + + def update_line_n(self, n): + self.line_n = n + + def render(self): + if not self.diag: + return self.content + assert "{{DIAG}}" in self.content + res = self.content.replace("{{DIAG}}", self.diag.render()) + if not res.strip(): + return "" + return res + + +class Diag: + def __init__( + self, + prefix, + diag_content, + category, + parsed_target_line_n, + line_is_absolute, + count, + line, + is_re, + whitespace_strings, + is_from_source_file, + ): + self.prefix = prefix + self.diag_content = diag_content + self.category = category + self.parsed_target_line_n = parsed_target_line_n + self.line_is_absolute = line_is_absolute + self.count = count + self.line = line + self.target = None + self.is_re = is_re + self.absolute_target() + self.whitespace_strings = whitespace_strings + self.is_from_source_file = is_from_source_file + + def decrement_count(self): + self.count -= 1 + assert self.count >= 0 + + def increment_count(self): + assert self.count >= 0 + self.count += 1 + + def unset_target(self): + assert self.target is not None + self.target.targeting_diags.remove(self) + self.target = None + + def set_target(self, target): + if self.target: + self.unset_target() + self.target = target + self.target.targeting_diags.append(self) + + def absolute_target(self): + if self.target: + return self.target.line_n + if self.line_is_absolute: + return self.parsed_target_line_n + return self.line.line_n + self.parsed_target_line_n + + def relative_target(self): + return self.absolute_target() - self.line.line_n + + def take(self, other_diag): + assert self.count == 0 + assert other_diag.count > 0 + assert other_diag.target == self.target + assert not other_diag.line_is_absolute + assert not other_diag.is_re and not self.is_re + self.line_is_absolute = False + self.diag_content = other_diag.diag_content + self.count = other_diag.count + self.category = other_diag.category + self.count = other_diag.count + other_diag.count = 0 + + def render(self): + assert self.count >= 0 + if self.count == 0: + return "" + line_location_s = "" + if self.relative_target() != 0: + if self.line_is_absolute: + line_location_s = f"@{self.absolute_target()}" + elif self.relative_target() > 0: + line_location_s = f"@+{self.relative_target()}" + else: + line_location_s = ( + f"@{self.relative_target()}" # the minus sign is implicit + ) + count_s = "" if self.count == 1 else f"{self.count}" + re_s = "-re" if self.is_re else "" + if self.whitespace_strings: + whitespace1_s = self.whitespace_strings[0] + whitespace2_s = self.whitespace_strings[1] + whitespace3_s = self.whitespace_strings[2] + else: + whitespace1_s = " " + whitespace2_s = "" + whitespace3_s = "" + if count_s and not whitespace2_s: + whitespace2_s = " " # required to parse correctly + elif not count_s and whitespace2_s == " ": + """Don't emit a weird extra space. + However if the whitespace is something other than the + standard single space, let it be to avoid disrupting manual formatting. + The existence of a non-empty whitespace2_s implies this was parsed with + a count > 1 and then decremented, otherwise this whitespace would have + been parsed as whitespace3_s. + """ + whitespace2_s = "" + return f"//{whitespace1_s}{self.prefix}-{self.category}{re_s}{line_location_s}{whitespace2_s}{count_s}{whitespace3_s}{{{{{self.diag_content}}}}}" + + +expected_diag_re = re.compile( + r"//(\s*)([a-zA-Z]+)-(note|warning|error)(-re)?(@[+-]?\d+)?(?:(\s*)(\d+))?(\s*)\{\{(.*)\}\}" +) + + +def parse_diag(line, filename, lines, prefix): + s = line.content + ms = expected_diag_re.findall(s) + if not ms: + return None + if len(ms) > 1: + raise KnownException( + f"multiple diags on line {filename}:{line.line_n}. Aborting due to missing implementation." + ) + [ + whitespace1_s, + check_prefix, + category_s, + re_s, + target_line_s, + whitespace2_s, + count_s, + whitespace3_s, + diag_s, + ] = ms[0] + if check_prefix != prefix: + return None + if not target_line_s: + target_line_n = 0 + is_absolute = False + elif target_line_s.startswith("@+"): + target_line_n = int(target_line_s[2:]) + is_absolute = False + elif target_line_s.startswith("@-"): + target_line_n = int(target_line_s[1:]) + is_absolute = False + else: + target_line_n = int(target_line_s[1:]) + is_absolute = True + count = int(count_s) if count_s else 1 + line.content = expected_diag_re.sub("{{DIAG}}", s) + + return Diag( + prefix, + diag_s, + category_s, + target_line_n, + is_absolute, + count, + line, + bool(re_s), + [whitespace1_s, whitespace2_s, whitespace3_s], + True, + ) + + +def add_line(new_line, lines): + lines.insert(new_line.line_n - 1, new_line) + for i in range(new_line.line_n, len(lines)): + line = lines[i] + assert line.line_n == i + line.update_line_n(i + 1) + assert all(line.line_n == i + 1 for i, line in enumerate(lines)) + + +def remove_line(old_line, lines): + lines.remove(old_line) + for i in range(old_line.line_n - 1, len(lines)): + line = lines[i] + assert line.line_n == i + 2 + line.update_line_n(i + 1) + assert all(line.line_n == i + 1 for i, line in enumerate(lines)) + + +indent_re = re.compile(r"\s*") + + +def get_indent(s): + return indent_re.match(s).group(0) + + +def orig_line_n_to_new_line_n(line_n, orig_lines): + return orig_lines[line_n - 1].line_n + + +def add_diag(orig_line_n, diag_s, diag_category, lines, orig_lines, prefix): + line_n = orig_line_n_to_new_line_n(orig_line_n, orig_lines) + target = lines[line_n - 1] + for other in target.targeting_diags: + if other.is_re: + raise KnownException( + "mismatching diag on line with regex matcher. Skipping due to missing implementation" + ) + reverse = ( + True + if [other for other in target.targeting_diags if other.relative_target() < 0] + else False + ) + + targeting = [ + other for other in target.targeting_diags if not other.line_is_absolute + ] + targeting.sort(reverse=reverse, key=lambda d: d.relative_target()) + prev_offset = 0 + prev_line = target + direction = -1 if reverse else 1 + for d in targeting: + if d.relative_target() != prev_offset + direction: + break + prev_offset = d.relative_target() + prev_line = d.line + total_offset = prev_offset - 1 if reverse else prev_offset + 1 + if reverse: + new_line_n = prev_line.line_n + 1 + else: + new_line_n = prev_line.line_n + assert new_line_n == line_n + (not reverse) - total_offset + + new_line = Line(get_indent(prev_line.content) + "{{DIAG}}\n", new_line_n) + add_line(new_line, lines) + + whitespace_strings = prev_line.diag.whitespace_strings if prev_line.diag else None + new_diag = Diag( + prefix, + diag_s, + diag_category, + total_offset, + False, + 1, + new_line, + False, + whitespace_strings, + False, + ) + new_line.diag = new_diag + new_diag.set_target(target) + + +def remove_dead_diags(lines): + for line in lines: + if not line.diag or line.diag.count != 0: + continue + if line.render() == "": + remove_line(line, lines) + else: + assert line.diag.is_from_source_file + for other_diag in line.targeting_diags: + if ( + other_diag.is_from_source_file + or other_diag.count == 0 + or other_diag.category != line.diag.category + ): + continue + if other_diag.is_re or line.diag.is_re: + continue + line.diag.take(other_diag) + remove_line(other_diag.line, lines) + + +def has_live_diags(lines): + for line in lines: + if line.diag and line.diag.count > 0: + return True + return False + + +def get_expected_no_diags_line_n(lines, prefix): + for line in lines: + if f"{prefix}-no-diagnostics" in line.content: + return line.line_n + return None + + +def update_test_file(filename, diag_errors, prefix, updated_test_files): + dprint(f"updating test file {filename}") + if filename in updated_test_files: + raise KnownException(f"{filename} already updated, but got new output") + else: + updated_test_files.add(filename) + with open(filename, "r") as f: + lines = [Line(line, i + 1) for i, line in enumerate(f.readlines())] + orig_lines = list(lines) + expected_no_diags_line_n = get_expected_no_diags_line_n(orig_lines, prefix) + + for line in lines: + diag = parse_diag(line, filename, lines, prefix) + if diag: + line.diag = diag + diag.set_target(lines[diag.absolute_target() - 1]) + + for line_n, diag_s, diag_category, seen in diag_errors: + if seen: + continue + # this is a diagnostic expected but not seen + assert lines[line_n - 1].diag + if diag_s != lines[line_n - 1].diag.diag_content: + raise KnownException( + f"{filename}:{line_n} - found diag {lines[line_n - 1].diag.diag_content} but expected {diag_s}" + ) + if diag_category != lines[line_n - 1].diag.category: + raise KnownException( + f"{filename}:{line_n} - found {lines[line_n - 1].diag.category} diag but expected {diag_category}" + ) + lines[line_n - 1].diag.decrement_count() + diag_errors_left = [] + diag_errors.sort(reverse=True, key=lambda t: t[0]) + for line_n, diag_s, diag_category, seen in diag_errors: + if not seen: + continue + target = orig_lines[line_n - 1] + other_diags = [ + d + for d in target.targeting_diags + if d.diag_content == diag_s and d.category == diag_category + ] + other_diag = other_diags[0] if other_diags else None + if other_diag: + other_diag.increment_count() + else: + add_diag(line_n, diag_s, diag_category, lines, orig_lines, prefix) + remove_dead_diags(lines) + has_diags = has_live_diags(lines) + with open(filename, "w") as f: + if not has_diags and expected_no_diags_line_n is None: + f.write("// expected-no-diagnostics\n") + for line in lines: + if has_diags and line.line_n == expected_no_diags_line_n: + continue + f.write(line.render()) + + +def update_test_files(errors, prefix): + errors_by_file = {} + for (filename, line, diag_s), (diag_category, seen) in errors: + if filename not in errors_by_file: + errors_by_file[filename] = [] + errors_by_file[filename].append((line, diag_s, diag_category, seen)) + updated_test_files = set() + for filename, diag_errors in errors_by_file.items(): + try: + update_test_file(filename, diag_errors, prefix, updated_test_files) + except KnownException as e: + return f"Error in update-verify-tests while updating {filename}: {e}" + updated_files = list(updated_test_files) + assert updated_files + if len(updated_files) == 1: + return f"updated file {updated_files[0]}" + updated_files_s = "\n\t".join(updated_files) + return "updated files:\n\t{updated_files_s}" + + +def check_expectations(tool_output, prefix): + """ + The entry point function. + Called by the stand-alone update-verify-tests.py as well as litplugin.py. + """ + curr = [] + curr_category = None + try: + for line in tool_output: + if line.startswith("error: "): + curr_category = parse_error_category(line[len("error: ") :], prefix) + continue + + diag_error = parse_diag_error(line.strip()) + if diag_error: + curr.append((diag_error, curr_category)) + else: + dprint("no match") + dprint(line.strip()) + except KnownException as e: + return f"Error in update-verify-tests while parsing tool output: {e}" + if curr: + return update_test_files(curr, prefix) + else: + return "no mismatching diagnostics found" + From 8bff12a5805fef891f9e2dc5355da3b9f6d99044 Mon Sep 17 00:00:00 2001 From: "Henrik G. Olsson" Date: Wed, 5 Nov 2025 15:50:48 -0800 Subject: [PATCH 2/3] [utils] port update-verify-tests to Swift's -verify This ports clang's test suite for update-verify-tests from C to Swift, and adjusts update-verify-tests as needed. The main differences are the Swift -verify output format being quite different, as well as Swift's '-verify-additional-prefix foo' working differently than clang's '-verify=foo'. --- .../update-verify-tests/diag-at-eof.swift | 15 ++ .../update-verify-tests/duplicate-diag.swift | 27 +++ .../infer-indentation.swift | 28 +++ .../leave-existing-diags.swift | 32 +++ .../update-verify-tests/multiple-errors.swift | 24 +++ .../multiple-missing-errors-same-line.swift | 30 +++ .../Utils/update-verify-tests/no-checks.swift | 16 ++ test/Utils/update-verify-tests/no-diags.swift | 16 ++ .../non-default-prefix.swift | 24 +++ .../update-same-line.swift | 17 ++ .../update-single-check.swift | 19 ++ .../update-verify-tests/wrong-category.swift | 19 ++ test/lit.cfg | 3 + utils/update-verify-tests.py | 7 +- utils/update_verify_tests/core.py | 188 ++++++++++++------ 15 files changed, 396 insertions(+), 69 deletions(-) create mode 100644 test/Utils/update-verify-tests/diag-at-eof.swift create mode 100644 test/Utils/update-verify-tests/duplicate-diag.swift create mode 100644 test/Utils/update-verify-tests/infer-indentation.swift create mode 100644 test/Utils/update-verify-tests/leave-existing-diags.swift create mode 100644 test/Utils/update-verify-tests/multiple-errors.swift create mode 100644 test/Utils/update-verify-tests/multiple-missing-errors-same-line.swift create mode 100644 test/Utils/update-verify-tests/no-checks.swift create mode 100644 test/Utils/update-verify-tests/no-diags.swift create mode 100644 test/Utils/update-verify-tests/non-default-prefix.swift create mode 100644 test/Utils/update-verify-tests/update-same-line.swift create mode 100644 test/Utils/update-verify-tests/update-single-check.swift create mode 100644 test/Utils/update-verify-tests/wrong-category.swift diff --git a/test/Utils/update-verify-tests/diag-at-eof.swift b/test/Utils/update-verify-tests/diag-at-eof.swift new file mode 100644 index 0000000000000..f0cf41c353e08 --- /dev/null +++ b/test/Utils/update-verify-tests/diag-at-eof.swift @@ -0,0 +1,15 @@ +// RUN: %empty-directory(%t) +// RUN: split-file %s %t + +// RUN: not %target-swift-frontend-verify -typecheck %t/test.swift 2>&1 | %update-verify-tests +// RUN: %target-swift-frontend-verify -typecheck %t/test.swift +// RUN: %diff %t/test.swift %t/test.swift.expected + +//--- test.swift +func foo() { + +//--- test.swift.expected +// expected-note@+1{{to match this opening '{'}} +func foo() { + +// expected-error@+1{{expected '}' at end of brace statement}} diff --git a/test/Utils/update-verify-tests/duplicate-diag.swift b/test/Utils/update-verify-tests/duplicate-diag.swift new file mode 100644 index 0000000000000..02ac1beaab443 --- /dev/null +++ b/test/Utils/update-verify-tests/duplicate-diag.swift @@ -0,0 +1,27 @@ +// RUN: %empty-directory(%t) +// RUN: split-file %s %t + +// RUN: not %target-swift-frontend-verify -typecheck %t/test.swift 2>&1 | %update-verify-tests +// RUN: %target-swift-frontend-verify -typecheck %t/test.swift +// RUN: %diff %t/test.swift %t/test.swift.expected + +//--- test.swift +func foo() { + // expected-error@+1{{cannot find 'a' in scope}} + a = 2; a = 2; + b = 2; b = 2; + + // expected-error@+1 3{{cannot find 'c' in scope}} + c = 2; c = 2; + // expected-error 3{{asdf}} +} +//--- test.swift.expected +func foo() { + // expected-error@+1 2{{cannot find 'a' in scope}} + a = 2; a = 2; + // expected-error@+1 2{{cannot find 'b' in scope}} + b = 2; b = 2; + + // expected-error@+1 2{{cannot find 'c' in scope}} + c = 2; c = 2; +} diff --git a/test/Utils/update-verify-tests/infer-indentation.swift b/test/Utils/update-verify-tests/infer-indentation.swift new file mode 100644 index 0000000000000..0fecc83e27872 --- /dev/null +++ b/test/Utils/update-verify-tests/infer-indentation.swift @@ -0,0 +1,28 @@ +// RUN: %empty-directory(%t) +// RUN: split-file %s %t + +// RUN: not %target-swift-frontend-verify -typecheck %t/test.swift 2>&1 | %update-verify-tests +// RUN: %target-swift-frontend-verify -typecheck %t/test.swift +// RUN: %diff %t/test.swift %t/test.swift.expected + +//--- test.swift +func foo() { + // expected-error@+1 2{{cannot find 'a' in scope}} + a = 2; a = 2; b = 2; b = 2; c = 2; + // expected-error@+1 2{{asdf}} + d = 2; + e = 2; f = 2; // expected-error 2{{cannot find 'e' in scope}} +} + +//--- test.swift.expected +func foo() { + // expected-error@+3 {{cannot find 'c' in scope}} + // expected-error@+2 2{{cannot find 'b' in scope}} + // expected-error@+1 2{{cannot find 'a' in scope}} + a = 2; a = 2; b = 2; b = 2; c = 2; + // expected-error@+1 {{cannot find 'd' in scope}} + d = 2; + // expected-error@+1 {{cannot find 'f' in scope}} + e = 2; f = 2; // expected-error {{cannot find 'e' in scope}} +} + diff --git a/test/Utils/update-verify-tests/leave-existing-diags.swift b/test/Utils/update-verify-tests/leave-existing-diags.swift new file mode 100644 index 0000000000000..4cb3e12c87932 --- /dev/null +++ b/test/Utils/update-verify-tests/leave-existing-diags.swift @@ -0,0 +1,32 @@ +// RUN: %empty-directory(%t) +// RUN: split-file %s %t + +// RUN: not %target-swift-frontend-verify -typecheck %t/test.swift 2>&1 | %update-verify-tests +// RUN: %target-swift-frontend-verify -typecheck %t/test.swift +// RUN: %diff %t/test.swift %t/test.swift.expected + +//--- test.swift +func foo() { + a = 2; + // expected-error@-1{{cannot find 'a' in scope}} + b = 2;// expected-error{{cannot find 'b' in scope}} + c = 2; + // expected-error@5{{cannot find 'c' in scope}} + d = 2; // expected-error{{'d' in scope}} + + e = 2; // error to trigger mismatch +} + +//--- test.swift.expected +func foo() { + a = 2; + // expected-error@-1{{cannot find 'a' in scope}} + b = 2;// expected-error{{cannot find 'b' in scope}} + c = 2; + // expected-error@5{{cannot find 'c' in scope}} + d = 2; // expected-error{{'d' in scope}} + + // expected-error@+1{{cannot find 'e' in scope}} + e = 2; // error to trigger mismatch +} + diff --git a/test/Utils/update-verify-tests/multiple-errors.swift b/test/Utils/update-verify-tests/multiple-errors.swift new file mode 100644 index 0000000000000..dbc6b72b36d53 --- /dev/null +++ b/test/Utils/update-verify-tests/multiple-errors.swift @@ -0,0 +1,24 @@ +// RUN: %empty-directory(%t) +// RUN: split-file %s %t + +// RUN: not %target-swift-frontend-verify -typecheck %t/test.swift 2>&1 | %update-verify-tests +// RUN: %target-swift-frontend-verify -typecheck %t/test.swift +// RUN: %diff %t/test.swift %t/test.swift.expected + +//--- test.swift +func foo() { + a = 2 + b = 2 + + c = 2 +} +//--- test.swift.expected +func foo() { + // expected-error@+1{{cannot find 'a' in scope}} + a = 2 + // expected-error@+1{{cannot find 'b' in scope}} + b = 2 + + // expected-error@+1{{cannot find 'c' in scope}} + c = 2 +} diff --git a/test/Utils/update-verify-tests/multiple-missing-errors-same-line.swift b/test/Utils/update-verify-tests/multiple-missing-errors-same-line.swift new file mode 100644 index 0000000000000..894b941df2e95 --- /dev/null +++ b/test/Utils/update-verify-tests/multiple-missing-errors-same-line.swift @@ -0,0 +1,30 @@ +// RUN: %empty-directory(%t) +// RUN: split-file %s %t + +// RUN: not %target-swift-frontend-verify -typecheck %t/test.swift 2>&1 | %update-verify-tests +// RUN: %target-swift-frontend-verify -typecheck %t/test.swift +// RUN: %diff %t/test.swift %t/test.swift.expected + +//--- test.swift +func foo() { + a = 2; b = 2; c = 2; +} + +func bar() { + x = 2; y = 2; z = 2; + // expected-error@-1{{cannot find 'x' in scope}} +} +//--- test.swift.expected +func foo() { + // expected-error@+3{{cannot find 'c' in scope}} + // expected-error@+2{{cannot find 'b' in scope}} + // expected-error@+1{{cannot find 'a' in scope}} + a = 2; b = 2; c = 2; +} + +func bar() { + x = 2; y = 2; z = 2; + // expected-error@-1{{cannot find 'x' in scope}} + // expected-error@-2{{cannot find 'y' in scope}} + // expected-error@-3{{cannot find 'z' in scope}} +} diff --git a/test/Utils/update-verify-tests/no-checks.swift b/test/Utils/update-verify-tests/no-checks.swift new file mode 100644 index 0000000000000..098b527753910 --- /dev/null +++ b/test/Utils/update-verify-tests/no-checks.swift @@ -0,0 +1,16 @@ +// RUN: %empty-directory(%t) +// RUN: split-file %s %t + +// RUN: not %target-swift-frontend-verify -typecheck %t/test.swift 2>&1 | %update-verify-tests +// RUN: %target-swift-frontend-verify -typecheck %t/test.swift +// RUN: %diff %t/test.swift %t/test.swift.expected + +//--- test.swift +func foo() { + bar = 2 +} +//--- test.swift.expected +func foo() { + // expected-error@+1{{cannot find 'bar' in scope}} + bar = 2 +} diff --git a/test/Utils/update-verify-tests/no-diags.swift b/test/Utils/update-verify-tests/no-diags.swift new file mode 100644 index 0000000000000..663429207c61e --- /dev/null +++ b/test/Utils/update-verify-tests/no-diags.swift @@ -0,0 +1,16 @@ +// RUN: %empty-directory(%t) +// RUN: split-file %s %t + +// RUN: not %target-swift-frontend-verify -typecheck %t/test.swift 2>&1 | %update-verify-tests +// RUN: %target-swift-frontend-verify -typecheck %t/test.swift +// RUN: %diff %t/test.swift %t/test.swift.expected + +//--- test.swift +func foo() { + // expected-error@+1{{asdf}} + let _ = 2 +} +//--- test.swift.expected +func foo() { + let _ = 2 +} diff --git a/test/Utils/update-verify-tests/non-default-prefix.swift b/test/Utils/update-verify-tests/non-default-prefix.swift new file mode 100644 index 0000000000000..a8ba434adf87b --- /dev/null +++ b/test/Utils/update-verify-tests/non-default-prefix.swift @@ -0,0 +1,24 @@ +// RUN: %empty-directory(%t) +// RUN: split-file %s %t + +// RUN: not %target-swift-frontend-verify -verify-additional-prefix check- -typecheck %t/test.swift 2>&1 | %update-verify-tests --prefix check- +// RUN: %target-swift-frontend-verify -typecheck %t/test.swift -verify-additional-prefix check- +// RUN: %diff %t/test.swift %t/test.swift.expected + +//--- test.swift +func foo() { + a = 2 + // expected-check-error{{foo}} + // expected-error{{bar}} + + // expected-error@+1{{baz}} + b = 3 +} +//--- test.swift.expected +func foo() { + // expected-check-error@+1{{cannot find 'a' in scope}} + a = 2 + + // expected-error@+1{{cannot find 'b' in scope}} + b = 3 +} diff --git a/test/Utils/update-verify-tests/update-same-line.swift b/test/Utils/update-verify-tests/update-same-line.swift new file mode 100644 index 0000000000000..74789b48567e5 --- /dev/null +++ b/test/Utils/update-verify-tests/update-same-line.swift @@ -0,0 +1,17 @@ +// RUN: %empty-directory(%t) +// RUN: split-file %s %t + +// RUN: not %target-swift-frontend-verify -typecheck %t/test.swift 2>&1 | %update-verify-tests +// RUN: %target-swift-frontend-verify -typecheck %t/test.swift +// RUN: %diff %t/test.swift %t/test.swift.expected + +//--- test.swift +func foo() { + bar = 2 // expected-error {{asdf}} +} + +//--- test.swift.expected +func foo() { + bar = 2 // expected-error {{cannot find 'bar' in scope}} +} + diff --git a/test/Utils/update-verify-tests/update-single-check.swift b/test/Utils/update-verify-tests/update-single-check.swift new file mode 100644 index 0000000000000..f4fc631033846 --- /dev/null +++ b/test/Utils/update-verify-tests/update-single-check.swift @@ -0,0 +1,19 @@ +// RUN: %empty-directory(%t) +// RUN: split-file %s %t + +// RUN: not %target-swift-frontend-verify -typecheck %t/test.swift 2>&1 | %update-verify-tests +// RUN: %target-swift-frontend-verify -typecheck %t/test.swift +// RUN: %diff %t/test.swift %t/test.swift.expected + +//--- test.swift +func foo() { + // expected-error@+1{{AAA}} + bar = 2 +} + +//--- test.swift.expected +func foo() { + // expected-error@+1{{cannot find 'bar' in scope}} + bar = 2 +} + diff --git a/test/Utils/update-verify-tests/wrong-category.swift b/test/Utils/update-verify-tests/wrong-category.swift new file mode 100644 index 0000000000000..c6803bf1fa6fc --- /dev/null +++ b/test/Utils/update-verify-tests/wrong-category.swift @@ -0,0 +1,19 @@ +// RUN: %empty-directory(%t) +// RUN: split-file %s %t + +// RUN: not %target-swift-frontend-verify -typecheck %t/test.swift 2>&1 | %update-verify-tests +// RUN: %target-swift-frontend-verify -typecheck %t/test.swift +// RUN: %diff %t/test.swift %t/test.swift.expected + +//--- test.swift +func foo() { + // expected-warning@+1{{cannot find 'bar' in scope}} + bar = 2 +} + +//--- test.swift.expected +func foo() { + // expected-error@+1{{cannot find 'bar' in scope}} + bar = 2 +} + diff --git a/test/lit.cfg b/test/lit.cfg index b4800b1e16507..65aa8d8cb8fc4 100644 --- a/test/lit.cfg +++ b/test/lit.cfg @@ -766,6 +766,9 @@ config.substitutions.append( ('%validate-json', f"{config.python} -m json.tool") config.clang_include_dir = make_path(config.llvm_obj_root, 'include') config.substitutions.append( ('%clang-include-dir', config.clang_include_dir) ) +config.update_verify_tests = make_path(config.swift_utils, "update-verify-tests.py") +config.substitutions.append( ('%update-verify-tests', '%s %s' % (config.python, config.update_verify_tests)) ) + config.swift_include_dir = make_path(config.swift_obj_root, 'include') config.substitutions.append( ('%swift-include-dir', config.swift_include_dir) ) diff --git a/utils/update-verify-tests.py b/utils/update-verify-tests.py index 88701aaff34d2..b2beb137d3cdf 100644 --- a/utils/update-verify-tests.py +++ b/utils/update-verify-tests.py @@ -1,6 +1,6 @@ import sys import argparse -from UpdateVerifyTests.core import check_expectations +from update_verify_tests.core import check_expectations """ Pipe output from clang's -verify into this script to have the test case updated to expect the actual diagnostic output. @@ -27,11 +27,12 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( - "--prefix", default="expected", help="The prefix passed to -verify" + "--prefix", default="", help="The prefix passed to -verify" ) args = parser.parse_args() - output = check_expectations(sys.stdin.readlines(), args.prefix) + (ret_code, output) = check_expectations(sys.stdin.readlines(), args.prefix) print(output) + sys.exit(ret_code) if __name__ == "__main__": diff --git a/utils/update_verify_tests/core.py b/utils/update_verify_tests/core.py index 74210ea12cb7a..20ad70d393b9b 100644 --- a/utils/update_verify_tests/core.py +++ b/utils/update_verify_tests/core.py @@ -34,19 +34,6 @@ def parse_error_category(s, prefix): return (diag_category, seen) -diag_error_re = re.compile(r"File (\S+) Line (\d+): (.+)") -diag_error_re2 = re.compile(r"File \S+ Line \d+ \(directive at (\S+):(\d+)\): (.+)") - - -def parse_diag_error(s): - m = diag_error_re2.match(s) - if not m: - m = diag_error_re.match(s) - if not m: - return None - return (m.group(1), int(m.group(2)), m.group(3)) - - class Line: def __init__(self, content, line_n): self.content = content @@ -171,15 +158,15 @@ def render(self): been parsed as whitespace3_s. """ whitespace2_s = "" - return f"//{whitespace1_s}{self.prefix}-{self.category}{re_s}{line_location_s}{whitespace2_s}{count_s}{whitespace3_s}{{{{{self.diag_content}}}}}" + return f"//{whitespace1_s}expected-{self.prefix}{self.category}{re_s}{line_location_s}{whitespace2_s}{count_s}{whitespace3_s}{{{{{self.diag_content}}}}}" expected_diag_re = re.compile( - r"//(\s*)([a-zA-Z]+)-(note|warning|error)(-re)?(@[+-]?\d+)?(?:(\s*)(\d+))?(\s*)\{\{(.*)\}\}" + r"//(\s*)expected-([a-zA-Z-]*)(note|warning|error)(-re)?(@[+-]?\d+)?(?:(\s*)(\d+))?(\s*)\{\{(.*)\}\}" ) -def parse_diag(line, filename, lines, prefix): +def parse_diag(line, filename, prefix): s = line.content ms = expected_diag_re.findall(s) if not ms: @@ -199,7 +186,7 @@ def parse_diag(line, filename, lines, prefix): whitespace3_s, diag_s, ] = ms[0] - if check_prefix != prefix: + if check_prefix != prefix and check_prefix != "": return None if not target_line_s: target_line_n = 0 @@ -217,7 +204,7 @@ def parse_diag(line, filename, lines, prefix): line.content = expected_diag_re.sub("{{DIAG}}", s) return Diag( - prefix, + check_prefix, diag_s, category_s, target_line_n, @@ -333,20 +320,6 @@ def remove_dead_diags(lines): remove_line(other_diag.line, lines) -def has_live_diags(lines): - for line in lines: - if line.diag and line.diag.count > 0: - return True - return False - - -def get_expected_no_diags_line_n(lines, prefix): - for line in lines: - if f"{prefix}-no-diagnostics" in line.content: - return line.line_n - return None - - def update_test_file(filename, diag_errors, prefix, updated_test_files): dprint(f"updating test file {filename}") if filename in updated_test_files: @@ -354,63 +327,60 @@ def update_test_file(filename, diag_errors, prefix, updated_test_files): else: updated_test_files.add(filename) with open(filename, "r") as f: - lines = [Line(line, i + 1) for i, line in enumerate(f.readlines())] + lines = [Line(line, i + 1) for i, line in enumerate(f.readlines() + [''])] orig_lines = list(lines) - expected_no_diags_line_n = get_expected_no_diags_line_n(orig_lines, prefix) for line in lines: - diag = parse_diag(line, filename, lines, prefix) + diag = parse_diag(line, filename, prefix) if diag: line.diag = diag diag.set_target(lines[diag.absolute_target() - 1]) - for line_n, diag_s, diag_category, seen in diag_errors: - if seen: + for diag_error in diag_errors: + if not isinstance(diag_error, NotFoundDiag): continue # this is a diagnostic expected but not seen + line_n = diag_error.line assert lines[line_n - 1].diag - if diag_s != lines[line_n - 1].diag.diag_content: + if not lines[line_n - 1].diag or diag_error.content != lines[line_n - 1].diag.diag_content: raise KnownException( - f"{filename}:{line_n} - found diag {lines[line_n - 1].diag.diag_content} but expected {diag_s}" + f"{filename}:{line_n} - found diag {lines[line_n - 1].diag.diag_content} but expected {diag_error.content}" ) - if diag_category != lines[line_n - 1].diag.category: + if diag_error.category != lines[line_n - 1].diag.category: raise KnownException( - f"{filename}:{line_n} - found {lines[line_n - 1].diag.category} diag but expected {diag_category}" + f"{filename}:{line_n} - found {lines[line_n - 1].diag.category} diag but expected {diag_error.category}" ) lines[line_n - 1].diag.decrement_count() - diag_errors_left = [] - diag_errors.sort(reverse=True, key=lambda t: t[0]) - for line_n, diag_s, diag_category, seen in diag_errors: - if not seen: + + diag_errors.sort(reverse=True, key=lambda t: t.line) + for diag_error in diag_errors: + if not isinstance(diag_error, ExtraDiag): continue + line_n = diag_error.line target = orig_lines[line_n - 1] other_diags = [ d for d in target.targeting_diags - if d.diag_content == diag_s and d.category == diag_category + if d.diag_content == diag_error.content and d.category == diag_error.category ] other_diag = other_diags[0] if other_diags else None if other_diag: other_diag.increment_count() else: - add_diag(line_n, diag_s, diag_category, lines, orig_lines, prefix) + add_diag(line_n, diag_error.content, diag_error.category, lines, orig_lines, diag_error.prefix) remove_dead_diags(lines) - has_diags = has_live_diags(lines) with open(filename, "w") as f: - if not has_diags and expected_no_diags_line_n is None: - f.write("// expected-no-diagnostics\n") for line in lines: - if has_diags and line.line_n == expected_no_diags_line_n: - continue f.write(line.render()) def update_test_files(errors, prefix): errors_by_file = {} - for (filename, line, diag_s), (diag_category, seen) in errors: + for error in errors: + filename = error.file if filename not in errors_by_file: errors_by_file[filename] = [] - errors_by_file[filename].append((line, diag_s, diag_category, seen)) + errors_by_file[filename].append(error) updated_test_files = set() for filename, diag_errors in errors_by_file.items(): try: @@ -425,29 +395,115 @@ def update_test_files(errors, prefix): return "updated files:\n\t{updated_files_s}" +""" +ex: +test.swift:2:6: error: expected error not produced + // expected-error@+1{{asdf}} +~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~ +""" +diag_error_re = re.compile(r"(\S+):(\d+):(\d+): error: expected (\S+) not produced") + + +""" +ex: +test.swift:2:3: error: unexpected error produced: cannot find 'a' in scope + a = 2 + ^ +""" +diag_error_re2 = re.compile(r"(\S+):(\d+):(\d+): error: unexpected (\S+) produced: (.*)") + + +""" +ex: +test.swift:2:43: error: incorrect message found + bar = 2 // expected-error{{asdf}} + ^~~~ + cannot find 'bar' in scope +""" +diag_error_re3 = re.compile(r"(\S+):(\d+):(\d+): error: incorrect message found") + + +""" +ex: +test.swift:2:15: error: expected warning, not error + // expected-warning@+1{{cannot find 'bar' in scope}} + ^~~~~~~ + error +""" +diag_error_re4 = re.compile(r"(\S+):(\d+):(\d+): error: expected (\S+), not (\S+)") + + +class NotFoundDiag: + def __init__(self, file, line, col, category, content, prefix): + self.file = file + self.line = line + self.col = col + self.category = category + self.content = content + self.prefix = prefix + + def __str__(self): + return f"{self.file}:{self.line}:{self.col}: error expected {self.category} not produced (expected {self.content})" + + +class ExtraDiag: + def __init__(self, file, line, col, category, content, prefix): + self.file = file + self.line = line + self.col = col + self.category = category + self.content = content + self.prefix = prefix + + def __str__(self): + return f"{self.file}:{self.line}:{self.col}: error unexpected {self.category} produced: {self.content}" + + def check_expectations(tool_output, prefix): """ The entry point function. Called by the stand-alone update-verify-tests.py as well as litplugin.py. """ curr = [] - curr_category = None try: - for line in tool_output: - if line.startswith("error: "): - curr_category = parse_error_category(line[len("error: ") :], prefix) - continue - - diag_error = parse_diag_error(line.strip()) - if diag_error: - curr.append((diag_error, curr_category)) + i = 0 + while i < len(tool_output): + line = tool_output[i].strip() + + if not "error:" in line: + pass + elif m := diag_error_re.match(line): + diag = parse_diag(Line(tool_output[i+1], int(m.group(2))), m.group(1), prefix) + i += 2 + curr.append(NotFoundDiag(m.group(1), int(m.group(2)), int(m.group(3)), m.group(4), diag.diag_content, diag.prefix)) + elif m := diag_error_re2.match(line): + curr.append(ExtraDiag(m.group(1), int(m.group(2)), int(m.group(3)), m.group(4), m.group(5), prefix)) + i += 2 + # Create two mirroring mismatches when the compiler reports that the category or diagnostic is incorrect. + # This makes it easier to handle cases where the same diagnostic is mentioned both in an incorrect message/category + # diagnostic, as well as in an error not produced diagnostic. This can happen for things like 'expected-error 2{{foo}}' + # if only one diagnostic is emitted on that line, and the content of that diagnostic is actually 'bar'. + elif m := diag_error_re3.match(line): + diag = parse_diag(Line(tool_output[i+1], int(m.group(2))), m.group(1), prefix) + curr.append(NotFoundDiag(m.group(1), int(m.group(2)), int(m.group(3)), diag.category, diag.diag_content, diag.prefix)) + curr.append(ExtraDiag(m.group(1), diag.absolute_target(), int(m.group(3)), diag.category, tool_output[i+3].strip(), diag.prefix)) + i += 3 + elif m := diag_error_re4.match(line): + diag = parse_diag(Line(tool_output[i+1], int(m.group(2))), m.group(1), prefix) + assert diag.category == m.group(4) + assert tool_output[i+3].strip() == m.group(5) + curr.append(NotFoundDiag(m.group(1), int(m.group(2)), int(m.group(3)), diag.category, diag.diag_content, diag.prefix)) + curr.append(ExtraDiag(m.group(1), diag.absolute_target(), int(m.group(3)), m.group(5), diag.diag_content, diag.prefix)) + i += 3 else: dprint("no match") dprint(line.strip()) + i += 1 + except KnownException as e: - return f"Error in update-verify-tests while parsing tool output: {e}" + return (1, f"Error in update-verify-tests while parsing tool output: {e}") if curr: - return update_test_files(curr, prefix) + return (0, update_test_files(curr, prefix)) else: - return "no mismatching diagnostics found" + return (1, "no mismatching diagnostics found") From 0979d340d183aeb77d959a04f3c9d00f60d63e3b Mon Sep 17 00:00:00 2001 From: "Henrik G. Olsson" Date: Wed, 5 Nov 2025 15:59:56 -0800 Subject: [PATCH 3/3] [utils] update documentation for update-verify-tests This updates the documentation to reflect the Swift port of this script, rather than the original clang version. --- utils/update-verify-tests.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/utils/update-verify-tests.py b/utils/update-verify-tests.py index b2beb137d3cdf..de7c5fdeb60c5 100644 --- a/utils/update-verify-tests.py +++ b/utils/update-verify-tests.py @@ -3,7 +3,7 @@ from update_verify_tests.core import check_expectations """ - Pipe output from clang's -verify into this script to have the test case updated to expect the actual diagnostic output. + Pipe output from swift-frontend's -verify into this script to have the test case updated to expect the actual diagnostic output. When inserting new expected-* checks it will place them on the line before the location of the diagnostic, with an @+1, or @+N for some N if there are multiple diagnostics emitted on the same line. If the current checks are using @-N for this line, the new check will follow that convention also. @@ -11,16 +11,20 @@ diffs. If inaccurate their count will be updated, or the check removed entirely. Missing features: - - multiple prefixes on the same line (-verify=my-prefix,my-other-prefix) - - multiple prefixes on separate RUN lines (RUN: -verify=my-prefix\nRUN: -verify my-other-prefix) - - regexes with expected-*-re: existing ones will be left untouched if accurate, but the script will abort if there are any - diagnostic mismatches on the same line. + - multiple prefixes on the same line (-verify-additional-prefix my-prefix -verify-additional-prefix my-other-prefix) + - multiple prefixes on separate RUN lines (RUN: -verify-additional-prefix my-prefix\nRUN: -verify-additional-prefix my-other-prefix) + - regexes matchers - multiple checks targeting the same line are supported, but a line may only contain one check - if multiple checks targeting the same line are failing the script is not guaranteed to produce a minimal diff + - remarks + - expansions + - columns + - fix-its + - doc files Example usage: - clang -verify [file] | python3 update-verify-tests.py - clang -verify=check [file] | python3 update-verify-tests.py --prefix check + swift-frontend -verify [file] | python3 update-verify-tests.py + swift-frontend -verify -verify-additional-prefix check- [file] | python3 update-verify-tests.py --prefix check- """