mdbook_admonish/
resolve.rs

1use crate::config::InstanceConfig;
2use crate::types::{BuiltinDirective, CssId, CustomDirective, CustomDirectiveMap, Overrides};
3use std::fmt;
4use std::str::FromStr;
5
6/// All information required to render an admonition.
7///
8/// i.e. all configured options have been resolved at this point.
9#[derive(Debug, PartialEq)]
10pub(crate) struct AdmonitionMeta {
11    pub directive: String,
12    pub title: String,
13    pub css_id: CssId,
14    pub additional_classnames: Vec<String>,
15    pub collapsible: bool,
16}
17
18/// Wrapper type to hold any value directive configuration.
19enum Directive {
20    Builtin(BuiltinDirective),
21    Custom(CustomDirective),
22}
23
24impl fmt::Display for Directive {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        match self {
27            Self::Builtin(builtin) => builtin.fmt(f),
28            Self::Custom(custom) => f.write_str(&custom.directive),
29        }
30    }
31}
32
33impl Directive {
34    fn from_str(custom_directive_map: &CustomDirectiveMap, string: &str) -> Result<Self, ()> {
35        if let Ok(builtin) = BuiltinDirective::from_str(string) {
36            return Ok(Self::Builtin(builtin));
37        }
38
39        if let Some(config) = custom_directive_map.get(string) {
40            return Ok(Self::Custom(config.clone()));
41        }
42
43        Err(())
44    }
45
46    fn title(self, raw_directive: &str) -> String {
47        match self {
48            Directive::Builtin(_) => format_builtin_directive_title(raw_directive),
49            Directive::Custom(custom) => custom
50                .title
51                .clone()
52                .unwrap_or_else(|| uppercase_first(raw_directive)),
53        }
54    }
55}
56
57impl AdmonitionMeta {
58    pub fn from_info_string(
59        info_string: &str,
60        overrides: &Overrides,
61    ) -> Option<Result<Self, String>> {
62        InstanceConfig::from_info_string(info_string)
63            .map(|raw| raw.map(|raw| Self::resolve(raw, overrides)))
64    }
65
66    /// Combine the per-admonition configuration with global defaults (and
67    /// other logic) to resolve the values needed for rendering.
68    fn resolve(raw: InstanceConfig, overrides: &Overrides) -> Self {
69        let InstanceConfig {
70            directive: raw_directive,
71            title,
72            id,
73            additional_classnames,
74            collapsible,
75        } = raw;
76
77        // Use values from block, else load default value
78        let title = title.or_else(|| overrides.book.title.clone());
79
80        let directive = Directive::from_str(&overrides.custom, &raw_directive);
81
82        let collapsible = match directive {
83            // If the directive is a builin one, use collapsible from block, else use default
84            // value of the builtin directive, else use global default value
85            Ok(Directive::Builtin(directive)) => collapsible.unwrap_or(
86                overrides
87                    .builtin
88                    .get(&directive)
89                    .and_then(|config| config.collapsible)
90                    .unwrap_or(overrides.book.collapsible),
91            ),
92            // If the directive is a custom one, use collapsible from block, else use default
93            // value of the custom directive, else use global default value
94            Ok(Directive::Custom(ref custom_dir)) => {
95                collapsible.unwrap_or(custom_dir.collapsible.unwrap_or(overrides.book.collapsible))
96            }
97            Err(_) => collapsible.unwrap_or(overrides.book.collapsible),
98        };
99
100        // Load the directive (and title, if one still not given)
101        let (directive, title) = match (directive, title) {
102            (Ok(directive), None) => (directive.to_string(), directive.title(&raw_directive)),
103            (Err(_), None) => (BuiltinDirective::Note.to_string(), "Note".to_owned()),
104            (Ok(directive), Some(title)) => (directive.to_string(), title),
105            (Err(_), Some(title)) => (BuiltinDirective::Note.to_string(), title),
106        };
107
108        let css_id = if let Some(verbatim) = id {
109            CssId::Verbatim(verbatim)
110        } else {
111            const DEFAULT_CSS_ID_PREFIX: &str = "admonition-";
112            CssId::Prefix(
113                overrides
114                    .book
115                    .css_id_prefix
116                    .clone()
117                    .unwrap_or_else(|| DEFAULT_CSS_ID_PREFIX.to_owned()),
118            )
119        };
120
121        Self {
122            directive,
123            title,
124            css_id,
125            additional_classnames,
126            collapsible,
127        }
128    }
129}
130
131/// Format the title of an admonition directive
132///
133/// We special case a few words to make them look nicer (e.g. "tldr" -> "TL;DR" and "faq" -> "FAQ").
134fn format_builtin_directive_title(input: &str) -> String {
135    match input {
136        "tldr" => "TL;DR".to_owned(),
137        "faq" => "FAQ".to_owned(),
138        _ => uppercase_first(input),
139    }
140}
141
142/// Make the first letter of `input` uppercase.
143///
144/// source: https://stackoverflow.com/a/38406885
145fn uppercase_first(input: &str) -> String {
146    let mut chars = input.chars();
147    match chars.next() {
148        None => String::new(),
149        Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
150    }
151}
152
153#[cfg(test)]
154mod test {
155    use std::collections::HashMap;
156
157    use crate::types::{AdmonitionDefaults, BuiltinDirectiveConfig};
158
159    use super::*;
160    use pretty_assertions::assert_eq;
161
162    #[test]
163    fn test_format_builtin_directive_title() {
164        assert_eq!(format_builtin_directive_title(""), "");
165        assert_eq!(format_builtin_directive_title("a"), "A");
166        assert_eq!(format_builtin_directive_title("tldr"), "TL;DR");
167        assert_eq!(format_builtin_directive_title("faq"), "FAQ");
168        assert_eq!(format_builtin_directive_title("note"), "Note");
169        assert_eq!(format_builtin_directive_title("abstract"), "Abstract");
170        // Unicode
171        assert_eq!(format_builtin_directive_title("🦀"), "🦀");
172    }
173
174    #[test]
175    fn test_admonition_info_from_raw() {
176        assert_eq!(
177            AdmonitionMeta::resolve(
178                InstanceConfig {
179                    directive: " ".to_owned(),
180                    title: None,
181                    id: None,
182                    additional_classnames: Vec::new(),
183                    collapsible: None,
184                },
185                &Overrides::default(),
186            ),
187            AdmonitionMeta {
188                directive: "note".to_owned(),
189                title: "Note".to_owned(),
190                css_id: CssId::Prefix("admonition-".to_owned()),
191                additional_classnames: Vec::new(),
192                collapsible: false,
193            }
194        );
195    }
196
197    #[test]
198    fn test_admonition_info_from_raw_with_defaults() {
199        assert_eq!(
200            AdmonitionMeta::resolve(
201                InstanceConfig {
202                    directive: " ".to_owned(),
203                    title: None,
204                    id: None,
205                    additional_classnames: Vec::new(),
206                    collapsible: None,
207                },
208                &Overrides {
209                    book: AdmonitionDefaults {
210                        title: Some("Important!!!".to_owned()),
211                        css_id_prefix: Some("custom-prefix-".to_owned()),
212                        collapsible: true,
213                    },
214                    ..Default::default()
215                }
216            ),
217            AdmonitionMeta {
218                directive: "note".to_owned(),
219                title: "Important!!!".to_owned(),
220                css_id: CssId::Prefix("custom-prefix-".to_owned()),
221                additional_classnames: Vec::new(),
222                collapsible: true,
223            }
224        );
225    }
226
227    #[test]
228    fn test_admonition_info_from_raw_with_defaults_and_custom_id() {
229        assert_eq!(
230            AdmonitionMeta::resolve(
231                InstanceConfig {
232                    directive: " ".to_owned(),
233                    title: None,
234                    id: Some("my-custom-id".to_owned()),
235                    additional_classnames: Vec::new(),
236                    collapsible: None,
237                },
238                &Overrides {
239                    book: AdmonitionDefaults {
240                        title: Some("Important!!!".to_owned()),
241                        css_id_prefix: Some("ignored-custom-prefix-".to_owned()),
242                        collapsible: true,
243                    },
244                    ..Default::default()
245                }
246            ),
247            AdmonitionMeta {
248                directive: "note".to_owned(),
249                title: "Important!!!".to_owned(),
250                css_id: CssId::Verbatim("my-custom-id".to_owned()),
251                additional_classnames: Vec::new(),
252                collapsible: true,
253            }
254        );
255    }
256
257    #[test]
258    fn test_admonition_info_from_raw_with_custom_directive() {
259        assert_eq!(
260            AdmonitionMeta::resolve(
261                InstanceConfig {
262                    directive: "frog".to_owned(),
263                    title: None,
264                    id: None,
265                    additional_classnames: Vec::new(),
266                    collapsible: None,
267                },
268                &Overrides {
269                    custom: [CustomDirective {
270                        directive: "frog".to_owned(),
271                        aliases: Vec::new(),
272                        title: None,
273                        collapsible: None,
274                    }]
275                    .into_iter()
276                    .collect(),
277                    ..Default::default()
278                }
279            ),
280            AdmonitionMeta {
281                directive: "frog".to_owned(),
282                title: "Frog".to_owned(),
283                css_id: CssId::Prefix("admonition-".to_owned()),
284                additional_classnames: Vec::new(),
285                collapsible: false,
286            }
287        );
288    }
289
290    #[test]
291    fn test_admonition_info_from_raw_with_custom_directive_and_title() {
292        assert_eq!(
293            AdmonitionMeta::resolve(
294                InstanceConfig {
295                    directive: "frog".to_owned(),
296                    title: None,
297                    id: None,
298                    additional_classnames: Vec::new(),
299                    collapsible: None,
300                },
301                &Overrides {
302                    custom: [CustomDirective {
303                        directive: "frog".to_owned(),
304                        aliases: Vec::new(),
305                        title: Some("🏳️‍🌈".to_owned()),
306                        collapsible: None,
307                    }]
308                    .into_iter()
309                    .collect(),
310                    ..Default::default()
311                }
312            ),
313            AdmonitionMeta {
314                directive: "frog".to_owned(),
315                title: "🏳️‍🌈".to_owned(),
316                css_id: CssId::Prefix("admonition-".to_owned()),
317                additional_classnames: Vec::new(),
318                collapsible: false,
319            }
320        );
321    }
322
323    #[test]
324    fn test_admonition_info_from_raw_with_custom_directive_alias() {
325        assert_eq!(
326            AdmonitionMeta::resolve(
327                InstanceConfig {
328                    directive: "toad".to_owned(),
329                    title: Some("Still a frog".to_owned()),
330                    id: None,
331                    additional_classnames: Vec::new(),
332                    collapsible: None,
333                },
334                &Overrides {
335                    custom: [CustomDirective {
336                        directive: "frog".to_owned(),
337                        aliases: vec!["newt".to_owned(), "toad".to_owned()],
338                        title: Some("🏳️‍🌈".to_owned()),
339                        collapsible: None,
340                    }]
341                    .into_iter()
342                    .collect(),
343                    ..Default::default()
344                }
345            ),
346            AdmonitionMeta {
347                directive: "frog".to_owned(),
348                title: "Still a frog".to_owned(),
349                css_id: CssId::Prefix("admonition-".to_owned()),
350                additional_classnames: Vec::new(),
351                collapsible: false,
352            }
353        );
354    }
355
356    #[test]
357    fn test_admonition_info_from_raw_with_collapsible_custom_directive() {
358        assert_eq!(
359            AdmonitionMeta::resolve(
360                InstanceConfig {
361                    directive: "frog".to_owned(),
362                    title: None,
363                    id: None,
364                    additional_classnames: Vec::new(),
365                    collapsible: None,
366                },
367                &Overrides {
368                    custom: [CustomDirective {
369                        directive: "frog".to_owned(),
370                        aliases: Vec::new(),
371                        title: None,
372                        collapsible: Some(true),
373                    }]
374                    .into_iter()
375                    .collect(),
376                    ..Default::default()
377                }
378            ),
379            AdmonitionMeta {
380                directive: "frog".to_owned(),
381                title: "Frog".to_owned(),
382                css_id: CssId::Prefix("admonition-".to_owned()),
383                additional_classnames: Vec::new(),
384                collapsible: true,
385            }
386        );
387    }
388
389    #[test]
390    fn test_admonition_info_from_raw_with_collapsible_builtin_directive() {
391        assert_eq!(
392            AdmonitionMeta::resolve(
393                InstanceConfig {
394                    directive: "abstract".to_owned(),
395                    title: None,
396                    id: None,
397                    additional_classnames: Vec::new(),
398                    collapsible: None,
399                },
400                &Overrides {
401                    book: AdmonitionDefaults {
402                        title: None,
403                        css_id_prefix: None,
404                        collapsible: false,
405                    },
406                    builtin: HashMap::from([(
407                        BuiltinDirective::Abstract,
408                        BuiltinDirectiveConfig {
409                            collapsible: Some(true),
410                        }
411                    )]),
412                    ..Default::default()
413                }
414            ),
415            AdmonitionMeta {
416                directive: "abstract".to_owned(),
417                title: "Abstract".to_owned(),
418                css_id: CssId::Prefix("admonition-".to_owned()),
419                additional_classnames: Vec::new(),
420                collapsible: true,
421            }
422        );
423    }
424
425    #[test]
426    fn test_admonition_info_from_raw_with_non_collapsible_builtin_directive() {
427        assert_eq!(
428            AdmonitionMeta::resolve(
429                InstanceConfig {
430                    directive: "abstract".to_owned(),
431                    title: None,
432                    id: None,
433                    additional_classnames: Vec::new(),
434                    collapsible: None,
435                },
436                &Overrides {
437                    book: AdmonitionDefaults {
438                        title: None,
439                        css_id_prefix: None,
440                        collapsible: true,
441                    },
442                    builtin: HashMap::from([(
443                        BuiltinDirective::Abstract,
444                        BuiltinDirectiveConfig {
445                            collapsible: Some(false),
446                        }
447                    )]),
448                    ..Default::default()
449                }
450            ),
451            AdmonitionMeta {
452                directive: "abstract".to_owned(),
453                title: "Abstract".to_owned(),
454                css_id: CssId::Prefix("admonition-".to_owned()),
455                additional_classnames: Vec::new(),
456                collapsible: false,
457            }
458        );
459    }
460}