Files
swift-mirror/utils/update_verify_tests/core.py
Henrik G. Olsson d629a0089a [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.
2025-11-05 16:02:03 -08:00

454 lines
14 KiB
Python

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"