parsecheck
stdlib/extra/parsecheck.hk: check a parser's failure diagnostics by brute force.
A hand-written parser can report the wrong failure position, or an expected set that has drifted from the grammar, and no ordinary test notices: the parse still rejects the input, and the only thing wrong is the message. This module turns both into assertions.
It needs nothing but the parse function. Given a subject that answers, for any input, either "accepted" or "failed at offset N", two facts about a rejected input are computable by brute force:
the position is right the reported offset is the length of the longest viable prefix, the point past which no continuation could have saved the parse. That length is what Report.honest_at records
the expected set is a byte is a viable continuation when appending it complete leaves the prefix viable; probing all 256 gives the true set, against which the parser's own labels must be neither short nor invented
The whole thing rests on one observation: a prefix is viable when the subject either accepts it outright, or fails only for want of more input, at the prefix's own end. A parser that fails at offset 2 of a 2-byte input consumed everything and wanted more; one that fails at offset 2 of a 3-byte input choked on something it saw. No grammar and no parser internals, and this reads a hand-written, table-driven or generated parser the same way.
It is expensive, 256 probes per rejected input plus one per prefix, and that is what it is for. It is a test and no parse.
Two classes of would-be finding are no findings at all, a real parser's corpus having measured them as the entire noise floor: bytes the author declines to name (check's ignore, whitespace for a parser that skips it), and bytes that merely extend a parse at end of input, where the parser's own "expected )" completion message is the better one (recorded on Report.viable_only and never demanded). Both exist to let explain be empty on a parser whose diagnostics are right, which is the negative result this tool certifies.
Outcome
struct Outcome
accepted: bool
at: int
end
What the subject answers about one input. at is meaningful only when the input was rejected.
accepted
def accepted() -> Outcome
The subject accepted the whole input.
accepted().accepted => true
failed_at
def failed_at(at: int) -> Outcome
The subject rejected the input, having got as far as offset at.
failed_at(3).at => 3
alphabet
def alphabet() -> List<bytes>
Every single-byte value, in byte order, which puts the one-byte bytes for byte i at index i. Built from hex and not by arithmetic, which leaves byte 0 and byte 255 as reachable as the printable middle.
alphabet().length => 256
viable?
def viable?(probe: (bytes) -> Outcome, s: bytes) -> bool
Is s a prefix of something the subject would accept? True when the subject accepts it, and when it rejects it only at the very end, having consumed everything and stopped for want of more input.
>= and not ==: a parser that reports the position it wanted to read, and not the last one it read, lands one past the end.
viable?(|s| accepted(), "ab".to_bytes()) => true
viable?(|s| failed_at(0), "ab".to_bytes()) => false
viable?(|s| failed_at(2), "ab".to_bytes()) => true
frontier
def frontier(probe: (bytes) -> Outcome, input: bytes) -> int
The length of the longest viable prefix of input, which is where the failure belongs. Prefixes are probed shortest-first and the first non-viable one settles it: viability is monotone, since a prefix that cannot be extended into an accepting parse cannot regain the ability by growing.
frontier(|s| failed_at(1), "abc".to_bytes()) => 1
continuations
def continuations(probe: (bytes) -> Outcome, input: bytes, at: int) -> List<int>
The byte values that could have continued a parse at at: the true expected set, as bytes and not as labels.
continuations(|s| accepted(), "ab".to_bytes(), 1).length => 256
continuations(|s| failed_at(0), "ab".to_bytes(), 1).length => 0
completions
def completions(probe: (bytes) -> Outcome, input: bytes, at: int) -> List<int>
The byte values that complete a parse at at: appending one yields an input the subject accepts outright. Always a subset of continuations; the rest of that set merely leaves the parse alive, which is a different claim, and check treats the two differently at an end-of-input frontier.
completions(|s| accepted(), "ab".to_bytes(), 1).length => 256
completions(|s| failed_at(0), "ab".to_bytes(), 1).length => 0
Report
struct Report
input: string
reported_at: int
honest_at: int
missing: List<int>
viable_only: List<int>
phantom: List<string>
end
What a check found for one rejected input. viable_only is informational: at an end-of-input frontier it is the unnamed bytes that would have left the parse alive without completing it, the class explain omits by design, the parser's own "expected )" being the better message and the extensions per-report noise. At a mid-input frontier it is empty: there such bytes are genuine omissions and land in missing.
impl Report
clean?
prop clean?(self) -> bool
Nothing to complain about: the reported position was the right one, every byte that had to be named is covered by some label, and every label covers a byte that would have left the parse alive. viable_only does not count against cleanliness; it is the set this checker declines to demand a name for.
Report(input="x", reported_at=1, honest_at=1, missing=[], viable_only=[], phantom=[]).clean? => true
Report(input="x", reported_at=0, honest_at=1, missing=[], viable_only=[], phantom=[]).clean? => false
check
def check(probe: (bytes) -> Outcome, input: string, reported_at: int, expected: List<string>, covers: (string, int) -> bool, ignore: List<int>) -> Report
Measure one rejected input against what the subject said about it.
probe answers for any input; reported_at and expected are what the subject's own diagnostic claimed; covers says whether one of those labels would admit a given byte: the only thing the caller has to supply, the caller alone knowing that "a number" means the ASCII digits. ignore is the bytes the author declines to name at all; a parser that skips whitespace passes the whitespace bytes here, or every report would carry "never expects " forever.
What a byte must do to land in missing depends on where the frontier is, because the two positions make different claims:
Mid-input (the subject refused a byte it saw): every uncovered viable byte is a genuine omission. "A leading - would also have parsed" is the defect this tool exists to catch.
End of input (the subject consumed everything and wanted more): only the uncovered completing bytes count. For (1 + 2 the parser's "expected )" is the right message, and the digits that merely extend the expression are noise; they are recorded on viable_only and never demanded.
A subject that accepts everything reports the right stopping point and expects nothing, and every byte that would have parsed therefore counts as missing:
check(|s| accepted(), "ab", 2, [], |l: string, b: int| false, []).honest_at => 2
check(|s| accepted(), "ab", 2, [], |l: string, b: int| false, []).missing.length => 256
show_byte
def show_byte(b: int) -> string
A byte as it should read in a message: the character itself when printable, and a hex escape otherwise.
show_byte(0) => "0x00"
show_byte(255) => "0xff"
explain
def explain(r: Report) -> string
One line naming everything wrong with r, or the empty string when nothing is, which lets a caller collect the non-empty ones and fail with all of them.
explain(Report(input="x", reported_at=1, honest_at=1, missing=[], viable_only=[], phantom=[])) => ""
_byte
def _byte(s: bytes, i: int) -> int
_demo
def _demo(s: bytes) -> Outcome
_covers?
def _covers?(label: string, b: int) -> bool
_paren
def _paren(s: bytes) -> Outcome
parencovers?
def _paren_covers?(label: string, b: int) -> bool