asciidoc_parser/document/
revision_line.rs

1use std::sync::LazyLock;
2
3use regex::Regex;
4
5use crate::{
6    HasSpan, Parser, Span,
7    content::{Content, SubstitutionGroup},
8};
9
10/// The revision line is the line directly after the author line in the document
11/// header. When the content on this line is structured correctly, the processor
12/// assigns the content to the built-in `revnumber`, `revdate`, and `revremark`
13/// attributes.
14#[derive(Clone, Debug, Eq, PartialEq)]
15pub struct RevisionLine<'src> {
16    revnumber: Option<String>,
17    revdate: String,
18    revremark: Option<String>,
19    source: Span<'src>,
20}
21
22impl<'src> RevisionLine<'src> {
23    pub(crate) fn parse(source: Span<'src>, parser: &mut Parser) -> Self {
24        let (left_of_colon, revremark) = if let Some((loc, remark)) = source.split_once(':') {
25            (loc.to_owned(), Some(remark.trim().to_owned()))
26        } else {
27            (source.data().to_owned(), None)
28        };
29
30        let (revnumber, revdate) = if let Some((rev, date)) = left_of_colon.split_once(',') {
31            // When there's a comma, we have a revision number followed by a date.
32            let rev_trimmed = rev.trim();
33            let cleaned_rev = strip_non_numeric_prefix(rev_trimmed);
34            (Some(cleaned_rev), date.trim().to_owned())
35        } else {
36            // No comma: Check if this is a standalone revision number.
37            let trimmed = left_of_colon.trim();
38            if is_valid_standalone_revision(trimmed) {
39                // This is a standalone revision number (like "v1.2.3").
40                let cleaned_rev = strip_non_numeric_prefix(trimmed);
41                (Some(cleaned_rev), String::new())
42            } else {
43                // This is just a date or other content, not a revision number.
44                (None, trimmed.to_owned())
45            }
46        };
47
48        if let Some(revnumber) = revnumber.as_deref() {
49            parser.set_attribute_by_value_from_header("revnumber", revnumber);
50        }
51
52        parser.set_attribute_by_value_from_header("revdate", &revdate);
53
54        if let Some(revremark) = revremark.as_deref() {
55            parser.set_attribute_by_value_from_header("revremark", revremark);
56        }
57
58        Self {
59            revnumber: revnumber.map(|s| apply_header_subs(&s, parser)),
60            revdate: apply_header_subs(&revdate, parser),
61            revremark: revremark.map(|s| apply_header_subs(&s, parser)),
62            source,
63        }
64    }
65
66    /// Returns the revision number, if present.
67    ///
68    /// The document’s revision number or version is assigned to the built-in
69    /// `revnumber` attribute. When assigned using the revision line, the
70    /// version must contain at least one number, and, if it isn’t followed by a
71    /// date or remark, it must begin with the letter `v` (e.g., `v7.0.6`). Any
72    /// letters or symbols preceding the number, including `v`, are dropped when
73    /// the document is rendered. If `revnumber` is set with an attribute entry,
74    /// it doesn’t have to contain a number and the entire value is displayed in
75    /// the rendered document.
76    pub fn revnumber(&self) -> Option<&str> {
77        self.revnumber.as_deref()
78    }
79
80    /// Returns the revision date.
81    ///
82    /// The date the revision was completed is assigned to the built-in
83    /// `revdate` attribute. If the date is assigned using the revision line, it
84    /// must be separated from the version by a comma (e.g., `78.1,
85    /// 2020-10-10`). The date can contain letters, numbers, symbols, and
86    /// attribute references.
87    pub fn revdate(&self) -> &str {
88        &self.revdate
89    }
90
91    /// Returns the revision remark, if present.
92    ///
93    /// Remarks about the revision of the document are assigned to the built-in
94    /// `revremark` attribute. The remark must be separated by a colon (`:`)
95    /// from the version or revision date when assigned using the revision line.
96    pub fn revremark(&self) -> Option<&str> {
97        self.revremark.as_deref()
98    }
99}
100
101impl<'src> HasSpan<'src> for RevisionLine<'src> {
102    fn span(&self) -> Span<'src> {
103        self.source
104    }
105}
106
107fn apply_header_subs(source: &str, parser: &Parser) -> String {
108    let span = Span::new(source);
109
110    let mut content = Content::from(span);
111    SubstitutionGroup::Header.apply(&mut content, parser, None);
112
113    content.rendered().to_string()
114}
115
116fn is_valid_standalone_revision(s: &str) -> bool {
117    STANDALONE_REVISION.is_match(s)
118}
119
120fn strip_non_numeric_prefix(s: &str) -> String {
121    NON_NUMERIC_PREFIX
122        .captures(s)
123        .and_then(|captures| captures.get(1))
124        .map_or_else(|| s.to_owned(), |m| m.as_str().to_owned())
125}
126
127static STANDALONE_REVISION: LazyLock<Regex> = LazyLock::new(|| {
128    #[allow(clippy::unwrap_used)]
129    Regex::new(r"^v\d").unwrap()
130});
131
132static NON_NUMERIC_PREFIX: LazyLock<Regex> = LazyLock::new(|| {
133    #[allow(clippy::unwrap_used)]
134    Regex::new(r"^[^0-9]*(.*)$").unwrap()
135});
136
137#[cfg(test)]
138mod tests {
139    #![allow(clippy::unwrap_used)]
140
141    use pretty_assertions_sorted::assert_eq;
142
143    use crate::{Parser, Span};
144
145    #[test]
146    fn v_prefix_standalone() {
147        let mut parser = Parser::default();
148        let result = crate::document::RevisionLine::parse(Span::new("v1.2.3"), &mut parser);
149
150        assert_eq!(result.revnumber(), Some("1.2.3"));
151        assert_eq!(result.revdate(), "");
152        assert_eq!(result.revremark(), None);
153    }
154
155    #[test]
156    fn standalone_number_without_v_prefix() {
157        let mut parser = Parser::default();
158        let result = crate::document::RevisionLine::parse(Span::new("1.2.3"), &mut parser);
159
160        // According to Asciidoctor behavior, standalone numbers without "v" are not
161        // revision numbers
162        assert_eq!(result.revnumber(), None);
163        assert_eq!(result.revdate(), "1.2.3");
164        assert_eq!(result.revremark(), None);
165    }
166
167    #[test]
168    fn other_prefix_standalone() {
169        let mut parser = Parser::default();
170        let result = crate::document::RevisionLine::parse(Span::new("LPR1.2.3"), &mut parser);
171
172        // Other prefixes don't have special standalone treatment
173        assert_eq!(result.revnumber(), None);
174        assert_eq!(result.revdate(), "LPR1.2.3");
175        assert_eq!(result.revremark(), None);
176    }
177
178    #[test]
179    fn v_prefix_with_comma_and_date() {
180        let mut parser = Parser::default();
181        let result =
182            crate::document::RevisionLine::parse(Span::new("v1.2.3, 2023-01-15"), &mut parser);
183
184        assert_eq!(result.revnumber(), Some("1.2.3"));
185        assert_eq!(result.revdate(), "2023-01-15");
186        assert_eq!(result.revremark(), None);
187    }
188
189    #[test]
190    fn other_prefix_with_comma_and_date() {
191        let mut parser = Parser::default();
192        let result =
193            crate::document::RevisionLine::parse(Span::new("LPR1.2.3, 2023-01-15"), &mut parser);
194
195        // With comma, other prefixes should be stripped from revision number
196        assert_eq!(result.revnumber(), Some("1.2.3"));
197        assert_eq!(result.revdate(), "2023-01-15");
198        assert_eq!(result.revremark(), None);
199    }
200
201    #[test]
202    fn revision_with_colon_and_remark() {
203        let mut parser = Parser::default();
204        let result =
205            crate::document::RevisionLine::parse(Span::new("v1.2.3: A great release"), &mut parser);
206
207        assert_eq!(result.revnumber(), Some("1.2.3"));
208        assert_eq!(result.revdate(), "");
209        assert_eq!(result.revremark(), Some("A great release"));
210    }
211
212    #[test]
213    fn full_revision_line() {
214        let mut parser = Parser::default();
215        let result = crate::document::RevisionLine::parse(
216            Span::new("v2.1.0, 2023-12-25: Christmas release"),
217            &mut parser,
218        );
219
220        assert_eq!(result.revnumber(), Some("2.1.0"));
221        assert_eq!(result.revdate(), "2023-12-25");
222        assert_eq!(result.revremark(), Some("Christmas release"));
223    }
224
225    #[test]
226    fn only_date() {
227        let mut parser = Parser::default();
228        let result = crate::document::RevisionLine::parse(Span::new("2023-01-15"), &mut parser);
229
230        // Just a date, no revision number
231        assert_eq!(result.revnumber(), None);
232        assert_eq!(result.revdate(), "2023-01-15");
233        assert_eq!(result.revremark(), None);
234    }
235
236    #[test]
237    fn date_with_remark() {
238        let mut parser = Parser::default();
239        let result = crate::document::RevisionLine::parse(
240            Span::new("2023-01-15: New year update"),
241            &mut parser,
242        );
243
244        assert_eq!(result.revnumber(), None);
245        assert_eq!(result.revdate(), "2023-01-15");
246        assert_eq!(result.revremark(), Some("New year update"));
247    }
248
249    #[test]
250    fn whitespace_handling() {
251        let mut parser = Parser::default();
252        let result = crate::document::RevisionLine::parse(
253            Span::new("  v1.0.0  ,   Jan 1, 2023   :   Initial release  "),
254            &mut parser,
255        );
256
257        assert_eq!(result.revnumber(), Some("1.0.0"));
258        assert_eq!(result.revdate(), "Jan 1, 2023");
259        assert_eq!(result.revremark(), Some("Initial release"));
260    }
261
262    #[test]
263    fn v_only_no_digits() {
264        let mut parser = Parser::default();
265        let result = crate::document::RevisionLine::parse(Span::new("v"), &mut parser);
266
267        // "v" without digits should not be treated as a standalone revision
268        assert_eq!(result.revnumber(), None);
269        assert_eq!(result.revdate(), "v");
270        assert_eq!(result.revremark(), None);
271    }
272
273    #[test]
274    fn complex_version_with_v() {
275        let mut parser = Parser::default();
276        let result = crate::document::RevisionLine::parse(Span::new("v1.2.3-beta.1"), &mut parser);
277
278        assert_eq!(result.revnumber(), Some("1.2.3-beta.1"));
279        assert_eq!(result.revdate(), "");
280        assert_eq!(result.revremark(), None);
281    }
282
283    #[test]
284    fn numeric_prefix_stripped() {
285        let mut parser = Parser::default();
286        let result =
287            crate::document::RevisionLine::parse(Span::new("abc123def, 2023-01-01"), &mut parser);
288
289        // Non-numeric prefix should be stripped, leaving "123def"
290        assert_eq!(result.revnumber(), Some("123def"));
291        assert_eq!(result.revdate(), "2023-01-01");
292        assert_eq!(result.revremark(), None);
293    }
294
295    #[test]
296    fn no_numeric_content() {
297        let mut parser = Parser::default();
298        let result =
299            crate::document::RevisionLine::parse(Span::new("nodigits, 2023-01-01"), &mut parser);
300
301        // When there are no digits, the prefix stripping should leave empty string
302        assert_eq!(result.revnumber(), Some(""));
303        assert_eq!(result.revdate(), "2023-01-01");
304        assert_eq!(result.revremark(), None);
305    }
306
307    #[test]
308    fn sets_document_attributes_with_all_components() {
309        let mut parser = Parser::default();
310        let _result = crate::document::RevisionLine::parse(
311            Span::new("v2.1.0, 2023-12-25: Christmas release"),
312            &mut parser,
313        );
314
315        assert_eq!(
316            parser.attribute_value("revnumber").as_maybe_str(),
317            Some("2.1.0")
318        );
319
320        assert_eq!(
321            parser.attribute_value("revdate").as_maybe_str(),
322            Some("2023-12-25")
323        );
324
325        assert_eq!(
326            parser.attribute_value("revremark").as_maybe_str(),
327            Some("Christmas release")
328        );
329    }
330
331    #[test]
332    fn sets_document_attributes_revision_number_only() {
333        let mut parser = Parser::default();
334        let _result = crate::document::RevisionLine::parse(Span::new("v1.2.3"), &mut parser);
335
336        assert_eq!(
337            parser.attribute_value("revnumber").as_maybe_str(),
338            Some("1.2.3")
339        );
340
341        assert_eq!(parser.attribute_value("revdate").as_maybe_str(), Some(""));
342        assert_eq!(parser.attribute_value("revremark").as_maybe_str(), None);
343    }
344
345    #[test]
346    fn sets_document_attributes_date_only() {
347        let mut parser = Parser::default();
348        let _result = crate::document::RevisionLine::parse(Span::new("2023-01-15"), &mut parser);
349
350        assert_eq!(parser.attribute_value("revnumber").as_maybe_str(), None);
351
352        assert_eq!(
353            parser.attribute_value("revdate").as_maybe_str(),
354            Some("2023-01-15")
355        );
356
357        assert_eq!(parser.attribute_value("revremark").as_maybe_str(), None);
358    }
359
360    #[test]
361    fn sets_document_attributes_date_with_remark() {
362        let mut parser = Parser::default();
363        let _result = crate::document::RevisionLine::parse(
364            Span::new("2023-01-15: New year update"),
365            &mut parser,
366        );
367
368        assert_eq!(parser.attribute_value("revnumber").as_maybe_str(), None);
369
370        assert_eq!(
371            parser.attribute_value("revdate").as_maybe_str(),
372            Some("2023-01-15")
373        );
374
375        assert_eq!(
376            parser.attribute_value("revremark").as_maybe_str(),
377            Some("New year update")
378        );
379    }
380
381    #[test]
382    fn sets_document_attributes_revision_with_date() {
383        let mut parser = Parser::default();
384        let _result =
385            crate::document::RevisionLine::parse(Span::new("v1.2.3, 2023-01-15"), &mut parser);
386
387        assert_eq!(
388            parser.attribute_value("revnumber").as_maybe_str(),
389            Some("1.2.3")
390        );
391
392        assert_eq!(
393            parser.attribute_value("revdate").as_maybe_str(),
394            Some("2023-01-15")
395        );
396
397        assert_eq!(parser.attribute_value("revremark").as_maybe_str(), None);
398    }
399
400    #[test]
401    fn sets_document_attributes_revision_with_remark_only() {
402        let mut parser = Parser::default();
403        let _result =
404            crate::document::RevisionLine::parse(Span::new("v1.2.3: A great release"), &mut parser);
405
406        assert_eq!(
407            parser.attribute_value("revnumber").as_maybe_str(),
408            Some("1.2.3")
409        );
410
411        assert_eq!(parser.attribute_value("revdate").as_maybe_str(), Some(""));
412
413        assert_eq!(
414            parser.attribute_value("revremark").as_maybe_str(),
415            Some("A great release")
416        );
417    }
418
419    #[test]
420    fn sets_document_attributes_with_whitespace_handling() {
421        let mut parser = Parser::default();
422        let _result = crate::document::RevisionLine::parse(
423            Span::new("  v1.0.0  ,   Jan 1, 2023   :   Initial release  "),
424            &mut parser,
425        );
426
427        assert_eq!(
428            parser.attribute_value("revnumber").as_maybe_str(),
429            Some("1.0.0")
430        );
431
432        assert_eq!(
433            parser.attribute_value("revdate").as_maybe_str(),
434            Some("Jan 1, 2023")
435        );
436
437        assert_eq!(
438            parser.attribute_value("revremark").as_maybe_str(),
439            Some("Initial release")
440        );
441    }
442
443    #[test]
444    fn sets_document_attributes_with_prefix_stripping() {
445        let mut parser = Parser::default();
446        let _result =
447            crate::document::RevisionLine::parse(Span::new("abc123def, 2023-01-01"), &mut parser);
448
449        assert_eq!(
450            parser.attribute_value("revnumber").as_maybe_str(),
451            Some("123def")
452        );
453
454        assert_eq!(
455            parser.attribute_value("revdate").as_maybe_str(),
456            Some("2023-01-01")
457        );
458
459        assert_eq!(parser.attribute_value("revremark").as_maybe_str(), None);
460    }
461
462    #[test]
463    fn sets_document_attributes_complex_version() {
464        let mut parser = Parser::default();
465        let _result = crate::document::RevisionLine::parse(Span::new("v1.2.3-beta.1"), &mut parser);
466
467        assert_eq!(
468            parser.attribute_value("revnumber").as_maybe_str(),
469            Some("1.2.3-beta.1")
470        );
471
472        assert_eq!(parser.attribute_value("revdate").as_maybe_str(), Some(""));
473        assert_eq!(parser.attribute_value("revremark").as_maybe_str(), None);
474    }
475}