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}