Skip to main content

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 crate::{Span, tests::prelude::*};
142
143    #[test]
144    fn v_prefix_standalone() {
145        let mut parser = Parser::default();
146        let result = crate::document::RevisionLine::parse(Span::new("v1.2.3"), &mut parser);
147
148        assert_eq!(result.revnumber(), Some("1.2.3"));
149        assert_eq!(result.revdate(), "");
150        assert_eq!(result.revremark(), None);
151    }
152
153    #[test]
154    fn standalone_number_without_v_prefix() {
155        let mut parser = Parser::default();
156        let result = crate::document::RevisionLine::parse(Span::new("1.2.3"), &mut parser);
157
158        // According to Asciidoctor behavior, standalone numbers without "v" are not
159        // revision numbers
160        assert_eq!(result.revnumber(), None);
161        assert_eq!(result.revdate(), "1.2.3");
162        assert_eq!(result.revremark(), None);
163    }
164
165    #[test]
166    fn other_prefix_standalone() {
167        let mut parser = Parser::default();
168        let result = crate::document::RevisionLine::parse(Span::new("LPR1.2.3"), &mut parser);
169
170        // Other prefixes don't have special standalone treatment
171        assert_eq!(result.revnumber(), None);
172        assert_eq!(result.revdate(), "LPR1.2.3");
173        assert_eq!(result.revremark(), None);
174    }
175
176    #[test]
177    fn v_prefix_with_comma_and_date() {
178        let mut parser = Parser::default();
179        let result =
180            crate::document::RevisionLine::parse(Span::new("v1.2.3, 2023-01-15"), &mut parser);
181
182        assert_eq!(result.revnumber(), Some("1.2.3"));
183        assert_eq!(result.revdate(), "2023-01-15");
184        assert_eq!(result.revremark(), None);
185    }
186
187    #[test]
188    fn other_prefix_with_comma_and_date() {
189        let mut parser = Parser::default();
190        let result =
191            crate::document::RevisionLine::parse(Span::new("LPR1.2.3, 2023-01-15"), &mut parser);
192
193        // With comma, other prefixes should be stripped from revision number
194        assert_eq!(result.revnumber(), Some("1.2.3"));
195        assert_eq!(result.revdate(), "2023-01-15");
196        assert_eq!(result.revremark(), None);
197    }
198
199    #[test]
200    fn revision_with_colon_and_remark() {
201        let mut parser = Parser::default();
202        let result =
203            crate::document::RevisionLine::parse(Span::new("v1.2.3: A great release"), &mut parser);
204
205        assert_eq!(result.revnumber(), Some("1.2.3"));
206        assert_eq!(result.revdate(), "");
207        assert_eq!(result.revremark(), Some("A great release"));
208    }
209
210    #[test]
211    fn full_revision_line() {
212        let mut parser = Parser::default();
213        let result = crate::document::RevisionLine::parse(
214            Span::new("v2.1.0, 2023-12-25: Christmas release"),
215            &mut parser,
216        );
217
218        assert_eq!(result.revnumber(), Some("2.1.0"));
219        assert_eq!(result.revdate(), "2023-12-25");
220        assert_eq!(result.revremark(), Some("Christmas release"));
221    }
222
223    #[test]
224    fn only_date() {
225        let mut parser = Parser::default();
226        let result = crate::document::RevisionLine::parse(Span::new("2023-01-15"), &mut parser);
227
228        // Just a date, no revision number
229        assert_eq!(result.revnumber(), None);
230        assert_eq!(result.revdate(), "2023-01-15");
231        assert_eq!(result.revremark(), None);
232    }
233
234    #[test]
235    fn date_with_remark() {
236        let mut parser = Parser::default();
237        let result = crate::document::RevisionLine::parse(
238            Span::new("2023-01-15: New year update"),
239            &mut parser,
240        );
241
242        assert_eq!(result.revnumber(), None);
243        assert_eq!(result.revdate(), "2023-01-15");
244        assert_eq!(result.revremark(), Some("New year update"));
245    }
246
247    #[test]
248    fn whitespace_handling() {
249        let mut parser = Parser::default();
250        let result = crate::document::RevisionLine::parse(
251            Span::new("  v1.0.0  ,   Jan 1, 2023   :   Initial release  "),
252            &mut parser,
253        );
254
255        assert_eq!(result.revnumber(), Some("1.0.0"));
256        assert_eq!(result.revdate(), "Jan 1, 2023");
257        assert_eq!(result.revremark(), Some("Initial release"));
258    }
259
260    #[test]
261    fn v_only_no_digits() {
262        let mut parser = Parser::default();
263        let result = crate::document::RevisionLine::parse(Span::new("v"), &mut parser);
264
265        // "v" without digits should not be treated as a standalone revision
266        assert_eq!(result.revnumber(), None);
267        assert_eq!(result.revdate(), "v");
268        assert_eq!(result.revremark(), None);
269    }
270
271    #[test]
272    fn complex_version_with_v() {
273        let mut parser = Parser::default();
274        let result = crate::document::RevisionLine::parse(Span::new("v1.2.3-beta.1"), &mut parser);
275
276        assert_eq!(result.revnumber(), Some("1.2.3-beta.1"));
277        assert_eq!(result.revdate(), "");
278        assert_eq!(result.revremark(), None);
279    }
280
281    #[test]
282    fn numeric_prefix_stripped() {
283        let mut parser = Parser::default();
284        let result =
285            crate::document::RevisionLine::parse(Span::new("abc123def, 2023-01-01"), &mut parser);
286
287        // Non-numeric prefix should be stripped, leaving "123def"
288        assert_eq!(result.revnumber(), Some("123def"));
289        assert_eq!(result.revdate(), "2023-01-01");
290        assert_eq!(result.revremark(), None);
291    }
292
293    #[test]
294    fn no_numeric_content() {
295        let mut parser = Parser::default();
296        let result =
297            crate::document::RevisionLine::parse(Span::new("nodigits, 2023-01-01"), &mut parser);
298
299        // When there are no digits, the prefix stripping should leave empty string
300        assert_eq!(result.revnumber(), Some(""));
301        assert_eq!(result.revdate(), "2023-01-01");
302        assert_eq!(result.revremark(), None);
303    }
304
305    #[test]
306    fn sets_document_attributes_with_all_components() {
307        let mut parser = Parser::default();
308        let _result = crate::document::RevisionLine::parse(
309            Span::new("v2.1.0, 2023-12-25: Christmas release"),
310            &mut parser,
311        );
312
313        assert_eq!(
314            parser.attribute_value("revnumber").as_maybe_str(),
315            Some("2.1.0")
316        );
317
318        assert_eq!(
319            parser.attribute_value("revdate").as_maybe_str(),
320            Some("2023-12-25")
321        );
322
323        assert_eq!(
324            parser.attribute_value("revremark").as_maybe_str(),
325            Some("Christmas release")
326        );
327    }
328
329    #[test]
330    fn sets_document_attributes_revision_number_only() {
331        let mut parser = Parser::default();
332        let _result = crate::document::RevisionLine::parse(Span::new("v1.2.3"), &mut parser);
333
334        assert_eq!(
335            parser.attribute_value("revnumber").as_maybe_str(),
336            Some("1.2.3")
337        );
338
339        assert_eq!(parser.attribute_value("revdate").as_maybe_str(), Some(""));
340        assert_eq!(parser.attribute_value("revremark").as_maybe_str(), None);
341    }
342
343    #[test]
344    fn sets_document_attributes_date_only() {
345        let mut parser = Parser::default();
346        let _result = crate::document::RevisionLine::parse(Span::new("2023-01-15"), &mut parser);
347
348        assert_eq!(parser.attribute_value("revnumber").as_maybe_str(), None);
349
350        assert_eq!(
351            parser.attribute_value("revdate").as_maybe_str(),
352            Some("2023-01-15")
353        );
354
355        assert_eq!(parser.attribute_value("revremark").as_maybe_str(), None);
356    }
357
358    #[test]
359    fn sets_document_attributes_date_with_remark() {
360        let mut parser = Parser::default();
361        let _result = crate::document::RevisionLine::parse(
362            Span::new("2023-01-15: New year update"),
363            &mut parser,
364        );
365
366        assert_eq!(parser.attribute_value("revnumber").as_maybe_str(), None);
367
368        assert_eq!(
369            parser.attribute_value("revdate").as_maybe_str(),
370            Some("2023-01-15")
371        );
372
373        assert_eq!(
374            parser.attribute_value("revremark").as_maybe_str(),
375            Some("New year update")
376        );
377    }
378
379    #[test]
380    fn sets_document_attributes_revision_with_date() {
381        let mut parser = Parser::default();
382        let _result =
383            crate::document::RevisionLine::parse(Span::new("v1.2.3, 2023-01-15"), &mut parser);
384
385        assert_eq!(
386            parser.attribute_value("revnumber").as_maybe_str(),
387            Some("1.2.3")
388        );
389
390        assert_eq!(
391            parser.attribute_value("revdate").as_maybe_str(),
392            Some("2023-01-15")
393        );
394
395        assert_eq!(parser.attribute_value("revremark").as_maybe_str(), None);
396    }
397
398    #[test]
399    fn sets_document_attributes_revision_with_remark_only() {
400        let mut parser = Parser::default();
401        let _result =
402            crate::document::RevisionLine::parse(Span::new("v1.2.3: A great release"), &mut parser);
403
404        assert_eq!(
405            parser.attribute_value("revnumber").as_maybe_str(),
406            Some("1.2.3")
407        );
408
409        assert_eq!(parser.attribute_value("revdate").as_maybe_str(), Some(""));
410
411        assert_eq!(
412            parser.attribute_value("revremark").as_maybe_str(),
413            Some("A great release")
414        );
415    }
416
417    #[test]
418    fn sets_document_attributes_with_whitespace_handling() {
419        let mut parser = Parser::default();
420        let _result = crate::document::RevisionLine::parse(
421            Span::new("  v1.0.0  ,   Jan 1, 2023   :   Initial release  "),
422            &mut parser,
423        );
424
425        assert_eq!(
426            parser.attribute_value("revnumber").as_maybe_str(),
427            Some("1.0.0")
428        );
429
430        assert_eq!(
431            parser.attribute_value("revdate").as_maybe_str(),
432            Some("Jan 1, 2023")
433        );
434
435        assert_eq!(
436            parser.attribute_value("revremark").as_maybe_str(),
437            Some("Initial release")
438        );
439    }
440
441    #[test]
442    fn sets_document_attributes_with_prefix_stripping() {
443        let mut parser = Parser::default();
444        let _result =
445            crate::document::RevisionLine::parse(Span::new("abc123def, 2023-01-01"), &mut parser);
446
447        assert_eq!(
448            parser.attribute_value("revnumber").as_maybe_str(),
449            Some("123def")
450        );
451
452        assert_eq!(
453            parser.attribute_value("revdate").as_maybe_str(),
454            Some("2023-01-01")
455        );
456
457        assert_eq!(parser.attribute_value("revremark").as_maybe_str(), None);
458    }
459
460    #[test]
461    fn sets_document_attributes_complex_version() {
462        let mut parser = Parser::default();
463        let _result = crate::document::RevisionLine::parse(Span::new("v1.2.3-beta.1"), &mut parser);
464
465        assert_eq!(
466            parser.attribute_value("revnumber").as_maybe_str(),
467            Some("1.2.3-beta.1")
468        );
469
470        assert_eq!(parser.attribute_value("revdate").as_maybe_str(), Some(""));
471        assert_eq!(parser.attribute_value("revremark").as_maybe_str(), None);
472    }
473}