mdbook_numthm/
lib.rs

1//! An [mdBook](https://github.com/rust-lang/mdBook) preprocessor for automatically numbering theorems, lemmas, etc.
2
3use log::warn;
4use mdbook::book::{Book, BookItem};
5use mdbook::errors::Result;
6use mdbook::preprocess::{Preprocessor, PreprocessorContext};
7use pathdiff::diff_paths;
8use regex::Regex;
9use serde::Deserialize;
10use std::collections::HashMap;
11use std::ops::{Deref, DerefMut};
12use std::path::{Path, PathBuf};
13
14/// The preprocessor name.
15const NAME: &str = "numthm";
16
17/// An environment handled by the preprocessor.
18#[derive(Debug, Clone, Deserialize)]
19struct Env {
20    /// The name to display in the header, e.g. "Theorem".
21    #[serde(default = "Env::name_default")]
22    name: String,
23    /// The markdown emphasis delimiter to apply to the header, e.g. "**" for bold.
24    #[serde(default = "Env::emph_default")]
25    emph: String,
26}
27
28impl Env {
29    fn create(name: &str, emph: &str) -> Self {
30        Env {
31            name: name.to_string(),
32            emph: emph.to_string(),
33        }
34    }
35    fn name_default() -> String {
36        String::from("Environment")
37    }
38    fn emph_default() -> String {
39        String::from("**")
40    }
41}
42
43/// Environment collection
44#[derive(Debug, Clone, Deserialize)]
45struct EnvMap(HashMap<String, Env>);
46
47impl Default for EnvMap {
48    fn default() -> Self {
49        let mut envs: HashMap<String, Env> = HashMap::new();
50        envs.insert("thm".to_string(), Env::create("Theorem", "**"));
51        envs.insert("lem".to_string(), Env::create("Lemma", "**"));
52        envs.insert("prop".to_string(), Env::create("Proposition", "**"));
53        envs.insert("def".to_string(), Env::create("Definition", "**"));
54        envs.insert("rem".to_string(), Env::create("Remark", "*"));
55        EnvMap(envs)
56    }
57}
58
59impl Deref for EnvMap {
60    type Target = HashMap<String, Env>;
61    fn deref(&self) -> &Self::Target {
62        &self.0
63    }
64}
65impl DerefMut for EnvMap {
66    fn deref_mut(&mut self) -> &mut Self::Target {
67        &mut self.0
68    }
69}
70
71/// The `LabelInfo` structure contains information for formatting the hyperlink to a specific theorem, lemma, etc.
72#[derive(Debug, PartialEq)]
73struct LabelInfo {
74    /// The "numbered name" associated with the label, e.g. "Theorem 1.2.1".
75    num_name: String,
76    /// The path to the file containing the environment with the label.
77    path: PathBuf,
78    /// An optional title.
79    title: Option<String>,
80}
81
82/// A preprocessor for automatically numbering theorems, lemmas, etc.
83#[derive(Clone, Debug, Default, Deserialize)]
84pub struct NumThmPreprocessor {
85    /// The list of environments handled by the preprocessor.
86    environments: EnvMap,
87    /// Whether theorem numbers must be prefixed by the section number.
88    with_prefix: bool,
89}
90
91impl NumThmPreprocessor {
92    pub fn new(ctx: &PreprocessorContext) -> Self {
93        let mut config = Self::default();
94
95        let toml_config: &toml::value::Table = ctx.config.get_preprocessor("numthm").unwrap();
96
97        // Set use of prefix conf.
98        if let Some(b) = toml_config.get("prefix").and_then(toml::Value::as_bool) {
99            config.with_prefix = b;
100        }
101
102        // Get environments table
103        if let Some(envs) = toml_config
104            .get("environments")
105            .and_then(toml::Value::as_table)
106        {
107            for (key, value) in envs.iter() {
108                // Update from entries, but only if data is available
109                if let Some(entry) = toml::Value::as_table(value) {
110                    // Allow removal of entry
111                    if let Some(ignore) = entry.get("ignore").and_then(toml::Value::as_bool) {
112                        if ignore {
113                            config.environments.remove(key);
114                            continue;
115                        }
116                    }
117
118                    let name = entry.get("name").and_then(toml::Value::as_str);
119                    let emph = entry.get("emph").and_then(toml::Value::as_str);
120
121                    if let Some(env) = config.environments.get_mut(key) {
122                        if let Some(v) = name {
123                            env.name = v.to_string();
124                        }
125
126                        if let Some(v) = emph {
127                            env.emph = v.to_string();
128                        }
129                    } else {
130                        config.environments.insert(
131                            String::from(key),
132                            Env::create(name.unwrap_or("Environment"), emph.unwrap_or("**")),
133                        );
134                    }
135                }
136            }
137        }
138
139        config
140    }
141}
142
143impl Preprocessor for NumThmPreprocessor {
144    fn name(&self) -> &str {
145        NAME
146    }
147
148    fn run(&self, _ctx: &PreprocessorContext, mut book: Book) -> Result<Book> {
149        // a hashmap mapping labels to `LabelInfo` structs
150        let mut refs: HashMap<String, LabelInfo> = HashMap::new();
151
152        book.for_each_mut(|item: &mut BookItem| {
153            if let BookItem::Chapter(chapter) = item {
154                if !chapter.is_draft_chapter() {
155                    // one can safely unwrap chapter.path which must be Some(...)
156                    let prefix = if self.with_prefix {
157                        match &chapter.number {
158                            Some(sn) => sn.to_string(),
159                            None => String::new(),
160                        }
161                    } else {
162                        String::new()
163                    };
164                    let path = chapter.path.as_ref().unwrap();
165                    chapter.content = find_and_replace_envs(
166                        &chapter.content,
167                        &prefix,
168                        path,
169                        &self.environments,
170                        &mut refs,
171                    );
172                }
173            }
174        });
175
176        book.for_each_mut(|item: &mut BookItem| {
177            if let BookItem::Chapter(chapter) = item {
178                if !chapter.is_draft_chapter() {
179                    // one can safely unwrap chapter.path which must be Some(...)
180                    let path = chapter.path.as_ref().unwrap();
181                    chapter.content = find_and_replace_refs(&chapter.content, path, &refs);
182                }
183            }
184        });
185
186        Ok(book)
187    }
188}
189
190/// Finds all patterns `{{key}}{mylabel}[mytitle]` where `key` is the key field of `env` (e.g. `thm`)
191/// and replaces them with a header (including the title if a title `mytitle` is provided)
192/// and potentially an anchor if a label `mylabel` is provided;
193/// if a label is provided, it updates the hashmap `refs` with an entry (label, LabelInfo)
194/// allowing to format links to the theorem.
195fn find_and_replace_envs(
196    s: &str,
197    prefix: &str,
198    path: &Path,
199    envs: &EnvMap,
200    refs: &mut HashMap<String, LabelInfo>,
201) -> String {
202    let mut counter: HashMap<String, u32> = envs.iter().map(|(k, _)| (k.clone(), 0)).collect();
203
204    let keys = envs
205        .keys()
206        .map(String::as_str)
207        .collect::<Vec<&str>>()
208        .join("|");
209    let pattern = format!(
210        r"\{{\{{(?P<key>{})\}}\}}(\{{(?P<label>.*?)\}})?(\[(?P<title>.*?)\])?",
211        keys
212    );
213    // see https://regex101.com/ for an explanation of the regex "\{\{(?P<key>key1|key2)\}\}(\{(?P<label>.*?)\})?(\[(?P<title>.*?)\])?"
214    // matches {{key}}{label}[title] where {label} and [title] are optional
215    let re: Regex = Regex::new(pattern.as_str()).unwrap();
216
217    re.replace_all(s, |caps: &regex::Captures| {
218        // key must have been matched
219        let key = caps.name("key").unwrap().as_str();
220
221        // key is absolutely part of env, so unwrap should be ok
222        let env = envs.get(key).unwrap();
223        let name = &env.name;
224        let emph = &env.emph;
225        let ctr = counter.get_mut(key).unwrap();
226        *ctr += 1;
227
228        let anchor = match caps.name("label") {
229            Some(match_label) => {
230                // if a label is given, we must update the hashmap
231                let label = match_label.as_str().to_string();
232                if refs.contains_key(&label) {
233                    // if the same label has already been used we emit a warning and don't update the hashmap
234                    warn!("{name} {prefix}{ctr}: Label `{label}' already used");
235                } else {
236                    refs.insert(
237                        label.clone(),
238                        LabelInfo {
239                            num_name: format!("{name} {prefix}{ctr}"),
240                            path: path.to_path_buf(),
241                            title: caps.name("title").map(|t| t.as_str().to_string()),
242                        },
243                    );
244                }
245                format!("<a name=\"{label}\"></a>\n")
246            }
247            None => String::new(),
248        };
249        let header = match caps.name("title") {
250            Some(match_title) => {
251                let title = match_title.as_str().to_string();
252                format!("{emph}{name} {prefix}{ctr} ({title}).{emph}")
253            }
254            None => {
255                format!("{emph}{name} {prefix}{ctr}.{emph}")
256            }
257        };
258        format!("{anchor}{header}")
259    })
260    .to_string()
261}
262
263/// Finds and replaces all patterns {{ref: label}} where label is an existing key in hashmap `refs`
264/// with a link towards the relevant theorem.
265fn find_and_replace_refs(
266    s: &str,
267    chap_path: &PathBuf,
268    refs: &HashMap<String, LabelInfo>,
269) -> String {
270    // see https://regex101.com/ for an explanation of the regex
271    let re: Regex = Regex::new(r"\{\{(?P<reftype>ref:|tref:)\s*(?P<label>.*?)\}\}").unwrap();
272
273    re.replace_all(s, |caps: &regex::Captures| {
274        let label = caps.name("label").unwrap().as_str().to_string();
275        if refs.contains_key(&label) {
276            let text = match caps.name("reftype").unwrap().as_str() {
277                "ref:" => &refs.get(&label).unwrap().num_name,
278                _ => {
279                    // this must be tref if there is a match
280                    match &refs.get(&label).unwrap().title {
281                        Some(t) => t,
282                        // fallback to the numbered name in case the label does not have an associated title
283                        None => &refs.get(&label).unwrap().num_name,
284                    }
285                }
286            };
287            let path_to_ref = &refs.get(&label).unwrap().path;
288            let rel_path = compute_rel_path(chap_path, path_to_ref);
289            format!("[{text}]({rel_path}#{label})")
290        } else {
291            warn!("Unknown reference: {}", label);
292            "**[??]**".to_string()
293        }
294    })
295    .to_string()
296}
297
298/// Computes the relative path from the folder containing `chap_path` to the file `path_to_ref`.
299fn compute_rel_path(chap_path: &PathBuf, path_to_ref: &PathBuf) -> String {
300    if chap_path == path_to_ref {
301        return "".to_string();
302    }
303    let mut local_chap_path = chap_path.clone();
304    local_chap_path.pop();
305    format!(
306        "{}",
307        diff_paths(path_to_ref, &local_chap_path).unwrap().display()
308    )
309}
310
311#[cfg(test)]
312mod test {
313    use super::*;
314    use lazy_static::lazy_static;
315
316    const SECNUM: &str = "1.2.";
317
318    lazy_static! {
319        static ref ENVMAP: EnvMap = EnvMap::default();
320        static ref PATH: PathBuf = "crypto/groups.md".into();
321    }
322
323    #[test]
324    fn wo_label_wo_title() {
325        let mut refs = HashMap::new();
326        let input = String::from(r"{{prop}}");
327        let output = find_and_replace_envs(&input, SECNUM, &PATH, &ENVMAP, &mut refs);
328        let expected = String::from("**Proposition 1.2.1.**");
329        assert_eq!(output, expected);
330        assert!(refs.is_empty());
331    }
332
333    #[test]
334    fn wo_label_wo_title_replace_default() {
335        let mut env_map = EnvMap::default();
336        env_map.insert(String::from("prop"), Env::create("Proposal", "*"));
337        let mut refs = HashMap::new();
338        let input = String::from(r"{{prop}}");
339        let output = find_and_replace_envs(&input, SECNUM, &PATH, &env_map, &mut refs);
340        let expected = String::from("*Proposal 1.2.1.*");
341        assert_eq!(output, expected);
342        assert!(refs.is_empty());
343    }
344
345    #[test]
346    fn with_label_wo_title() {
347        let mut refs = HashMap::new();
348        let input = String::from(r"{{prop}}{prop:lagrange}");
349        let output = find_and_replace_envs(&input, SECNUM, &PATH, &ENVMAP, &mut refs);
350        let expected = String::from(
351            "<a name=\"prop:lagrange\"></a>\n\
352            **Proposition 1.2.1.**",
353        );
354        assert_eq!(output, expected);
355        assert_eq!(refs.len(), 1);
356        assert_eq!(
357            *refs.get("prop:lagrange").unwrap(),
358            LabelInfo {
359                num_name: "Proposition 1.2.1".to_string(),
360                path: "crypto/groups.md".into(),
361                title: None,
362            }
363        )
364    }
365
366    #[test]
367    fn wo_label_with_title() {
368        let mut refs = HashMap::new();
369        let input = String::from(r"{{prop}}[Lagrange Theorem]");
370        let output = find_and_replace_envs(&input, SECNUM, &PATH, &ENVMAP, &mut refs);
371        let expected = String::from("**Proposition 1.2.1 (Lagrange Theorem).**");
372        assert_eq!(output, expected);
373        assert!(refs.is_empty());
374    }
375
376    #[test]
377    fn with_label_with_title() {
378        let mut refs = HashMap::new();
379        let input = String::from(r"{{prop}}{prop:lagrange}[Lagrange Theorem]");
380        let output = find_and_replace_envs(&input, SECNUM, &PATH, &ENVMAP, &mut refs);
381        let expected = String::from(
382            "<a name=\"prop:lagrange\"></a>\n\
383            **Proposition 1.2.1 (Lagrange Theorem).**",
384        );
385        assert_eq!(output, expected);
386    }
387
388    #[test]
389    fn double_label() {
390        let mut refs = HashMap::new();
391        let input = String::from(
392            r"{{prop}}{prop:lagrange}[Lagrange Theorem] {{thm}}{prop:lagrange}[Another Lagrange Theorem]",
393        );
394        let output = find_and_replace_envs(&input, SECNUM, &PATH, &ENVMAP, &mut refs);
395        let expected = String::from(
396            "<a name=\"prop:lagrange\"></a>\n\
397            **Proposition 1.2.1 (Lagrange Theorem).** \
398            <a name=\"prop:lagrange\"></a>\n\
399            **Theorem 1.2.1 (Another Lagrange Theorem).**",
400        );
401        assert_eq!(output, expected);
402        assert_eq!(refs.len(), 1);
403    }
404
405    #[test]
406    fn label_and_ref_in_same_file() {
407        let mut refs = HashMap::new();
408        let input =
409            String::from(r"{{prop}}{prop:lagrange}[Lagrange Theorem] {{ref: prop:lagrange}}");
410        let output = find_and_replace_envs(&input, SECNUM, &PATH, &ENVMAP, &mut refs);
411        let output = find_and_replace_refs(&output, &PATH, &refs);
412        let expected = String::from(
413            "<a name=\"prop:lagrange\"></a>\n\
414            **Proposition 1.2.1 (Lagrange Theorem).** \
415            [Proposition 1.2.1](#prop:lagrange)",
416        );
417        assert_eq!(output, expected);
418    }
419
420    #[test]
421    fn label_and_ref_in_different_files() {
422        let mut refs = HashMap::new();
423        let label_file: PathBuf = "math/groups.md".into();
424        let ref_file: PathBuf = "crypto/bls_signatures.md".into();
425        let label_input = String::from(r"{{prop}}{prop:lagrange}[Lagrange Theorem]");
426        let ref_input = String::from(r"{{ref: prop:lagrange}}");
427        let _label_output =
428            find_and_replace_envs(&label_input, SECNUM, &label_file, &ENVMAP, &mut refs);
429        let ref_output = find_and_replace_refs(&ref_input, &ref_file, &refs);
430        let expected = String::from("[Proposition 1.2.1](../math/groups.md#prop:lagrange)");
431        assert_eq!(ref_output, expected);
432    }
433
434    #[test]
435    fn label_and_ref_in_different_files_2() {
436        let mut refs = HashMap::new();
437        let label_file: PathBuf = "math/algebra/groups.md".into();
438        let ref_file: PathBuf = "math/crypto//signatures/bls_signatures.md".into();
439        let label_input = String::from(r"{{prop}}{prop:lagrange}[Lagrange Theorem]");
440        let ref_input = String::from(r"{{ref: prop:lagrange}}");
441        let _label_output =
442            find_and_replace_envs(&label_input, SECNUM, &label_file, &ENVMAP, &mut refs);
443        let ref_output = find_and_replace_refs(&ref_input, &ref_file, &refs);
444        let expected = String::from("[Proposition 1.2.1](../../algebra/groups.md#prop:lagrange)");
445        assert_eq!(ref_output, expected);
446    }
447
448    #[test]
449    fn title_ref() {
450        let mut refs = HashMap::new();
451        let label_file: PathBuf = "math/algebra/groups.md".into();
452        let ref_file: PathBuf = "math/crypto//signatures/bls_signatures.md".into();
453        let label_input = String::from(r"{{prop}}{prop:lagrange}[Lagrange Theorem]");
454        let ref_input = String::from(r"{{tref: prop:lagrange}}");
455        let _label_output =
456            find_and_replace_envs(&label_input, SECNUM, &label_file, &ENVMAP, &mut refs);
457        let ref_output = find_and_replace_refs(&ref_input, &ref_file, &refs);
458        let expected = String::from("[Lagrange Theorem](../../algebra/groups.md#prop:lagrange)");
459        assert_eq!(ref_output, expected);
460    }
461
462    #[test]
463    fn title_ref_without_title() {
464        let mut refs = HashMap::new();
465        let label_file: PathBuf = "math/algebra/groups.md".into();
466        let ref_file: PathBuf = "math/crypto//signatures/bls_signatures.md".into();
467        let label_input = String::from(r"{{prop}}{prop:lagrange}");
468        let ref_input = String::from(r"{{tref: prop:lagrange}}");
469        let _label_output =
470            find_and_replace_envs(&label_input, SECNUM, &label_file, &ENVMAP, &mut refs);
471        let ref_output = find_and_replace_refs(&ref_input, &ref_file, &refs);
472        let expected = String::from("[Proposition 1.2.1](../../algebra/groups.md#prop:lagrange)");
473        assert_eq!(ref_output, expected);
474    }
475}