mirror of
https://github.com/fish-shell/fish-shell.git
synced 2025-02-22 13:39:40 +08: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
@ -13,6 +13,11 @@ import re
|
|||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
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 #.
|
# Directives can occur at the beginning of a line, or anywhere in a line that does not start with #.
|
||||||
COMMENT_RE = r'^(?:[^#].*)?#\s*'
|
COMMENT_RE = r'^(?:[^#].*)?#\s*'
|
||||||
@ -35,10 +40,6 @@ class Config(object):
|
|||||||
self.colorize = False
|
self.colorize = False
|
||||||
# Whether to show which file was tested.
|
# Whether to show which file was tested.
|
||||||
self.progress = False
|
self.progress = False
|
||||||
# How many after lines to print
|
|
||||||
self.after = 5
|
|
||||||
# How many before lines to print
|
|
||||||
self.before = 5
|
|
||||||
|
|
||||||
def colors(self):
|
def colors(self):
|
||||||
""" Return a dictionary mapping color names to ANSI escapes """
|
""" Return a dictionary mapping color names to ANSI escapes """
|
||||||
@ -132,6 +133,11 @@ class Line(object):
|
|||||||
def is_empty_space(self):
|
def is_empty_space(self):
|
||||||
return not self.text or self.text.isspace()
|
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):
|
class RunCmd(object):
|
||||||
""" A command to run on a given Checker.
|
""" A command to run on a given Checker.
|
||||||
@ -152,17 +158,16 @@ class RunCmd(object):
|
|||||||
|
|
||||||
|
|
||||||
class TestFailure(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.line = line
|
||||||
self.check = check
|
self.check = check
|
||||||
self.testrun = testrun
|
self.testrun = testrun
|
||||||
self.error_annotation_lines = None
|
self.error_annotation_lines = None
|
||||||
# The output that comes *after* the failure.
|
self.diff = diff
|
||||||
self.after = after
|
self.lines = lines
|
||||||
self.before = before
|
self.checks = checks
|
||||||
|
|
||||||
def message(self):
|
def message(self):
|
||||||
afterlines = self.testrun.config.after
|
|
||||||
fields = self.testrun.config.colors()
|
fields = self.testrun.config.colors()
|
||||||
fields["name"] = self.testrun.name
|
fields["name"] = self.testrun.name
|
||||||
fields["subbed_command"] = self.testrun.subbed_command
|
fields["subbed_command"] = self.testrun.subbed_command
|
||||||
@ -171,7 +176,7 @@ class TestFailure(object):
|
|||||||
{
|
{
|
||||||
"output_file": self.line.file,
|
"output_file": self.line.file,
|
||||||
"output_lineno": self.line.number,
|
"output_lineno": self.line.number,
|
||||||
"output_line": self.line.text.rstrip("\n"),
|
"output_line": self.line.escaped_text(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if self.check:
|
if self.check:
|
||||||
@ -179,7 +184,7 @@ class TestFailure(object):
|
|||||||
{
|
{
|
||||||
"input_file": self.check.line.file,
|
"input_file": self.check.line.file,
|
||||||
"input_lineno": self.check.line.number,
|
"input_lineno": self.check.line.number,
|
||||||
"input_line": self.check.line.text,
|
"input_line": self.check.line.escaped_text(),
|
||||||
"check_type": self.check.type,
|
"check_type": self.check.type,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -218,20 +223,50 @@ class TestFailure(object):
|
|||||||
" additional output on stderr:{error_annotation_lineno}:",
|
" additional output on stderr:{error_annotation_lineno}:",
|
||||||
" {BOLD}{error_annotation}{RESET}",
|
" {BOLD}{error_annotation}{RESET}",
|
||||||
]
|
]
|
||||||
if self.before or self.after:
|
if self.diff:
|
||||||
fmtstrs += [" Context:"]
|
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:
|
# We print one "no more checks" after the last check and then skip any markers
|
||||||
fields["before_output"] = " ".join(self.before)[:-1]
|
lastcheck = False
|
||||||
fmtstrs += [" {BOLD}{before_output}"]
|
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 += [
|
if op == 'equal':
|
||||||
" {RED}{output_line}{RESET} <= does not match '{LIGHTBLUE}{input_line}{RESET}'",
|
fmtstrs += [" " + astr]
|
||||||
]
|
elif b and a:
|
||||||
|
fmtstrs += [" " + astr + " <= does not match " + b.type + " " + bstr]
|
||||||
if self.after is not None:
|
elif b:
|
||||||
fields["additional_output"] = " ".join(self.after[:afterlines])
|
fmtstrs += [" " + astr + " <= nothing to match " + b.type + " " + bstr]
|
||||||
fmtstrs += [" {BOLD}{additional_output}{RESET}"]
|
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}"]
|
fmtstrs += [" when running command:", " {subbed_command}"]
|
||||||
return "\n".join(fmtstrs).format(**fields)
|
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.
|
# Reverse our lines and checks so we can pop off the end.
|
||||||
lineq = lines[::-1]
|
lineq = lines[::-1]
|
||||||
checkq = checks[::-1]
|
checkq = checks[::-1]
|
||||||
# We keep the last couple of lines in a deque so we can show context.
|
usedlines = []
|
||||||
before = deque(maxlen=self.config.before)
|
usedchecks = []
|
||||||
|
text1 = []
|
||||||
|
text2 = []
|
||||||
|
mismatches = []
|
||||||
while lineq and checkq:
|
while lineq and checkq:
|
||||||
line = lineq[-1]
|
line = lineq[-1]
|
||||||
check = checkq[-1]
|
check = checkq[-1]
|
||||||
if check.regex.match(line.text):
|
if check.regex.match(line.text):
|
||||||
# This line matched this checker, continue on.
|
# 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()
|
lineq.pop()
|
||||||
checkq.pop()
|
checkq.pop()
|
||||||
before.append(line)
|
|
||||||
elif line.is_empty_space():
|
elif line.is_empty_space():
|
||||||
# Skip all whitespace input lines.
|
# Skip all whitespace input lines.
|
||||||
lineq.pop()
|
lineq.pop()
|
||||||
else:
|
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.
|
# Failed to match.
|
||||||
lineq.pop()
|
lineq.pop()
|
||||||
line.text = escape_string(line.text.strip()) + "\n"
|
checkq.pop()
|
||||||
# Add context, ignoring empty lines.
|
|
||||||
return TestFailure(
|
# Drain empties
|
||||||
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.
|
|
||||||
while lineq and lineq[-1].is_empty_space():
|
while lineq and lineq[-1].is_empty_space():
|
||||||
lineq.pop()
|
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.
|
# Otherwise it's success.
|
||||||
if lineq:
|
if mismatches:
|
||||||
return TestFailure(lineq[-1], None, self)
|
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:
|
elif checkq:
|
||||||
return TestFailure(None, checkq[-1], self)
|
return TestFailure(None, checkq[-1], self, diff=diff, lines=usedlines, checks=usedchecks)
|
||||||
else:
|
else:
|
||||||
|
# Success!
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
@ -509,22 +570,6 @@ def get_argparse():
|
|||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
parser.add_argument("file", nargs="+", help="File to check")
|
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
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@ -539,12 +584,6 @@ def main():
|
|||||||
config.colorize = sys.stdout.isatty()
|
config.colorize = sys.stdout.isatty()
|
||||||
config.progress = args.progress
|
config.progress = args.progress
|
||||||
fields = config.colors()
|
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:
|
for path in args.file:
|
||||||
fields["path"] = path
|
fields["path"] = path
|
||||||
|
Loading…
x
Reference in New Issue
Block a user