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});