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