use super::At;
use crate::{
Finding, Locator, RepoPath,
grammar::{
Block, FmFormat, Format, FormatMatch, FormatRegistry, FormatSource, FrontmatterConstraint,
Schema, Section, Shape, Text, Unit, captured, refused, squash,
},
markdown::{is_slug, is_url},
};
type Problem = Option<String>;
struct Examples<'a> {
schema: &'a Schema,
path: &'a RepoPath,
out: Vec<Finding>,
failed: Failed,
locator: Locator,
at: At,
}
pub fn check(schema: &Schema, path: &RepoPath, id: &str) -> Vec<Finding> {
check_examples(schema, path, id).0
}
#[derive(Debug, Default)]
pub(super) struct Failed {
pub texts: Vec<String>,
pub rules: Vec<usize>,
}
pub(super) fn check_examples(schema: &Schema, path: &RepoPath, id: &str) -> (Vec<Finding>, Failed) {
let mut e = Examples {
schema,
path,
out: Vec::new(),
failed: Failed::default(),
locator: Locator::schema(id),
at: At::default(),
};
for (key, constraint) in &schema.core.frontmatter {
if let Some(text) = &constraint.example {
e.at = At {
named: Some(format!("key `{key}`")),
..At::default()
};
e.said_of(
text,
constraint.common.line,
"TL_64",
frontmatter(key, constraint),
);
}
}
for section in &schema.sections {
e.at = At {
region: Some(format!("section \"{}\"", section.label())),
rule_id: section.rule_id.clone(),
..At::default()
};
if let Some(text) = §ion.heading.example {
e.said_of(text, section.common.line, "TL_64", heading(section));
}
e.blocks(section.blocks.as_deref());
}
if let Some(preamble) = &schema.preamble {
e.at = At {
region: Some("preamble".to_string()),
..At::default()
};
e.blocks(preamble.blocks.as_deref());
}
e.at = At::default();
for (name, (format, source)) in &schema.core.formats.entries {
let own = matches!(source, FormatSource::Schema(_));
let layer = if own {
String::new()
} else {
format!(" ({})", source.label())
};
for (criterion, message, text, line) in format_examples(name, format, &layer) {
let at = if own { line } else { schema.core.line };
if let Some(text) = text.filter(|t| !t.is_empty()) {
e.failed.texts.push(text);
}
if !matches!(source, FormatSource::File(_)) {
e.out
.push(Finding::error(path.clone(), Some(at), criterion, message));
}
}
}
(e.out, e.failed)
}
pub fn check_formats(
registry: &FormatRegistry,
source: &FormatSource,
path: &RepoPath,
) -> Vec<Finding> {
registry
.entries
.iter()
.filter(|(_, (_, s))| s == source)
.flat_map(|(name, (format, _))| {
format_examples(name, format, "")
.into_iter()
.map(|(criterion, message, _, line)| {
Finding::error(path.clone(), Some(line), criterion, message)
})
})
.collect()
}
fn format_examples(
name: &str,
format: &Format,
layer: &str,
) -> Vec<(&'static str, String, Option<String>, usize)> {
let mut out = Vec::new();
let line_of = |variant: Option<&str>| {
variant
.and_then(|v| format.raw.get("variants")?.get(v))
.map_or(format.raw.line, |node| node.line)
};
let examples: Vec<(Option<(&str, &Shape)>, &str)> = match &format.example {
Some(example) => vec![(None, example.as_str())],
None => format
.variants
.iter()
.map(|(vi, v)| (Some((vi.as_str(), &v.shape)), v.example.as_str()))
.collect(),
};
for (own, line) in examples {
let text = squash(line);
let problem = match (own, format.check(&text)) {
(Some((vi, shape)), _) if !shape.matches(&text, |_| None) => Some(format!(
"the example `{line}` of variant `{vi}` of format `{name}`{layer} does not match its own variant: it {}",
shape.failure(&text)
)),
(_, FormatMatch::Fail(reason) | FormatMatch::Refused(reason)) => {
let reason = reason
.rsplit_once("; a line of this kind reads ")
.map_or(reason.as_str(), |(why, _)| why);
Some(format!("the example `{line}` of format `{name}`{layer} {reason}"))
}
(_, FormatMatch::Ok(None)) => format.key.as_ref().map(|key| {
format!(
"the example `{line}` of format `{name}`{layer} carries no key: the format captures `{key}`"
)
}),
(_, FormatMatch::Ok(Some(_))) => None,
};
if let Some(message) = problem {
out.push(("TL_65", message, Some(text), line_of(own.map(|(vi, _)| vi))));
}
}
for (i, (vi, variant)) in format.variants.iter().enumerate() {
let text = squash(&variant.example);
if !variant.shape.matches(&text, |_| None) {
continue;
}
for (j, (vj, other)) in format.variants.iter().enumerate() {
if i != j && other.shape.matches(&text, |_| None) {
out.push((
"TL_43",
format!(
"the example of variant `{vi}` of format `{name}`{layer} also matches variant `{vj}`: each variant's example matches its own variant only"
),
None,
line_of(Some(vi)),
));
}
}
}
out
}
impl Examples<'_> {
fn said_of(&mut self, text: &str, line: usize, criterion: &'static str, problem: Problem) {
let text = squash(text);
if problem.is_some() {
self.failed.rules.push(line);
if !text.is_empty() {
self.failed.texts.push(text);
}
}
self.say(line, criterion, problem);
}
fn say(&mut self, line: usize, criterion: &'static str, problem: Problem) {
if let Some(reason) = problem {
let locator = self.at.locate(&self.locator);
self.out.push(
Finding::error(self.path.clone(), Some(line), criterion, reason).at(&locator),
);
}
}
fn inside(&mut self, pos: Option<usize>, rule_id: Option<&String>, f: impl FnOnce(&mut Self)) {
let mut at = self.at.clone();
at.blocks.extend(pos);
if rule_id.is_some() {
at.rule_id = rule_id.cloned();
}
let held = std::mem::replace(&mut self.at, at);
f(self);
self.at = held;
}
fn blocks(&mut self, rules: Option<&[Block]>) {
for block in rules.unwrap_or(&[]) {
self.inside(Some(block.pos), block.rule_id.as_ref(), |e| e.block(block));
}
}
fn block(&mut self, block: &Block) {
for (i, text) in block.examples.iter().enumerate() {
for rule in consumers(block) {
let problem = item(i, &block.examples, unit(self.schema, rule, text));
self.said_of(text, block.common.line, "TL_64", problem);
}
}
for rule in block.units() {
self.units(rule);
}
if let Some(rules) = &block.rules {
self.grammar(rules);
}
self.blocks(block.blocks.as_deref());
}
fn units(&mut self, rule: &Unit) {
for (i, text) in rule.examples.iter().enumerate() {
let problem = item(i, &rule.examples, unit(self.schema, rule, text));
self.inside(None, rule.rule_id.as_ref(), |e| {
e.said_of(text, rule.common.line, "TL_64", problem);
});
}
for line in rule.lines.iter().flatten() {
self.units(line);
}
if let Some(lines) = &rule.lines {
self.grammar(lines);
}
self.blocks(rule.blocks.as_deref());
}
fn grammar(&mut self, rules: &[Unit]) {
for (i, rule) in rules.iter().enumerate() {
if !rule.is_production() {
continue;
}
let examples = examples_of(self.schema, rule);
for (n, text) in examples.iter().enumerate() {
if let Some(earlier) = rules[..i]
.iter()
.position(|r| r.is_production() && has_shape(self.schema, r, text))
{
let problem = Some(format!(
"the example `{text}` is taken by the rule at line {} first, so this rule takes nothing: order the rules from the narrowest",
rules[earlier].common.line
));
self.say(rule.common.line, "TLMD_58", item(n, &examples, problem));
}
}
}
}
}
fn item(i: usize, examples: &[String], problem: Problem) -> Problem {
let reason = problem?;
if examples.len() > 1 {
Some(format!("item {} of `example`: {reason}", i + 1))
} else {
Some(reason)
}
}
fn examples_of(schema: &Schema, rule: &Unit) -> Vec<String> {
if !rule.examples.is_empty() {
return rule.examples.clone();
}
rule.format
.as_ref()
.and_then(|n| schema.core.formats.get(n))
.map(|f| vec![f.first_example().to_string()])
.unwrap_or_default()
}
fn has_shape(schema: &Schema, rule: &Unit, text: &str) -> bool {
rule.fits(&schema.core.formats, &squash(text), |_| None)
}
fn unit(schema: &Schema, rule: &Unit, text: &str) -> Problem {
let squashed = squash(text);
if let Some(select) = &rule.select
&& !select.is_match(&squashed)
{
return Some(format!(
"the example `{text}` does not match `select-pattern` `{}`, so this rule would not govern it",
select.as_str()
));
}
if let Some(shape) = &rule.shape
&& !matches!(shape, Shape::Text(Text::From(_)))
&& !shape.matches(&squashed, |_| None)
{
return Some(format!("the example `{text}` {}", shape.failure(&squashed)));
}
if let Some(reason) = refused(&squashed, &rule.prohibited_patterns, None) {
return Some(format!("the example `{text}` is refused: {reason}"));
}
if let Some(name) = &rule.format {
match rule
.format_in(&schema.core.formats)
.map(|f| (f, f.check(&squashed)))
{
Some((_, FormatMatch::Fail(reason) | FormatMatch::Refused(reason))) => {
return Some(format!("the example `{text}` {reason}"));
}
Some((f, FormatMatch::Ok(None))) if f.key.is_some() && !rule.key_optional => {
return Some(format!(
"the example `{text}` carries no key: the format `{name}` captures `{}`",
f.key.clone().unwrap_or_default()
));
}
_ => {}
}
}
if let Some(k) = &rule.key
&& !rule.key_optional
&& let Some(shape) = &rule.shape
&& captured(shape, k, &squashed).is_none()
{
return Some(format!("the example `{text}` captures no `key` `{k}`"));
}
None
}
fn consumers(block: &Block) -> Vec<&Unit> {
block
.units()
.filter(|r| {
!matches!(r.shape, Some(Shape::Text(_))) && r.examples.is_empty() && r.format.is_none()
})
.collect()
}
fn heading(section: &Section) -> Problem {
let text = section.heading.example.as_ref()?;
let shape = §ion.heading.shape;
if matches!(shape, Shape::Text(_)) || shape.matches(&squash(text), |_| None) {
return None;
}
Some(format!(
"the example `{text}` {}",
shape.failure(&squash(text))
))
}
fn frontmatter(key: &str, constraint: &FrontmatterConstraint) -> Problem {
let text = constraint.example.as_ref()?;
let said = |reason: String| Some(format!("the example of `{key}` {reason}"));
if let Some(c) = &constraint.const_
&& c != text
{
return said(format!("is not the `const` `{c}`"));
}
if let Some(shape) = &constraint.shape
&& constraint.id_template.is_none()
&& !shape.matches(&squash(text), |_| None)
{
return said(shape.failure(&squash(text)));
}
if let Some(values) = &constraint.enum_
&& !values.contains(text)
{
return said(format!("is not one of `{}`", values.join("`, `")));
}
match constraint.format {
Some(FmFormat::Slug) if !is_slug(text) => return said("is not a slug".into()),
Some(FmFormat::Url) if !is_url(text) => return said("is not a URL".into()),
_ => {}
}
if let Some(template) = &constraint.id_template
&& !template.matches(text)
{
return said("does not match the id template".into());
}
None
}