Skip to main content

asciidoc_parser/document/
docinfo.rs

1//! Resolves a document's [docinfo] content from the `docinfo` family of
2//! attributes and a caller-supplied
3//! [`DocinfoFileHandler`](crate::parser::DocinfoFileHandler).
4//!
5//! [docinfo]: https://docs.asciidoctor.org/asciidoc/latest/docinfo/
6
7use crate::{Parser, SafeMode, content::substitute_attributes_in_text, document::InterpretedValue};
8
9/// Where a [docinfo] file's content is injected into the converted output.
10///
11/// Each location corresponds to a distinct set of docinfo files (differentiated
12/// by name) and a distinct insertion point in the output document.
13///
14/// [docinfo]: https://docs.asciidoctor.org/asciidoc/latest/docinfo/
15#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
16pub enum DocinfoLocation {
17    /// Head docinfo: injected into the top of the document (appended to the
18    /// HTML `<head>` element, or the DocBook root `<info>` element).
19    Head,
20
21    /// Header docinfo: injected at the start of the document body (immediately
22    /// before the HTML header `<div>`).
23    Header,
24
25    /// Footer docinfo: injected at the end of the document body (immediately
26    /// after the HTML footer `<div>`).
27    Footer,
28}
29
30impl DocinfoLocation {
31    /// The token used to enable this location in the `docinfo` attribute (e.g.
32    /// the `header` in `private-header`).
33    fn token(self) -> &'static str {
34        match self {
35            Self::Head => "head",
36            Self::Header => "header",
37            Self::Footer => "footer",
38        }
39    }
40
41    /// The infix added to the docinfo file name for this location (`-header` or
42    /// `-footer`; head files have no infix).
43    fn name_infix(self) -> &'static str {
44        match self {
45            Self::Head => "",
46            Self::Header => "-header",
47            Self::Footer => "-footer",
48        }
49    }
50}
51
52/// A document's resolved docinfo content, captured once the document's header
53/// (and body) have been processed and the parser holds the document's final
54/// attribute state.
55///
56/// The content for each location is already concatenated (shared file first,
57/// then private, matching Asciidoctor) and has had `docinfosubs` substitutions
58/// applied. An empty string means no docinfo applies to that location (e.g. no
59/// handler was configured, the `docinfo` attribute did not enable it, or no
60/// matching file was found).
61#[derive(Clone, Debug, Default, Eq, PartialEq)]
62pub(crate) struct Docinfo {
63    head: String,
64    header: String,
65    footer: String,
66}
67
68impl Docinfo {
69    /// Returns the resolved docinfo content for `location` (an empty string
70    /// when none applies).
71    pub(crate) fn content(&self, location: DocinfoLocation) -> &str {
72        match location {
73            DocinfoLocation::Head => &self.head,
74            DocinfoLocation::Header => &self.header,
75            DocinfoLocation::Footer => &self.footer,
76        }
77    }
78
79    /// Resolves a document's docinfo from a parser's current attribute state
80    /// and its configured
81    /// [`DocinfoFileHandler`](crate::parser::DocinfoFileHandler).
82    ///
83    /// Returns empty content when no handler is configured or the `docinfo`
84    /// attribute is unset.
85    pub(crate) fn resolve(parser: &Parser) -> Self {
86        // Docinfo injects the contents of external files directly into the
87        // output, so Asciidoctor disables it entirely at `SafeMode::Secure` and
88        // above (the default). A caller who wants docinfo must relax the safe
89        // mode via [`Parser::with_safe_mode`].
90        if parser.safe_mode() >= SafeMode::Secure {
91            return Self::default();
92        }
93
94        let Some(handler) = parser.docinfo_file_handler.as_ref() else {
95            return Self::default();
96        };
97
98        // The `docinfo` attribute selects which scopes/locations apply. An unset
99        // attribute means no docinfo; an empty value (a bare `:docinfo:`) is
100        // equivalent to `private`.
101        let tokens: Vec<String> = match parser.attribute_value("docinfo") {
102            InterpretedValue::Unset => return Self::default(),
103            InterpretedValue::Set => vec!["private".to_string()],
104            InterpretedValue::Value(v) => v
105                .split(',')
106                .map(|t| t.trim().to_ascii_lowercase())
107                .filter(|t| !t.is_empty())
108                .collect(),
109        };
110
111        if tokens.is_empty() {
112            return Self::default();
113        }
114
115        // Docinfo file names share the output file extension (`outfilesuffix`,
116        // which always begins with a period and defaults to `.html`).
117        let suffix = match parser.attribute_value("outfilesuffix") {
118            InterpretedValue::Value(v) => v,
119            _ => ".html".to_string(),
120        };
121
122        // When `docinfodir` is set, files are searched only there; otherwise the
123        // document directory is searched (the handler owns path resolution).
124        let docinfodir = match parser.attribute_value("docinfodir") {
125            InterpretedValue::Value(v) => Some(v),
126            _ => None,
127        };
128
129        // Private docinfo file names are derived from the document name, so
130        // private scope is only available when a primary file name is known.
131        let docname = parser.docname();
132
133        let apply_attribute_subs = docinfosubs_includes_attributes(parser);
134
135        let resolve_location = |location: DocinfoLocation| -> String {
136            let token = location.token();
137            let infix = location.name_infix();
138
139            let shared_token = format!("shared-{token}");
140            let private_token = format!("private-{token}");
141
142            let shared_enabled = tokens.iter().any(|t| t == "shared" || *t == shared_token);
143            let private_enabled = tokens.iter().any(|t| t == "private" || *t == private_token);
144
145            // Shared content is concatenated before private content, matching
146            // Asciidoctor's output order.
147            let mut parts: Vec<String> = vec![];
148
149            if shared_enabled {
150                let file_name = format!("docinfo{infix}{suffix}");
151                if let Some(content) =
152                    handler.resolve_docinfo(docinfodir.as_deref(), &file_name, parser)
153                {
154                    parts.push(content);
155                }
156            }
157
158            if private_enabled && let Some(docname) = docname.as_deref() {
159                let file_name = format!("{docname}-docinfo{infix}{suffix}");
160                if let Some(content) =
161                    handler.resolve_docinfo(docinfodir.as_deref(), &file_name, parser)
162                {
163                    parts.push(content);
164                }
165            }
166
167            if parts.is_empty() {
168                return String::new();
169            }
170
171            let joined = parts.join("\n");
172
173            if !apply_attribute_subs {
174                return joined;
175            }
176
177            // Attribute substitution may record `warn`-mode warnings whose
178            // offsets refer to the docinfo text, not the document source.
179            // Discard them so they are not reported against the document.
180            let saved = parser.substitution_warnings_len();
181            let substituted = substitute_attributes_in_text(&joined, parser);
182            parser.truncate_substitution_warnings(saved);
183            substituted
184        };
185
186        Self {
187            head: resolve_location(DocinfoLocation::Head),
188            header: resolve_location(DocinfoLocation::Header),
189            footer: resolve_location(DocinfoLocation::Footer),
190        }
191    }
192}
193
194/// Returns whether the `attributes` substitution should be applied to docinfo
195/// content, per the `docinfosubs` attribute.
196///
197/// When `docinfosubs` is unset it has an implied default of `attributes`, so
198/// substitution is applied. When set, substitution is applied only if the
199/// comma-separated list names `attributes`.
200fn docinfosubs_includes_attributes(parser: &Parser) -> bool {
201    match parser.attribute_value("docinfosubs") {
202        InterpretedValue::Unset => true,
203        InterpretedValue::Set => false,
204        InterpretedValue::Value(v) => v.split(',').any(|s| s.trim() == "attributes"),
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use std::collections::HashMap;
211
212    use crate::{Parser, SafeMode, document::DocinfoLocation, parser::DocinfoFileHandler};
213
214    /// A minimal handler that resolves docinfo from a fixed file-name map.
215    #[derive(Debug)]
216    struct MapHandler(HashMap<String, String>);
217
218    impl MapHandler {
219        fn new(pairs: &[(&str, &str)]) -> Self {
220            Self(
221                pairs
222                    .iter()
223                    .map(|(k, v)| (k.to_string(), v.to_string()))
224                    .collect(),
225            )
226        }
227    }
228
229    impl DocinfoFileHandler for MapHandler {
230        fn resolve_docinfo(
231            &self,
232            _docinfodir: Option<&str>,
233            file_name: &str,
234            _parser: &Parser,
235        ) -> Option<String> {
236            self.0.get(file_name).cloned()
237        }
238    }
239
240    fn head_for(src: &str, files: &[(&str, &str)]) -> String {
241        // Docinfo is disabled at `SafeMode::Secure` (the default), so these
242        // tests run in `Server` mode, where docinfo is resolved.
243        Parser::default()
244            .with_safe_mode(SafeMode::Server)
245            .with_primary_file_name("mydoc.adoc")
246            .with_docinfo_file_handler(MapHandler::new(files))
247            .parse(src)
248            .docinfo(DocinfoLocation::Head)
249            .to_string()
250    }
251
252    #[test]
253    fn empty_docinfosubs_disables_substitution() {
254        // A bare `:docinfosubs:` (set, but with no value) names no
255        // substitutions, so attribute references are left untouched.
256        let head = head_for(
257            "= Doc\n:license-url: https://example.org\n:docinfo: shared-head\n:docinfosubs:\n\nBody.",
258            &[("docinfo.html", "{license-url}")],
259        );
260        assert_eq!(head, "{license-url}");
261    }
262
263    #[test]
264    fn blank_docinfo_value_resolves_to_no_locations() {
265        // A `docinfo` value made up only of separators yields no tokens, so no
266        // docinfo is applied.
267        let head = head_for(
268            "= Doc\n:docinfo: ,\n\nBody.",
269            &[("docinfo.html", "X"), ("mydoc-docinfo.html", "Y")],
270        );
271        assert_eq!(head, "");
272    }
273
274    #[test]
275    fn multi_line_content_mixes_references_and_plain_lines() {
276        // A docinfo file whose lines mix attribute references with plain lines
277        // exercises line-by-line substitution and re-joining of the output.
278        let head = head_for(
279            "= Doc\n:name: World\n:docinfo: shared-head\n\nBody.",
280            &[(
281                "docinfo.html",
282                "<p>Hello {name}</p>\n<p>plain line</p>\n<p>{name} again</p>",
283            )],
284        );
285        assert_eq!(
286            head,
287            "<p>Hello World</p>\n<p>plain line</p>\n<p>World again</p>"
288        );
289    }
290
291    #[test]
292    fn drop_line_removes_docinfo_lines_with_missing_references() {
293        // With `attribute-missing=drop-line`, a docinfo line referencing a
294        // missing attribute is dropped while the surrounding lines are kept.
295        let head = head_for(
296            "= Doc\n:attribute-missing: drop-line\n:docinfo: shared-head\n\nBody.",
297            &[("docinfo.html", "keep one\n{nope}\nkeep two")],
298        );
299        assert_eq!(head, "keep one\nkeep two");
300    }
301
302    #[test]
303    fn outfilesuffix_falls_back_to_html() {
304        // A bare `:outfilesuffix:` (set, no value) is not a usable suffix, so
305        // docinfo file names fall back to the `.html` default.
306        let head = head_for(
307            "= Doc\n:docinfo: shared-head\n:outfilesuffix:\n\nBody.",
308            &[("docinfo.html", "HEAD")],
309        );
310        assert_eq!(head, "HEAD");
311    }
312}