mirror of
https://github.com/fish-shell/fish-shell
synced 2024-11-10 15:14:44 +00:00
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:
parent
6c8c8bf819
commit
47ddb6d516
1 changed files with 104 additions and 65 deletions
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue