///|
/// A TAP directive attached to a test point.
pub(all) enum TapDirective {
NoDirective
Skip(String)
Todo(String)
} derive(Debug, Eq)
///|
/// One parsed TAP test point.
pub(all) struct TapPoint {
line : Int
number : Int
ok : Bool
name : String
directive : TapDirective
raw : String
} derive(Debug, Eq)
///|
/// A diagnostic comment line.
pub(all) struct TapDiagnostic {
line : Int
text : String
} derive(Debug, Eq)
///|
/// A YAMLish TAP diagnostic block.
pub(all) struct TapYamlBlock {
start_line : Int
end_line : Int
lines : Array[String]
} derive(Debug, Eq)
///|
/// A parser or validation issue.
pub(all) struct TapIssue {
line : Int
code : String
message : String
} derive(Debug, Eq)
///|
/// Parsed TAP stream with non-fatal parser issues.
pub(all) struct TapDocument {
has_version : Bool
version : String
has_plan : Bool
plan_start : Int
plan_end : Int
points : Array[TapPoint]
diagnostics : Array[TapDiagnostic]
yaml_blocks : Array[TapYamlBlock]
bailed_out : Bool
bailout_reason : String
parse_issues : Array[TapIssue]
} derive(Debug, Eq)
///|
/// Aggregate counts for one TAP stream.
pub(all) struct TapSummary {
planned : Int
total : Int
passed : Int
failed : Int
skipped : Int
todo : Int
diagnostics : Int
yaml_blocks : Int
} derive(Debug, Eq)
///|
/// A validation report ready for CI gates and release notes.
pub(all) struct TapReport {
document : TapDocument
summary : TapSummary
issues : Array[TapIssue]
ok : Bool
} derive(Debug, Eq)
///|
/// Parse a TAP13-ish stream. The parser is permissive and records unknown lines
/// as issues instead of aborting.
pub fn parse_tap(input : String) -> TapDocument {
let points : Array[TapPoint] = []
let diagnostics : Array[TapDiagnostic] = []
let yaml_blocks : Array[TapYamlBlock] = []
let parse_issues : Array[TapIssue] = []
let mut has_version = false
let mut version = ""
let mut has_plan = false
let mut plan_start = 0
let mut plan_end = 0
let mut bailed_out = false
let mut bailout_reason = ""
let mut line_no = 0
let mut in_yaml = false
let mut yaml_start = 0
let yaml_lines : Array[String] = []
for raw in input.split("\n") {
line_no += 1
let line = raw.trim_end(chars="\r").to_owned()
let trimmed = line.trim_start().to_owned()
if in_yaml {
yaml_lines.push(line)
if trimmed == "..." {
yaml_blocks.push(TapYamlBlock::{
start_line: yaml_start,
end_line: line_no,
lines: yaml_lines.copy(),
})
yaml_lines.clear()
in_yaml = false
}
continue
}
if trimmed.length() == 0 {
continue
} else if trimmed == "TAP version 13" {
has_version = true
version = "13"
} else if trimmed == "---" {
in_yaml = true
yaml_start = line_no
yaml_lines.clear()
yaml_lines.push(line)
} else if trimmed.has_prefix("Bail out!") {
bailed_out = true
bailout_reason = trimmed[9:].trim_start().to_owned()
} else if trimmed.has_prefix("#") {
diagnostics.push(TapDiagnostic::{
line: line_no,
text: trimmed[1:].trim_start().to_owned(),
})
} else if is_plan_line(trimmed) {
match parse_plan_line(trimmed) {
Some((start, end)) => {
has_plan = true
plan_start = start
plan_end = end
}
None =>
parse_issues.push(issue(line_no, "invalid-plan", "invalid TAP plan"))
}
} else if is_point_line(trimmed) {
match parse_point_line(trimmed, line_no) {
Some(point) => points.push(point)
None =>
parse_issues.push(
issue(line_no, "invalid-point", "invalid test point"),
)
}
} else {
parse_issues.push(
issue(line_no, "unknown-line", "line is not a TAP13 construct"),
)
}
}
if in_yaml {
parse_issues.push(
issue(
yaml_start, "unterminated-yaml", "YAMLish diagnostic block was not closed",
),
)
yaml_blocks.push(TapYamlBlock::{
start_line: yaml_start,
end_line: line_no,
lines: yaml_lines.copy(),
})
}
TapDocument::{
has_version,
version,
has_plan,
plan_start,
plan_end,
points,
diagnostics,
yaml_blocks,
bailed_out,
bailout_reason,
parse_issues,
}
}
///|
/// Build aggregate counters for a parsed TAP document.
pub fn summarize(doc : TapDocument) -> TapSummary {
let mut passed = 0
let mut failed = 0
let mut skipped = 0
let mut todo = 0
for point in doc.points {
match point.directive {
Skip(_) => skipped += 1
Todo(_) => todo += 1
NoDirective => ()
}
if point.ok {
passed += 1
} else {
failed += 1
}
}
TapSummary::{
planned: if doc.has_plan {
doc.plan_end - doc.plan_start + 1
} else {
0
},
total: doc.points.length(),
passed,
failed,
skipped,
todo,
diagnostics: doc.diagnostics.length(),
yaml_blocks: doc.yaml_blocks.length(),
}
}
///|
/// Validate a parsed TAP document for release or CI gating.
pub fn validate(doc : TapDocument) -> TapReport {
let issues = doc.parse_issues.copy()
let summary = summarize(doc)
if !doc.has_version {
issues.push(issue(0, "missing-version", "TAP version line is missing"))
} else if doc.version != "13" {
issues.push(
issue(1, "unsupported-version", "only TAP version 13 is supported"),
)
}
if !doc.has_plan {
issues.push(issue(0, "missing-plan", "TAP plan is missing"))
} else if doc.plan_start != 1 {
issues.push(issue(0, "plan-start", "plan should start at 1"))
} else if summary.planned != summary.total {
issues.push(
issue(
0, "plan-count", "number of test points does not match the TAP plan",
),
)
}
if doc.bailed_out {
issues.push(issue(0, "bailout", "stream contains Bail out!"))
}
for point in doc.points {
if !point.ok {
match point.directive {
Todo(_) => ()
_ =>
issues.push(
issue(
point.line,
"failed-point",
"test point failed: \{point.name}",
),
)
}
}
}
let duplicate_numbers = find_duplicate_numbers(doc.points)
for n in duplicate_numbers {
issues.push(
issue(0, "duplicate-number", "duplicate test point number \{n}"),
)
}
if !is_monotonic(doc.points) {
issues.push(
issue(
0, "non-monotonic", "test point numbers are not strictly increasing",
),
)
}
TapReport::{ document: doc, summary, issues, ok: issues.is_empty() }
}
///|
/// Parse and validate a TAP stream in one call.
pub fn parse_and_report(input : String) -> TapReport {
validate(parse_tap(input))
}
///|
/// Render a compact Markdown report for CI logs or release artifacts.
pub fn to_markdown(report : TapReport) -> String {
let sb = StringBuilder()
sb.write_string("# TAPTrail Report\n\n")
sb.write_string("- status: ")
sb.write_string(if report.ok { "ok" } else { "failed" })
sb.write_string("\n")
sb.write_string("- planned: \{report.summary.planned}\n")
sb.write_string("- total: \{report.summary.total}\n")
sb.write_string("- passed: \{report.summary.passed}\n")
sb.write_string("- failed: \{report.summary.failed}\n")
sb.write_string("- skipped: \{report.summary.skipped}\n")
sb.write_string("- todo: \{report.summary.todo}\n")
sb.write_string("- diagnostics: \{report.summary.diagnostics}\n")
sb.write_string("- yaml_blocks: \{report.summary.yaml_blocks}\n")
if report.issues.is_empty() {
sb.write_string("\nNo validation issues.\n")
} else {
sb.write_string("\n## Issues\n\n")
for item in report.issues {
sb.write_string("- ")
sb.write_string(item.code)
if item.line > 0 {
sb.write_string(" at line \{item.line}")
}
sb.write_string(": ")
sb.write_string(item.message)
sb.write_string("\n")
}
}
sb.to_string()
}
///|
/// Render a small JSON object without requiring a JSON dependency.
pub fn to_compact_json(report : TapReport) -> String {
let sb = StringBuilder()
sb.write_string("{")
sb.write_string("\"ok\":")
sb.write_string(if report.ok { "true" } else { "false" })
sb.write_string(",\"planned\":\{report.summary.planned}")
sb.write_string(",\"total\":\{report.summary.total}")
sb.write_string(",\"passed\":\{report.summary.passed}")
sb.write_string(",\"failed\":\{report.summary.failed}")
sb.write_string(",\"skipped\":\{report.summary.skipped}")
sb.write_string(",\"todo\":\{report.summary.todo}")
sb.write_string(",\"issues\":[")
for item in report.issues; first = true {
if first {
()
} else {
sb.write_string(",")
}
sb.write_string("{\"code\":\"")
sb.write_string(json_escape(item.code))
sb.write_string("\",\"line\":\{item.line},\"message\":\"")
sb.write_string(json_escape(item.message))
sb.write_string("\"}")
continue false
}
sb.write_string("]}")
sb.to_string()
}
///|
/// Render a JUnit-style XML test suite for CI systems that can ingest XML.
pub fn to_junit_xml(
report : TapReport,
suite_name? : String = "taptrail",
) -> String {
let failures = blocking_failure_count(report.document)
let sb = StringBuilder()
sb.write_string("\n")
sb.write_string("\n",
)
for point in report.document.points {
sb.write_string(" \n")
} else {
sb.write_string(">\n")
match point.directive {
Skip(reason) => {
sb.write_string(" \n")
}
Todo(reason) => {
sb.write_string(" \n")
}
NoDirective =>
if !point.ok {
sb.write_string(" ")
sb.write_string(xml_escape(point.raw))
sb.write_string("\n")
}
}
sb.write_string(" \n")
}
}
if report.issues.length() > 0 {
sb.write_string(" ")
for item in report.issues {
sb.write_string(xml_escape("[\{item.code}] \{item.message}\n"))
}
sb.write_string("\n")
}
sb.write_string("\n")
sb.to_string()
}
///|
/// Return true when the report has no validation issues.
pub fn report_passes(input : String) -> Bool {
parse_and_report(input).ok
}
///|
fn is_point_line(line : String) -> Bool {
line == "ok" ||
line.has_prefix("ok ") ||
line == "not ok" ||
line.has_prefix("not ok ")
}
///|
fn is_plan_line(line : String) -> Bool {
let trimmed = line.trim_start()
let mut i = 0
let mut saw_digit = false
while i < trimmed.length() {
let c = trimmed.unsafe_get(i).to_int()
if c >= 48 && c <= 57 {
saw_digit = true
i += 1
} else {
break
}
}
saw_digit && trimmed[i:].has_prefix("..")
}
///|
fn parse_plan_line(line : String) -> (Int, Int)? {
let trimmed = line.trim_start()
match parse_uint_prefix(trimmed) {
Some((start, rest1)) => {
let rest = rest1.trim_start()
guard rest.has_prefix("..") else { return None }
let after_dots = rest[2:].trim_start()
match parse_uint_prefix(after_dots) {
Some((end, _)) => Some((start, end))
None => None
}
}
None => None
}
}
///|
fn parse_point_line(line : String, line_no : Int) -> TapPoint? {
let ok = line == "ok" || line.has_prefix("ok ")
let rest0 = if ok { line[2:] } else { line[6:] }
let rest = rest0.trim_start()
let (number, after_number) = match parse_uint_prefix(rest) {
Some(pair) => pair
None => (0, rest)
}
let after = after_number.trim_start()
let body = match after.strip_prefix("-") {
Some(x) => x.trim_start()
None => after
}
let (name, directive) = split_directive(body)
Some(TapPoint::{ line: line_no, number, ok, name, directive, raw: line })
}
///|
fn split_directive(body : StringView) -> (String, TapDirective) {
match body.find("#") {
Some(index) => {
let name = body[:index].trim_end().to_owned()
let directive_text = body[index + 1:].trim_start()
if directive_text.has_prefix("SKIP") {
(name, Skip(directive_text[4:].trim_start().to_owned()))
} else if directive_text.has_prefix("TODO") {
(name, Todo(directive_text[4:].trim_start().to_owned()))
} else {
(body.to_owned(), NoDirective)
}
}
None => (body.trim_end().to_owned(), NoDirective)
}
}
///|
fn parse_uint_prefix(view : StringView) -> (Int, StringView)? {
let mut i = 0
let mut value = 0
while i < view.length() {
let c = view.unsafe_get(i).to_int()
if c >= 48 && c <= 57 {
value = value * 10 + c - 48
i += 1
} else {
break
}
}
if i == 0 {
None
} else {
Some((value, view[i:]))
}
}
///|
fn find_duplicate_numbers(points : Array[TapPoint]) -> Array[Int] {
let duplicates : Array[Int] = []
for i in 0.. Bool {
let mut last = 0
for point in points {
if point.number == 0 {
continue
}
if point.number <= last {
return false
}
last = point.number
}
true
}
///|
fn blocking_failure_count(doc : TapDocument) -> Int {
let mut count = 0
for point in doc.points {
if !point.ok {
match point.directive {
Todo(_) => ()
_ => count += 1
}
}
}
count
}
///|
fn point_name(point : TapPoint) -> String {
if point.name.length() > 0 {
point.name
} else if point.number > 0 {
"test \{point.number}"
} else {
"unnumbered at line \{point.line}"
}
}
///|
fn issue(line : Int, code : String, message : String) -> TapIssue {
TapIssue::{ line, code, message }
}
///|
fn json_escape(text : String) -> String {
let sb = StringBuilder()
for ch in text {
match ch {
'"' => sb.write_string("\\\"")
'\\' => sb.write_string("\\\\")
'\n' => sb.write_string("\\n")
'\r' => sb.write_string("\\r")
'\t' => sb.write_string("\\t")
_ => sb.write_char(ch)
}
}
sb.to_string()
}
///|
fn xml_escape(text : String) -> String {
let sb = StringBuilder()
for ch in text {
match ch {
'&' => sb.write_string("&")
'<' => sb.write_string("<")
'>' => sb.write_string(">")
'"' => sb.write_string(""")
'\'' => sb.write_string("'")
_ => sb.write_char(ch)
}
}
sb.to_string()
}