///|
pub(all) struct ValidationReport {
valid : Bool
complete : Bool
errors : Int
warnings : Int
diagnostics : Array[Diagnostic]
} derive(Eq, Debug, ToJson)
///|
fn check_span(span : Span, length : Int) -> Bool {
span.start >= 0 && span.end >= span.start && span.end <= length
}
///|
fn validation_issue(code : String, message : String, span : Span) -> Diagnostic {
{ code, severity: Warning, message, span, }
}
///|
/// Check references and structural invariants in the extracted document.
pub fn Document::validate(
self : Document,
max_diagnostics? : Int = 1000,
) -> Array[Diagnostic] {
let out : Array[Diagnostic] = []
if max_diagnostics <= 0 {
return out
}
let font_ids : Map[Int, Bool] = Map([])
let missing_fonts : Map[Int, Bool] = Map([])
let missing_colors : Map[Int, Bool] = Map([])
for font in self.fonts {
if out.length() >= max_diagnostics {
return out
}
if font_ids.contains(font.id) {
out.push(
validation_issue(
"duplicate_font_id",
"Font identifier appears more than once",
{ start: 0, end: 0, },
),
)
}
font_ids[font.id] = true
}
let mut last_row : Int? = None
let mut last_cell : Int? = None
for paragraph in self.paragraphs {
if out.length() >= max_diagnostics {
break
}
if !check_span(paragraph.span, self.input_bytes) {
out.push(
validation_issue(
"invalid_paragraph_span",
"Paragraph source range is outside the input",
{ start: 0, end: 0, },
),
)
}
if (paragraph.table_row is Some(_)) != (paragraph.table_cell is Some(_)) {
out.push(
validation_issue(
"incomplete_table_address",
"Table row and cell must both be present",
paragraph.span,
),
)
}
if paragraph.table_row is Some(row) {
if last_row == Some(row) {
if paragraph.table_cell is Some(cell) &&
last_cell is Some(previous) &&
cell < previous {
out.push(
validation_issue(
"table_cell_order",
"Cell indices decrease within a row",
paragraph.span,
),
)
}
}
last_row = Some(row)
last_cell = paragraph.table_cell
} else {
last_row = None
last_cell = None
}
for run in paragraph.runs {
if out.length() >= max_diagnostics {
break
}
if !check_span(run.span, self.input_bytes) {
out.push(
validation_issue(
"invalid_run_span",
"Text source range is outside the input",
{ start: 0, end: 0, },
),
)
}
let font = run.style.font
if !self.fonts.is_empty() &&
!font_ids.contains(font) &&
!missing_fonts.contains(font) {
missing_fonts[font] = true
out.push(
validation_issue(
"undefined_font",
"Font \{font} is not declared",
run.span,
),
)
}
for color in [run.style.foreground, run.style.background] {
if color > 0 &&
color >= self.colors.length() &&
!missing_colors.contains(color) {
missing_colors[color] = true
out.push(
validation_issue(
"undefined_color",
"Color \{color} is not declared",
run.span,
),
)
}
}
if run.link is Some(link) && !safe_link(link) {
out.push(
validation_issue(
"unsafe_link",
"Link is retained as metadata but not activated by Markdown export",
run.span,
),
)
}
}
}
// The inner checks can add a few messages together; the public cap is exact.
if out.length() > max_diagnostics {
out[:max_diagnostics].to_owned()
} else {
out
}
}
///|
pub fn validate(
input : Bytes,
fail_on_warning? : Bool = false,
limits? : Limits = Limits::default(),
) -> ValidationReport {
let parsed = parse(input, limits~, recover=true)
let diagnostics = parsed.diagnostics.copy()
let remaining = (limits.diagnostics - diagnostics.length()).max(0)
for issue in parsed.document.validate(max_diagnostics=remaining) {
diagnostics.push(issue)
}
let mut errors = 0
let mut warnings = 0
for d in diagnostics {
if d.is_error() {
errors += 1
} else {
warnings += 1
}
}
{
valid: parsed.complete && errors == 0 && (!fail_on_warning || warnings == 0),
complete: parsed.complete,
errors,
warnings,
diagnostics,
}
}
///|
pub fn ValidationReport::to_json_text(self : ValidationReport) -> String {
Json::object({
"schema": Json::string("moonrtf.validation.v1"),
"valid": Json::boolean(self.valid),
"complete": Json::boolean(self.complete),
"errors": self.errors.to_json(),
"warnings": self.warnings.to_json(),
"diagnostics": Json::array(self.diagnostics.map(diagnostic_json)),
}).stringify()
}
///|
pub fn ValidationReport::to_text(
self : ValidationReport,
input : Bytes,
) -> String {
let out = StringBuilder()
out.write_string(if self.valid { "VALID" } else { "INVALID" })
out.write_string(" errors=\{self.errors} warnings=\{self.warnings}\n")
for d in self.diagnostics {
out.write_string(format_diagnostic(input, d))
out.write_char('\n')
}
out.to_string()
}