Skip to main content

impish_core/
lib.rs

1//! Reading a primitive's README: the obligations it declares.
2
3use std::collections::BTreeMap;
4
5const KINDS: [&str; 5] = [
6    "functional",
7    "non-functional",
8    "persisted-format",
9    "ordering",
10    "security",
11];
12const FIELDS: [&str; 4] = ["must", "why", "kind", "status"];
13
14pub struct Obligation {
15    pub kind: String,
16    pub retired: bool,
17}
18
19struct Block<'a> {
20    id: u32,
21    line: usize,
22    fields: BTreeMap<&'a str, &'a str>,
23}
24
25/// The obligations a primitive's README declares under `## Obligations`.
26/// Every line there must belong to a block; `file` names the README in the
27/// problems, which are sorted by line.
28pub fn obligations(readme: &str, file: &str) -> Result<BTreeMap<u32, Obligation>, Vec<String>> {
29    let mut found = BTreeMap::new();
30    let mut problems = Vec::new();
31    let mut in_section = false;
32    let mut in_fence = false;
33    let mut block = None;
34    for (i, line) in readme.lines().enumerate() {
35        let n = i + 1;
36        let line = line.trim_end();
37        if line.trim_start().starts_with("```") {
38            in_fence = !in_fence;
39            continue;
40        }
41        if in_fence {
42            continue;
43        }
44        if line.starts_with("## ") {
45            finish(block.take(), &mut found, &mut problems);
46            in_section = line == "## Obligations";
47            continue;
48        }
49        if !in_section {
50            if line.starts_with("### A") || line.starts_with("- **A") {
51                problems.push((n, "an obligation outside ## Obligations".to_string()));
52            }
53            continue;
54        }
55        if line.is_empty() {
56            continue;
57        }
58        if let Some(rest) = line.strip_prefix("### ") {
59            finish(block.take(), &mut found, &mut problems);
60            match heading(rest) {
61                Some(id) => {
62                    block = Some(Block {
63                        id,
64                        line: n,
65                        fields: BTreeMap::new(),
66                    })
67                }
68                None => problems.push((n, format!("malformed obligation heading `{line}`"))),
69            }
70            continue;
71        }
72        match (field(line), block.as_mut()) {
73            (Some((name, _)), Some(_)) if !FIELDS.contains(&name) => {
74                problems.push((n, format!("unknown field `{name}`")))
75            }
76            (Some((name, value)), Some(block)) => {
77                if block.fields.insert(name, value).is_some() {
78                    problems.push((n, format!("`{name}` is given twice")));
79                }
80            }
81            _ => problems.push((n, format!("`{line}` is not part of an obligation block"))),
82        }
83    }
84    finish(block, &mut found, &mut problems);
85    if !problems.is_empty() {
86        problems.sort_by_key(|(n, _)| *n);
87        return Err(problems
88            .into_iter()
89            .map(|(n, problem)| format!("{file}:{n}: {problem}"))
90            .collect());
91    }
92    if found.is_empty() {
93        return Err(vec![format!("{file}: declares no obligations")]);
94    }
95    Ok(found)
96}
97
98/// The number in `A<n> — <title>`, the part of a heading after `### `.
99fn heading(rest: &str) -> Option<u32> {
100    let (num, title) = rest.strip_prefix('A')?.split_once(' ')?;
101    title
102        .strip_prefix("— ")
103        .or_else(|| title.strip_prefix("- "))?;
104    num.parse().ok()
105}
106
107/// The name and value of a `- **name:** value` line.
108fn field(line: &str) -> Option<(&str, &str)> {
109    line.strip_prefix("- **")?.split_once(":** ")
110}
111
112fn finish(
113    block: Option<Block>,
114    found: &mut BTreeMap<u32, Obligation>,
115    problems: &mut Vec<(usize, String)>,
116) {
117    let Some(Block { id, line, fields }) = block else {
118        return;
119    };
120    for name in ["must", "kind", "status"] {
121        if !fields.contains_key(name) {
122            problems.push((line, format!("A{id} has no `{name}`")));
123        }
124    }
125    if let Some(kind) = fields.get("kind")
126        && !KINDS.contains(kind)
127    {
128        let kinds = KINDS.join(", ");
129        problems.push((line, format!("A{id} has kind `{kind}`, not one of {kinds}")));
130    }
131    let retired = match fields.get("status") {
132        Some(&"retired") => true,
133        Some(&"active") | None => false,
134        Some(status) => {
135            problems.push((
136                line,
137                format!("A{id} has status `{status}`, not active or retired"),
138            ));
139            false
140        }
141    };
142    let kind = fields.get("kind").unwrap_or(&"").to_string();
143    if found.insert(id, Obligation { kind, retired }).is_some() {
144        problems.push((line, format!("A{id} is declared twice")));
145    }
146}