use crate::finding::Finding;
use crate::registry::Ctx;
use crate::schema::Schema;
use crate::tree::{BlockKind, DocTree, SectionNode};
pub trait Rule {
fn id(&self) -> &str;
fn run(&self, node: &SectionNode, ctx: &Ctx) -> Vec<Finding>;
}
pub trait DocRule {
fn id(&self) -> &str;
fn run(&self, tree: &DocTree, ctx: &Ctx) -> Vec<Finding>;
}
struct FnRule<F> {
id: String,
f: F,
}
impl<F> Rule for FnRule<F>
where
F: Fn(&SectionNode, &Ctx) -> Vec<Finding>,
{
fn id(&self) -> &str {
&self.id
}
fn run(&self, node: &SectionNode, ctx: &Ctx) -> Vec<Finding> {
(self.f)(node, ctx)
}
}
struct FnDocRule<F> {
id: String,
f: F,
}
impl<F> DocRule for FnDocRule<F>
where
F: Fn(&DocTree, &Ctx) -> Vec<Finding>,
{
fn id(&self) -> &str {
&self.id
}
fn run(&self, tree: &DocTree, ctx: &Ctx) -> Vec<Finding> {
(self.f)(tree, ctx)
}
}
pub fn rule<F>(id: impl Into<String>, f: F) -> Box<dyn Rule>
where
F: Fn(&SectionNode, &Ctx) -> Vec<Finding> + 'static,
{
Box::new(FnRule { id: id.into(), f })
}
pub fn doc_rule<F>(id: impl Into<String>, f: F) -> Box<dyn DocRule>
where
F: Fn(&DocTree, &Ctx) -> Vec<Finding> + 'static,
{
Box::new(FnDocRule { id: id.into(), f })
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ExtraColumns {
#[default]
Ignore,
Error,
}
#[derive(Debug, Clone, Default)]
pub struct TableConfig {
pub columns: Vec<String>,
pub anchor: Option<String>,
pub min_rows: Option<f64>,
pub cells: Vec<(String, Schema)>,
pub extra_columns: ExtraColumns,
}
#[derive(Debug, Clone)]
pub enum EveryItem {
Checkbox,
Schema(Schema),
}
#[derive(Debug, Clone, Default)]
pub struct ListConfig {
pub ordered: Option<bool>,
pub every_item: Option<EveryItem>,
pub min_items: Option<f64>,
}
#[derive(Debug, Clone, Default)]
pub struct CodeConfig {
pub lang: Option<String>,
}
#[derive(Debug, Clone)]
#[allow(missing_docs)]
pub enum LeafConfig {
Table(TableConfig),
List(ListConfig),
Code(CodeConfig),
MaxWords(f64),
}
#[derive(Debug, Clone)]
pub struct LeafSpec {
pub kind: BlockKind,
pub config: Option<LeafConfig>,
pub description: Option<String>,
}
impl LeafSpec {
pub fn new(kind: BlockKind) -> Self {
Self {
kind,
config: None,
description: None,
}
}
pub fn table() -> Self {
Self::new(BlockKind::Table)
}
pub fn table_with(config: TableConfig) -> Self {
Self {
kind: BlockKind::Table,
config: Some(LeafConfig::Table(config)),
description: None,
}
}
pub fn list() -> Self {
Self::new(BlockKind::List)
}
pub fn list_with(config: ListConfig) -> Self {
Self {
kind: BlockKind::List,
config: Some(LeafConfig::List(config)),
description: None,
}
}
pub fn code() -> Self {
Self::new(BlockKind::Code)
}
pub fn code_with(config: CodeConfig) -> Self {
Self {
kind: BlockKind::Code,
config: Some(LeafConfig::Code(config)),
description: None,
}
}
pub fn paragraph() -> Self {
Self::new(BlockKind::Paragraph)
}
pub fn max_words(n: f64) -> Self {
Self {
kind: BlockKind::Paragraph,
config: Some(LeafConfig::MaxWords(n)),
description: None,
}
}
}
pub enum SectionContent {
Single(LeafSpec),
Anchored(Vec<(String, LeafSpec)>),
}
#[derive(Default)]
pub struct SectionOpts {
pub optional: bool,
pub content: Option<SectionContent>,
pub children: Option<SectionSeq>,
pub rules: Vec<Box<dyn Rule>>,
pub anchor: Option<String>,
pub repeatable: bool,
pub min: Option<usize>,
pub max: Option<usize>,
pub description: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Order {
None,
RecognizedRelative,
Strict,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LevelOpts {
pub order: Order,
pub allow_unknown: bool,
}
impl Default for LevelOpts {
fn default() -> Self {
Self {
order: Order::None,
allow_unknown: true,
}
}
}
pub struct SectionSpec {
pub names: Vec<String>,
#[allow(missing_docs)]
pub opts: Option<SectionOpts>,
}
pub struct OneOfSpec {
pub names: Vec<String>,
#[allow(missing_docs)]
pub opts: Option<SectionOpts>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GapSpec {
pub min: Option<usize>,
pub max: Option<usize>,
}
pub enum Spec {
Section(SectionSpec),
Optional(Box<Spec>),
OneOf(OneOfSpec),
Gap(GapSpec),
}
impl Spec {
pub(crate) fn unwrap_optional(&self) -> (&Spec, bool) {
match self {
Spec::Optional(inner) => (inner.unwrap_optional().0, true),
other => (other, false),
}
}
}
#[derive(Default)]
pub struct SectionSeq {
pub opts: LevelOpts,
pub specs: Vec<Spec>,
pub description: Option<String>,
}
pub fn section(name: impl Into<String>) -> Spec {
Spec::Section(SectionSpec {
names: vec![name.into()],
opts: None,
})
}
pub fn section_with(name: impl Into<String>, opts: SectionOpts) -> Spec {
Spec::Section(SectionSpec {
names: vec![name.into()],
opts: Some(opts),
})
}
pub fn one_of<I, S>(names: I) -> Spec
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Spec::OneOf(OneOfSpec {
names: names.into_iter().map(Into::into).collect(),
opts: None,
})
}
pub fn one_of_with<I, S>(names: I, opts: SectionOpts) -> Spec
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Spec::OneOf(OneOfSpec {
names: names.into_iter().map(Into::into).collect(),
opts: Some(opts),
})
}
pub fn optional(spec: Spec) -> Spec {
Spec::Optional(Box::new(spec))
}
pub fn gap() -> Spec {
Spec::Gap(GapSpec::default())
}
pub fn gap_bounds(min: Option<usize>, max: Option<usize>) -> Spec {
Spec::Gap(GapSpec { min, max })
}
pub fn sections(opts: LevelOpts, specs: Vec<Spec>) -> SectionSeq {
SectionSeq {
opts,
specs,
description: None,
}
}
#[derive(Debug, Clone)]
pub struct FrontmatterSpec {
pub schema: Schema,
}
#[derive(Default)]
pub struct Contract {
pub frontmatter: Option<FrontmatterSpec>,
pub body: Option<SectionSeq>,
pub rules: Vec<Box<dyn DocRule>>,
pub description: Option<String>,
}
impl Contract {
pub fn new() -> Self {
Self::default()
}
pub fn body(mut self, body: SectionSeq) -> Self {
self.body = Some(body);
self
}
pub fn doc_rule(mut self, rule: Box<dyn DocRule>) -> Self {
self.rules.push(rule);
self
}
pub fn frontmatter(mut self, spec: FrontmatterSpec) -> Self {
self.frontmatter = Some(spec);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::finding::SourcePos;
use crate::registry::{FindingSpec, Registry};
#[test]
fn builders_assemble_the_grammar() {
let seq = sections(
LevelOpts {
order: Order::Strict,
allow_unknown: false,
},
vec![
section("Title"),
one_of(["Goal", "Goal / Problem statement"]),
gap_bounds(Some(1), Some(2)),
optional(section("Appendix")),
],
);
assert_eq!(seq.specs.len(), 4);
let Spec::Section(s) = &seq.specs[0] else {
panic!("expected a section")
};
assert_eq!(s.names, vec!["Title"]);
let Spec::OneOf(o) = &seq.specs[1] else {
panic!("expected a oneOf")
};
assert_eq!(o.names.len(), 2);
let Spec::Gap(g) = &seq.specs[2] else {
panic!("expected a gap")
};
assert_eq!((g.min, g.max), (Some(1), Some(2)));
let Spec::Optional(inner) = &seq.specs[3] else {
panic!("expected optional")
};
assert!(matches!(**inner, Spec::Section(_)));
}
#[test]
fn max_words_is_a_paragraph_kind_leaf() {
assert_eq!(LeafSpec::max_words(120.0).kind, BlockKind::Paragraph);
assert_eq!(LeafSpec::table().kind, BlockKind::Table);
}
#[test]
fn a_closure_rule_runs_against_a_node_and_mints_via_ctx() {
let r = rule("summary/mentions-outcome", |node, ctx| {
vec![
ctx.finding(
FindingSpec::new(
"summary/mentions-outcome",
"Summary must mention the outcome",
)
.pos(node.pos),
),
]
});
let registry = Registry::default();
let ctx = Ctx::new("doc.md", ®istry);
let node = SectionNode::new("Summary", 2, SourcePos::at(1, 1));
let out = r.run(&node, &ctx);
assert_eq!(r.id(), "summary/mentions-outcome");
assert_eq!(out.len(), 1);
assert_eq!(out[0].path, "doc.md");
assert_eq!(out[0].pos, Some(SourcePos::at(1, 1)));
}
#[test]
fn default_level_opts_match_the_ts_defaults() {
let opts = LevelOpts::default();
assert_eq!(opts.order, Order::None);
assert!(opts.allow_unknown);
}
}