update_checks.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. #!/usr/bin/env python3
  2. """Updates the CHECK: lines in lit tests based on the AUTOUPDATE line."""
  3. __copyright__ = """
  4. Part of the Carbon Language project, under the Apache License v2.0 with LLVM
  5. Exceptions. See /LICENSE for license information.
  6. SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
  7. """
  8. from concurrent import futures
  9. import os
  10. import re
  11. import subprocess
  12. import sys
  13. from abc import ABC, abstractmethod
  14. from typing import Any, Dict, List, Optional, Set
  15. _BIN = "./bazel-bin/explorer/explorer"
  16. _TESTDATA = "explorer/testdata"
  17. # A prefix followed by a command to run for autoupdating checked output.
  18. _AUTOUPDATE_MARKER = "// AUTOUPDATE: "
  19. # Indicates no autoupdate is requested.
  20. _NOAUTOUPDATE_MARKER = "// NOAUTOUPDATE"
  21. # A regexp matching lines that contain line number references.
  22. _LINE_NUMBER_RE = r"((?:COMPILATION|RUNTIME) ERROR: [^:]*:)([1-9][0-9]*)(:.*)"
  23. def _get_tests() -> Set[str]:
  24. """Get the list of tests from the filesystem."""
  25. tests = set()
  26. for root, _, files in os.walk(_TESTDATA):
  27. for f in files:
  28. if f in {"lit.cfg.py", "BUILD"}:
  29. # Ignore the lit config.
  30. continue
  31. if os.path.splitext(f)[1] == ".carbon":
  32. tests.add(os.path.join(root, f))
  33. else:
  34. sys.exit("Unrecognized file type in testdata: %s" % f)
  35. return tests
  36. class Line(ABC):
  37. """A line that may appear in the resulting test file."""
  38. @abstractmethod
  39. def format(
  40. self, *, output_line_number: int, line_number_remap: Dict[int, int]
  41. ) -> str:
  42. raise NotImplementedError
  43. class OriginalLine(Line):
  44. """A line that was copied from the original test file."""
  45. def __init__(self, line_number: int, text: str) -> None:
  46. self.line_number = line_number
  47. self.text = text
  48. def format(self, **kwargs: Any) -> str:
  49. return self.text
  50. class CheckLine(Line):
  51. """A `// CHECK:` line generated from the test output."""
  52. def __init__(self) -> None:
  53. self.indent = ""
  54. @staticmethod
  55. def escape(s: str) -> str:
  56. """Escape any FileCheck special characters in `s`."""
  57. return s.replace("{{", "{{[{][{]}}").replace("[[", "{{[[][[]}}")
  58. def print_before_line(self, line: int) -> bool:
  59. """Determine if we'd prefer to print this CHECK before line `line`."""
  60. return True
  61. class SimpleCheckLine(CheckLine):
  62. """A `// CHECK:` line that checks for an exact string."""
  63. def __init__(self, expected: str) -> None:
  64. super().__init__()
  65. self.expected = expected
  66. def format(self, **kwargs: Any) -> str:
  67. if self.expected:
  68. return f"{self.indent}// CHECK: {self.expected}\n"
  69. else:
  70. return f"{self.indent}// CHECK-EMPTY:\n"
  71. class CheckLineWithLineNumber(CheckLine):
  72. """A `// CHECK:` line where the expected output includes a line number.
  73. Such result lines need to be fixed up after we've figured out which lines
  74. to include in the resulting test file and in what order, because their
  75. contents depend on where an original input line appears in the output.
  76. """
  77. def __init__(self, before: str, line_number: int, after: str) -> None:
  78. super().__init__()
  79. self.before = before
  80. self.line_number = line_number
  81. self.after = after
  82. def format(
  83. self, *, output_line_number: int, line_number_remap: Dict[int, int]
  84. ) -> str:
  85. delta = line_number_remap[self.line_number] - output_line_number
  86. # We use `:+d` here to produce `LINE-n` or `LINE+n` as appropriate.
  87. return (
  88. f"{self.indent}// CHECK: {self.before}[[@LINE{delta:+d}]]"
  89. + f"{self.after}\n"
  90. )
  91. def print_before_line(self, line: int) -> bool:
  92. return line >= self.line_number
  93. def _make_check_line(out_line: str) -> CheckLine:
  94. """Given a line of output, determine what CHECK line to produce."""
  95. out_line = out_line.rstrip()
  96. match = re.match(_LINE_NUMBER_RE, out_line)
  97. if match:
  98. # Convert from 1-based line numbers to 0-based indexes.
  99. diagnostic_line_number = int(match[2]) - 1
  100. return CheckLineWithLineNumber(
  101. match[1], diagnostic_line_number, match[3]
  102. )
  103. else:
  104. return SimpleCheckLine(out_line)
  105. def _should_produce_check_line(
  106. check_line: CheckLine,
  107. orig_line: Optional[OriginalLine],
  108. autoupdate_index: int,
  109. ) -> bool:
  110. """Determine whether it's time to produce a given CHECK line."""
  111. if not orig_line:
  112. # If there's no original line, we have no choice.
  113. return True
  114. if orig_line.line_number <= autoupdate_index:
  115. # Don't put any CHECK lines before the AUTOUPDATE line.
  116. return False
  117. return check_line.print_before_line(orig_line.line_number)
  118. def _update_check_once(test: str) -> bool:
  119. """Updates the CHECK: lines for `test` by running explorer.
  120. Returns True if the number of lines changes.
  121. """
  122. with open(test) as f:
  123. orig_lines = f.readlines()
  124. # Remove old OUT.
  125. autoupdate_index = None
  126. noautoupdate_index = None
  127. for line_index, line in enumerate(orig_lines):
  128. if line.startswith(_AUTOUPDATE_MARKER):
  129. autoupdate_index = line_index
  130. autoupdate_cmd = line[len(_AUTOUPDATE_MARKER) :]
  131. if line.startswith(_NOAUTOUPDATE_MARKER):
  132. noautoupdate_index = line_index
  133. if autoupdate_index is None:
  134. if noautoupdate_index is None:
  135. raise ValueError(
  136. "%s must have either '%s' or '%s'"
  137. % (test, _AUTOUPDATE_MARKER, _NOAUTOUPDATE_MARKER)
  138. )
  139. else:
  140. return False
  141. elif noautoupdate_index is not None:
  142. raise ValueError(
  143. "%s has both '%s' and '%s', must have only one"
  144. % (test, _AUTOUPDATE_MARKER, _NOAUTOUPDATE_MARKER)
  145. )
  146. # Mirror lit.cfg.py substitutions; bazel runs don't need --prelude.
  147. autoupdate_cmd = autoupdate_cmd.replace("%{explorer}", _BIN)
  148. # Run the autoupdate command to generate output.
  149. # (`bazel run` would serialize)
  150. p = subprocess.run(
  151. autoupdate_cmd % test,
  152. shell=True,
  153. stdout=subprocess.PIPE,
  154. stderr=subprocess.STDOUT,
  155. )
  156. out = p.stdout.decode("utf-8")
  157. # `lit` uses full paths to the test file, so use a regex to ignore paths
  158. # when used.
  159. # TODO: Maybe revisit and see if lit can be convinced to give a
  160. # root-relative path.
  161. out = CheckLine.escape(out).replace(test, "{{.*}}/%s" % test)
  162. out_lines = out.splitlines()
  163. orig_line_iter = iter(
  164. OriginalLine(i, line) for i, line in enumerate(orig_lines)
  165. )
  166. check_line_iter = iter(_make_check_line(out_line) for out_line in out_lines)
  167. next_orig_line: Optional[OriginalLine] = next(orig_line_iter, None)
  168. next_check_line: Optional[CheckLine] = next(check_line_iter, None)
  169. # Interleave the original lines and the CHECK: lines into a list of
  170. # `result_lines`.
  171. result_lines: List[Line] = []
  172. # Mapping from `orig_lines` indexes to `result_lines` indexes.
  173. line_number_remap: Dict[int, int] = {}
  174. while next_orig_line or next_check_line:
  175. if next_check_line and _should_produce_check_line(
  176. next_check_line, next_orig_line, autoupdate_index
  177. ):
  178. # Indent the CHECK: line to match the next original line.
  179. if next_orig_line:
  180. match = re.match(" *", next_orig_line.text)
  181. if match:
  182. next_check_line.indent = match[0]
  183. result_lines.append(next_check_line)
  184. next_check_line = next(check_line_iter, None)
  185. else:
  186. assert next_orig_line, "no lines left"
  187. # Include this original line if it isn't a CHECK: line.
  188. if not re.match(" *// CHECK", next_orig_line.text):
  189. line_number_remap[next_orig_line.line_number] = len(
  190. result_lines
  191. )
  192. result_lines.append(next_orig_line)
  193. next_orig_line = next(orig_line_iter, None)
  194. # Generate contents for any lines that depend on line numbers.
  195. formatted_result_lines = [
  196. line.format(output_line_number=i, line_number_remap=line_number_remap)
  197. for i, line in enumerate(result_lines)
  198. ]
  199. # If nothing's changed, we're done.
  200. if formatted_result_lines == orig_lines:
  201. return False
  202. # Interleave the new CHECK: lines with the tested content.
  203. with open(test, "w") as f:
  204. f.writelines(formatted_result_lines)
  205. return True
  206. def _update_check(test: str) -> None:
  207. """Wraps CHECK: updates for test files."""
  208. # If the number of output lines changes, run again because output can be
  209. # line-specific. However, output should stabilize quickly.
  210. if (
  211. _update_check_once(test)
  212. and _update_check_once(test)
  213. and _update_check_once(test)
  214. ):
  215. raise ValueError("The output of %s kept changing" % test)
  216. print(".", end="", flush=True)
  217. def _update_checks() -> None:
  218. """Runs bazel to update CHECK: lines in lit tests."""
  219. # TODO: It may be helpful if a list of tests can be passed in args; would
  220. # want to use argparse for this.
  221. tests = _get_tests()
  222. # Build all tests at once in order to allow parallel updates.
  223. print("Building explorer...")
  224. subprocess.check_call(["bazel", "build", "//explorer"])
  225. print("Updating %d lit tests..." % len(tests))
  226. with futures.ThreadPoolExecutor() as exec:
  227. # list() iterates to propagate exceptions.
  228. list(exec.map(_update_check, tests))
  229. # Each update call indicates progress with a dot without a newline, so put a
  230. # newline to wrap.
  231. print("\nUpdated lit tests.")
  232. def main() -> None:
  233. # Go to the repository root so that paths will match bazel's view.
  234. os.chdir(os.path.join(os.path.dirname(__file__), ".."))
  235. _update_checks()
  236. if __name__ == "__main__":
  237. main()