///|
/// Run a weighted rule set against text.
pub fn audit_text(
title : StringView,
text : StringView,
rules : ArrayView[Rule],
) -> Audit {
let source = text.to_owned()
let lines = split_lines(source)
let shape = analyze_shape(source)
let findings : Array[Finding] = []
let mut max_score = 0
let mut earned_score = 0
for rule in rules {
let finding = evaluate_rule(rule, source, lines)
max_score += positive_weight(rule.weight)
earned_score += finding.earned
findings.push(finding)
}
let score = if max_score == 0 { 100 } else { earned_score * 100 / max_score }
let stats = summarize_stats(source, findings, shape)
{
title: title.to_owned(),
score,
max_score,
earned_score,
grade: grade_for(score),
findings,
stats,
}
}
///|
fn positive_weight(weight : Int) -> Int {
if weight < 0 {
0
} else {
weight
}
}
///|
fn evaluate_rule(
rule : Rule,
source : String,
lines : Array[String],
) -> Finding {
match rule.kind {
MustContainAny(terms) => {
let evidence = collect_evidence(lines, terms, 4)
let passed = !evidence.is_empty()
make_finding(
rule,
passed,
if passed {
"found one of: \{terms.join(", ")}"
} else {
"missing all of: \{terms.join(", ")}"
},
evidence,
)
}
MustNotContainAny(terms) => {
let evidence = collect_evidence(lines, terms, 4)
let passed = evidence.is_empty()
make_finding(
rule,
passed,
if passed {
"none of the forbidden terms were found"
} else {
"found forbidden term(s): \{terms.join(", ")}"
},
evidence,
)
}
MinOccurrences(needle, min) => {
let count = count_occurrences(source, needle)
let evidence = collect_evidence(lines, [needle], 4)
make_finding(
rule,
count >= min,
"matched '\{needle}' \{count} time(s), required \{min}",
evidence,
)
}
MaxLineLength(max) => {
let evidence = collect_long_lines(lines, max)
make_finding(
rule,
evidence.is_empty(),
if evidence.is_empty() {
"all lines are within \{max} characters"
} else {
"\{evidence.length()} sampled line(s) exceed \{max} characters"
},
evidence,
)
}
RequiresPair(left, right) => {
let has_left = contains_folded(source, left)
let has_right = contains_folded(source, right)
let evidence = collect_evidence(lines, [left, right], 4)
make_finding(
rule,
!has_left || has_right,
if !has_left {
"'\{left}' is absent, so '\{right}' is not required"
} else if has_right {
"'\{left}' is paired with '\{right}'"
} else {
"'\{left}' appears without required pair '\{right}'"
},
evidence,
)
}
SectionOrder(sections) => {
let evidence : Array[Evidence] = []
let missing : Array[String] = []
let mut cursor = 0
for section in sections {
match find_line_after(lines, section, cursor) {
Some(index) => {
evidence.push({
line: index + 1,
snippet: clip(lines[index], 96),
matched: section,
})
cursor = index + 1
}
None => missing.push(section)
}
}
make_finding(
rule,
missing.is_empty(),
if missing.is_empty() {
"sections appear in order: \{sections.join(" -> ")}"
} else {
"missing or out-of-order section(s): \{missing.join(", ")}"
},
evidence,
)
}
}
}
///|
fn make_finding(
rule : Rule,
passed : Bool,
message : String,
evidence : Array[Evidence],
) -> Finding {
let weight = positive_weight(rule.weight)
{
rule_id: rule.id,
title: rule.title,
category: rule.category,
severity: rule.severity,
passed,
weight,
earned: if passed {
weight
} else {
0
},
message,
hint: rule.hint,
evidence,
}
}
///|
fn summarize_stats(
source : String,
findings : Array[Finding],
shape : DocumentShape,
) -> AuditStats {
let mut matched_rules = 0
let mut failed_rules = 0
let mut error_count = 0
let mut warning_count = 0
for finding in findings {
if finding.passed {
matched_rules += 1
} else {
failed_rules += 1
match finding.severity {
Error => error_count += 1
Warning => warning_count += 1
Info => ()
}
}
}
{
line_count: shape.lines,
char_count: source.char_length(),
matched_rules,
failed_rules,
error_count,
warning_count,
heading_count: shape.headings,
bullet_count: shape.bullets,
}
}
///|
fn grade_for(score : Int) -> String {
if score >= 90 {
"A"
} else if score >= 75 {
"B"
} else if score >= 60 {
"C"
} else {
"D"
}
}