Update littlecheck

This lets littlecheck "diff" the given output with the checks, leading
to easier to understand errors.

E.g. changing some random lines in andandoror.fish yields error output
like:

```
Testing file checks/andandoror.fish ... Failure:

  The CHECK on line 36 wants:
    if test 4 ok

  which failed to match line stdout:9:
    if test 3 ok

  Context:
    [...] from line 17 (stdout:6):
    true && false || true: 0
    if test 1 ok
    if test 2 ok
    if test 3 ok <= no check matches this, previous check on line 35
    if test 4 ok
    0 0 0
    1 1 1
    2 2 2
    3 3 3 <= does not match CHECK '3 5 3' on line 55
    4 4 4
    0
    1
    [...] from line 126 (stdout:33):
    0
    0
    0
     <= nothing to match CHECK 'banana' on line 135

  when running command:
    ../test/root/bin/fish checks/andandoror.fish
```

This updates littlecheck to b9c24a3.
This commit is contained in:
Fabian Homborg 2020-11-22 11:24:41 +01:00
parent 6c8c8bf819
commit 47ddb6d516

View file

@ -13,6 +13,11 @@ import re
import shlex
import subprocess
import sys
try:
from itertools import zip_longest
except ImportError:
from itertools import izip_longest as zip_longest
from difflib import SequenceMatcher
# Directives can occur at the beginning of a line, or anywhere in a line that does not start with #.
COMMENT_RE = r'^(?:[^#].*)?#\s*'
@ -35,10 +40,6 @@ class Config(object):
self.colorize = False
# Whether to show which file was tested.
self.progress = False
# How many after lines to print
self.after = 5
# How many before lines to print
self.before = 5
def colors(self):
""" Return a dictionary mapping color names to ANSI escapes """
@ -132,6 +133,11 @@ class Line(object):
def is_empty_space(self):
return not self.text or self.text.isspace()
def escaped_text(self, for_formatting=False):
ret = escape_string(self.text.rstrip("\n"))
if for_formatting:
ret = ret.replace("{", "{{").replace("}", "}}")
return ret
class RunCmd(object):
""" A command to run on a given Checker.
@ -152,17 +158,16 @@ class RunCmd(object):
class TestFailure(object):
def __init__(self, line, check, testrun, before=None, after=None):
def __init__(self, line, check, testrun, diff=None, lines=[], checks=[]):
self.line = line
self.check = check
self.testrun = testrun
self.error_annotation_lines = None
# The output that comes *after* the failure.
self.after = after
self.before = before
self.diff = diff
self.lines = lines
self.checks = checks
def message(self):
afterlines = self.testrun.config.after
fields = self.testrun.config.colors()
fields["name"] = self.testrun.name
fields["subbed_command"] = self.testrun.subbed_command
@ -171,7 +176,7 @@ class TestFailure(object):
{
"output_file": self.line.file,
"output_lineno": self.line.number,
"output_line": self.line.text.rstrip("\n"),
"output_line": self.line.escaped_text(),
}
)
if self.check:
@ -179,7 +184,7 @@ class TestFailure(object):
{
"input_file": self.check.line.file,
"input_lineno": self.check.line.number,
"input_line": self.check.line.text,
"input_line": self.check.line.escaped_text(),
"check_type": self.check.type,
}
)
@ -218,20 +223,50 @@ class TestFailure(object):
" additional output on stderr:{error_annotation_lineno}:",
" {BOLD}{error_annotation}{RESET}",
]
if self.before or self.after:
if self.diff:
fmtstrs += [" Context:"]
lasthi = 0
lastcheckline = None
for d in self.diff.get_grouped_opcodes():
for op, alo, ahi, blo, bhi in d:
color="{BOLD}"
if op == 'replace' or op == 'delete':
color="{RED}"
# We got a new chunk, so we print a marker.
if alo > lasthi:
fmtstrs += [
" [...] from line " + str(self.checks[blo].line.number)
+ " (" + self.lines[alo].file + ":" + str(self.lines[alo].number) + "):"
]
lasthi = ahi
if self.before:
fields["before_output"] = " ".join(self.before)[:-1]
fmtstrs += [" {BOLD}{before_output}"]
# We print one "no more checks" after the last check and then skip any markers
lastcheck = False
for a, b in zip_longest(self.lines[alo:ahi], self.checks[blo:bhi]):
# Clean up strings for use in a format string - double up the curlies.
astr = color + a.escaped_text(for_formatting=True) + "{RESET}" if a else ""
if b:
bstr = "'{BLUE}" + b.line.escaped_text(for_formatting=True) + "{RESET}'" + " on line " + str(b.line.number)
lastcheckline = b.line.number
fmtstrs += [
" {RED}{output_line}{RESET} <= does not match '{LIGHTBLUE}{input_line}{RESET}'",
]
if self.after is not None:
fields["additional_output"] = " ".join(self.after[:afterlines])
fmtstrs += [" {BOLD}{additional_output}{RESET}"]
if op == 'equal':
fmtstrs += [" " + astr]
elif b and a:
fmtstrs += [" " + astr + " <= does not match " + b.type + " " + bstr]
elif b:
fmtstrs += [" " + astr + " <= nothing to match " + b.type + " " + bstr]
elif not b:
string = " " + astr
if bhi == len(self.checks):
if not lastcheck:
string += " <= no more checks"
lastcheck = True
elif lastcheckline is not None:
string += " <= no check matches this, previous check on line " + str(lastcheckline)
else:
string += " <= no check matches"
fmtstrs.append(string)
fmtstrs.append("")
fmtstrs += [" when running command:", " {subbed_command}"]
return "\n".join(fmtstrs).format(**fields)
@ -275,45 +310,71 @@ class TestRun(object):
# Reverse our lines and checks so we can pop off the end.
lineq = lines[::-1]
checkq = checks[::-1]
# We keep the last couple of lines in a deque so we can show context.
before = deque(maxlen=self.config.before)
usedlines = []
usedchecks = []
text1 = []
text2 = []
mismatches = []
while lineq and checkq:
line = lineq[-1]
check = checkq[-1]
if check.regex.match(line.text):
# This line matched this checker, continue on.
text1.append(line.escaped_text())
usedlines.append(line)
text2.append(line.escaped_text())
usedchecks.append(check)
lineq.pop()
checkq.pop()
before.append(line)
elif line.is_empty_space():
# Skip all whitespace input lines.
lineq.pop()
else:
text1.append(line.escaped_text())
usedlines.append(line)
# HACK: Theoretically it's possible that
# the line is the same as the CHECK regex but doesn't match
# (e.g. both are `\s+` or something).
# Since we only need this for the SequenceMatcher to *compare*,
# we give it a fake non-matching check in those cases.
etext = check.line.escaped_text()
if etext != line.escaped_text():
text2.append(etext)
else:
text2.append(" " + etext)
usedchecks.append(check)
mismatches.append((line, check))
# Failed to match.
lineq.pop()
line.text = escape_string(line.text.strip()) + "\n"
# Add context, ignoring empty lines.
return TestFailure(
line,
check,
self,
before=[escape_string(line.text.strip()) + "\n" for line in before],
after=[
escape_string(line.text.strip()) + "\n"
for line in lineq[::-1]
if not line.is_empty_space()
],
)
# Drain empties.
checkq.pop()
# Drain empties
while lineq and lineq[-1].is_empty_space():
lineq.pop()
# If there's still lines or checkers, we have a failure.
# Store the remaining lines for the diff
for i in lineq[::-1]:
if not i.is_empty_space():
text1.append(i.escaped_text())
usedlines.append(i)
# Store remaining checks for the diff
for i in checkq[::-1]:
text2.append(i.line.escaped_text())
usedchecks.append(i)
# Do a SequenceMatch! This gives us a diff-like thing.
diff = SequenceMatcher(a=text1, b=text2)
# If there's a mismatch or still lines or checkers, we have a failure.
# Otherwise it's success.
if lineq:
return TestFailure(lineq[-1], None, self)
if mismatches:
return TestFailure(mismatches[0][0], mismatches[0][1], self, diff=diff, lines=usedlines, checks=usedchecks)
elif lineq:
return TestFailure(lineq[-1], None, self, diff=diff, lines=usedlines, checks=usedchecks)
elif checkq:
return TestFailure(None, checkq[-1], self)
return TestFailure(None, checkq[-1], self, diff=diff, lines=usedlines, checks=usedchecks)
else:
# Success!
return None
def run(self):
@ -509,22 +570,6 @@ def get_argparse():
default=False,
)
parser.add_argument("file", nargs="+", help="File to check")
parser.add_argument(
"-A",
"--after",
type=int,
help="How many non-empty lines of output after a failure to print (default: 5)",
action="store",
default=5,
)
parser.add_argument(
"-B",
"--before",
type=int,
help="How many non-empty lines of output before a failure to print (default: 5)",
action="store",
default=5,
)
return parser
@ -539,12 +584,6 @@ def main():
config.colorize = sys.stdout.isatty()
config.progress = args.progress
fields = config.colors()
config.after = args.after
config.before = args.before
if config.before < 0:
raise ValueError("Before must be at least 0")
if config.after < 0:
raise ValueError("After must be at least 0")
for path in args.file:
fields["path"] = path