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}