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        // Resolve attribute references *before* recording the built-in
49        // `revnumber`/`revdate`/`revremark` document attributes, so a revision
50        // component written as an attribute reference (e.g. `{project-version}`)
51        // is reflected in `doc.attr` with its value resolved rather than as the
52        // raw `{...}` text.
53        let revnumber = revnumber.map(|s| {
54            let resolved = apply_header_subs(&s, parser);
55            parser.set_attribute_by_value_from_header("revnumber", &resolved);
56            resolved
57        });
58
59        let revdate = {
60            let resolved = apply_header_subs(&revdate, parser);
61            parser.set_attribute_by_value_from_header("revdate", &resolved);
62            resolved
63        };
64
65        let revremark = revremark.map(|s| {
66            let resolved = apply_header_subs(&s, parser);
67            parser.set_attribute_by_value_from_header("revremark", &resolved);
68            resolved
69        });
70
71        Self {
72            revnumber,
73            revdate,
74            revremark,
75            source,
76        }
77    }
78
79    /// Returns the revision number, if present.
80    ///
81    /// The document’s revision number or version is assigned to the built-in
82    /// `revnumber` attribute. When assigned using the revision line, the
83    /// version must contain at least one number, and, if it isn’t followed by a
84    /// date or remark, it must begin with the letter `v` (e.g., `v7.0.6`). Any
85    /// letters or symbols preceding the number, including `v`, are dropped when
86    /// the document is rendered. If `revnumber` is set with an attribute entry,
87    /// it doesn’t have to contain a number and the entire value is displayed in
88    /// the rendered document.
89    pub fn revnumber(&self) -> Option<&str> {
90        self.revnumber.as_deref()
91    }
92
93    /// Returns the revision date.
94    ///
95    /// The date the revision was completed is assigned to the built-in
96    /// `revdate` attribute. If the date is assigned using the revision line, it
97    /// must be separated from the version by a comma (e.g., `78.1,
98    /// 2020-10-10`). The date can contain letters, numbers, symbols, and
99    /// attribute references.
100    pub fn revdate(&self) -> &str {
101        &self.revdate
102    }
103
104    /// Returns the revision remark, if present.
105    ///
106    /// Remarks about the revision of the document are assigned to the built-in
107    /// `revremark` attribute. The remark must be separated by a colon (`:`)
108    /// from the version or revision date when assigned using the revision line.
109    pub fn revremark(&self) -> Option<&str> {
110        self.revremark.as_deref()
111    }
112}
113
114impl<'src> HasSpan<'src> for RevisionLine<'src> {
115    fn span(&self) -> Span<'src> {
116        self.source
117    }
118}
119
120fn apply_header_subs(source: &str, parser: &Parser) -> String {
121    let span = Span::new(source);
122
123    let mut content = Content::from(span);
124    SubstitutionGroup::Header.apply(&mut content, parser, None);
125
126    content.rendered().to_string()
127}
128
129fn is_valid_standalone_revision(s: &str) -> bool {
130    STANDALONE_REVISION.is_match(s)
131}
132
133fn strip_non_numeric_prefix(s: &str) -> String {
134    NON_NUMERIC_PREFIX
135        .captures(s)
136        .and_then(|captures| captures.get(1))
137        .map_or_else(|| s.to_owned(), |m| m.as_str().to_owned())
138}
139
140static STANDALONE_REVISION: LazyLock<Regex> = LazyLock::new(|| {
141    #[allow(clippy::unwrap_used)]
142    Regex::new(r"^v\d").unwrap()
143});
144
145static NON_NUMERIC_PREFIX: LazyLock<Regex> = LazyLock::new(|| {
146    // Strip any leading run of characters that are neither a digit nor the start
147    // of an attribute reference (`{`). Stopping at `{` preserves a revision
148    // number written purely as an attribute reference (e.g. `v{project-version}`
149    // keeps `{project-version}` for later substitution) instead of dropping the
150    // whole value because it contains no literal digit.
151    #[allow(clippy::unwrap_used)]
152    Regex::new(r"^[^0-9{]*(.*)$").unwrap()
153});
154
155#[cfg(test)]
156mod tests {
157    #![allow(clippy::unwrap_used)]
158
159    use crate::{Span, tests::prelude::*};
160
161    #[test]
162    fn v_prefix_standalone() {
163        let mut parser = Parser::default();
164        let result = crate::document::RevisionLine::parse(Span::new("v1.2.3"), &mut parser);
165
166        assert_eq!(result.revnumber(), Some("1.2.3"));
167        assert_eq!(result.revdate(), "");
168        assert_eq!(result.revremark(), None);
169    }
170
171    #[test]
172    fn standalone_number_without_v_prefix() {
173        let mut parser = Parser::default();
174        let result = crate::document::RevisionLine::parse(Span::new("1.2.3"), &mut parser);
175
176        // According to Asciidoctor behavior, standalone numbers without "v" are not
177        // revision numbers
178        assert_eq!(result.revnumber(), None);
179        assert_eq!(result.revdate(), "1.2.3");
180        assert_eq!(result.revremark(), None);
181    }
182
183    #[test]
184    fn other_prefix_standalone() {
185        let mut parser = Parser::default();
186        let result = crate::document::RevisionLine::parse(Span::new("LPR1.2.3"), &mut parser);
187
188        // Other prefixes don't have special standalone treatment
189        assert_eq!(result.revnumber(), None);
190        assert_eq!(result.revdate(), "LPR1.2.3");
191        assert_eq!(result.revremark(), None);
192    }
193
194    #[test]
195    fn v_prefix_with_comma_and_date() {
196        let mut parser = Parser::default();
197        let result =
198            crate::document::RevisionLine::parse(Span::new("v1.2.3, 2023-01-15"), &mut parser);
199
200        assert_eq!(result.revnumber(), Some("1.2.3"));
201        assert_eq!(result.revdate(), "2023-01-15");
202        assert_eq!(result.revremark(), None);
203    }
204
205    #[test]
206    fn other_prefix_with_comma_and_date() {
207        let mut parser = Parser::default();
208        let result =
209            crate::document::RevisionLine::parse(Span::new("LPR1.2.3, 2023-01-15"), &mut parser);
210
211        // With comma, other prefixes should be stripped from revision number
212        assert_eq!(result.revnumber(), Some("1.2.3"));
213        assert_eq!(result.revdate(), "2023-01-15");
214        assert_eq!(result.revremark(), None);
215    }
216
217    #[test]
218    fn revision_with_colon_and_remark() {
219        let mut parser = Parser::default();
220        let result =
221            crate::document::RevisionLine::parse(Span::new("v1.2.3: A great release"), &mut parser);
222
223        assert_eq!(result.revnumber(), Some("1.2.3"));
224        assert_eq!(result.revdate(), "");
225        assert_eq!(result.revremark(), Some("A great release"));
226    }
227
228    #[test]
229    fn full_revision_line() {
230        let mut parser = Parser::default();
231        let result = crate::document::RevisionLine::parse(
232            Span::new("v2.1.0, 2023-12-25: Christmas release"),
233            &mut parser,
234        );
235
236        assert_eq!(result.revnumber(), Some("2.1.0"));
237        assert_eq!(result.revdate(), "2023-12-25");
238        assert_eq!(result.revremark(), Some("Christmas release"));
239    }
240
241    #[test]
242    fn only_date() {
243        let mut parser = Parser::default();
244        let result = crate::document::RevisionLine::parse(Span::new("2023-01-15"), &mut parser);
245
246        // Just a date, no revision number
247        assert_eq!(result.revnumber(), None);
248        assert_eq!(result.revdate(), "2023-01-15");
249        assert_eq!(result.revremark(), None);
250    }
251
252    #[test]
253    fn date_with_remark() {
254        let mut parser = Parser::default();
255        let result = crate::document::RevisionLine::parse(
256            Span::new("2023-01-15: New year update"),
257            &mut parser,
258        );
259
260        assert_eq!(result.revnumber(), None);
261        assert_eq!(result.revdate(), "2023-01-15");
262        assert_eq!(result.revremark(), Some("New year update"));
263    }
264
265    #[test]
266    fn whitespace_handling() {
267        let mut parser = Parser::default();
268        let result = crate::document::RevisionLine::parse(
269            Span::new("  v1.0.0  ,   Jan 1, 2023   :   Initial release  "),
270            &mut parser,
271        );
272
273        assert_eq!(result.revnumber(), Some("1.0.0"));
274        assert_eq!(result.revdate(), "Jan 1, 2023");
275        assert_eq!(result.revremark(), Some("Initial release"));
276    }
277
278    #[test]
279    fn v_only_no_digits() {
280        let mut parser = Parser::default();
281        let result = crate::document::RevisionLine::parse(Span::new("v"), &mut parser);
282
283        // "v" without digits should not be treated as a standalone revision
284        assert_eq!(result.revnumber(), None);
285        assert_eq!(result.revdate(), "v");
286        assert_eq!(result.revremark(), None);
287    }
288
289    #[test]
290    fn complex_version_with_v() {
291        let mut parser = Parser::default();
292        let result = crate::document::RevisionLine::parse(Span::new("v1.2.3-beta.1"), &mut parser);
293
294        assert_eq!(result.revnumber(), Some("1.2.3-beta.1"));
295        assert_eq!(result.revdate(), "");
296        assert_eq!(result.revremark(), None);
297    }
298
299    #[test]
300    fn numeric_prefix_stripped() {
301        let mut parser = Parser::default();
302        let result =
303            crate::document::RevisionLine::parse(Span::new("abc123def, 2023-01-01"), &mut parser);
304
305        // Non-numeric prefix should be stripped, leaving "123def"
306        assert_eq!(result.revnumber(), Some("123def"));
307        assert_eq!(result.revdate(), "2023-01-01");
308        assert_eq!(result.revremark(), None);
309    }
310
311    #[test]
312    fn no_numeric_content() {
313        let mut parser = Parser::default();
314        let result =
315            crate::document::RevisionLine::parse(Span::new("nodigits, 2023-01-01"), &mut parser);
316
317        // When there are no digits, the prefix stripping should leave empty string
318        assert_eq!(result.revnumber(), Some(""));
319        assert_eq!(result.revdate(), "2023-01-01");
320        assert_eq!(result.revremark(), None);
321    }
322
323    #[test]
324    fn comma_form_prefix_stops_at_attribute_reference() {
325        // Mirrors Asciidoctor's `RevisionInfoLineRx`, whose revision-number
326        // prefix class is `[^\d{]*`: the leading run stops at `{`, so an
327        // attribute reference survives prefix removal and is then resolved by
328        // the header substitutions. With the referenced attribute undefined the
329        // reference is preserved verbatim (default `attribute-missing` skips it).
330        let mut parser = Parser::default();
331        let result =
332            crate::document::RevisionLine::parse(Span::new("v{draft}, 2024-01-01"), &mut parser);
333
334        assert_eq!(result.revnumber(), Some("{draft}"));
335        assert_eq!(result.revdate(), "2024-01-01");
336        assert_eq!(result.revremark(), None);
337    }
338
339    #[test]
340    fn comma_form_prefix_absorbs_escape_before_reference() {
341        // Also mirrors Asciidoctor: the `[^\d{]*` prefix absorbs any leading
342        // characters up to the first `{` — including a backslash — so
343        // `v\{draft}1` yields `{draft}1`, an active reference the header
344        // substitutions then resolve when `draft` is defined.
345        let mut parser = Parser::default().with_intrinsic_attribute(
346            "draft",
347            "beta",
348            ModificationContext::Anywhere,
349        );
350        let result =
351            crate::document::RevisionLine::parse(Span::new("v\\{draft}1, 2024-01-01"), &mut parser);
352
353        assert_eq!(result.revnumber(), Some("beta1"));
354        assert_eq!(result.revdate(), "2024-01-01");
355        assert_eq!(result.revremark(), None);
356    }
357
358    #[test]
359    fn sets_document_attributes_with_all_components() {
360        let mut parser = Parser::default();
361        let _result = crate::document::RevisionLine::parse(
362            Span::new("v2.1.0, 2023-12-25: Christmas release"),
363            &mut parser,
364        );
365
366        assert_eq!(
367            parser.attribute_value("revnumber").as_maybe_str(),
368            Some("2.1.0")
369        );
370
371        assert_eq!(
372            parser.attribute_value("revdate").as_maybe_str(),
373            Some("2023-12-25")
374        );
375
376        assert_eq!(
377            parser.attribute_value("revremark").as_maybe_str(),
378            Some("Christmas release")
379        );
380    }
381
382    #[test]
383    fn sets_document_attributes_revision_number_only() {
384        let mut parser = Parser::default();
385        let _result = crate::document::RevisionLine::parse(Span::new("v1.2.3"), &mut parser);
386
387        assert_eq!(
388            parser.attribute_value("revnumber").as_maybe_str(),
389            Some("1.2.3")
390        );
391
392        assert_eq!(parser.attribute_value("revdate").as_maybe_str(), Some(""));
393        assert_eq!(parser.attribute_value("revremark").as_maybe_str(), None);
394    }
395
396    #[test]
397    fn sets_document_attributes_date_only() {
398        let mut parser = Parser::default();
399        let _result = crate::document::RevisionLine::parse(Span::new("2023-01-15"), &mut parser);
400
401        assert_eq!(parser.attribute_value("revnumber").as_maybe_str(), None);
402
403        assert_eq!(
404            parser.attribute_value("revdate").as_maybe_str(),
405            Some("2023-01-15")
406        );
407
408        assert_eq!(parser.attribute_value("revremark").as_maybe_str(), None);
409    }
410
411    #[test]
412    fn sets_document_attributes_date_with_remark() {
413        let mut parser = Parser::default();
414        let _result = crate::document::RevisionLine::parse(
415            Span::new("2023-01-15: New year update"),
416            &mut parser,
417        );
418
419        assert_eq!(parser.attribute_value("revnumber").as_maybe_str(), None);
420
421        assert_eq!(
422            parser.attribute_value("revdate").as_maybe_str(),
423            Some("2023-01-15")
424        );
425
426        assert_eq!(
427            parser.attribute_value("revremark").as_maybe_str(),
428            Some("New year update")
429        );
430    }
431
432    #[test]
433    fn sets_document_attributes_revision_with_date() {
434        let mut parser = Parser::default();
435        let _result =
436            crate::document::RevisionLine::parse(Span::new("v1.2.3, 2023-01-15"), &mut parser);
437
438        assert_eq!(
439            parser.attribute_value("revnumber").as_maybe_str(),
440            Some("1.2.3")
441        );
442
443        assert_eq!(
444            parser.attribute_value("revdate").as_maybe_str(),
445            Some("2023-01-15")
446        );
447
448        assert_eq!(parser.attribute_value("revremark").as_maybe_str(), None);
449    }
450
451    #[test]
452    fn sets_document_attributes_revision_with_remark_only() {
453        let mut parser = Parser::default();
454        let _result =
455            crate::document::RevisionLine::parse(Span::new("v1.2.3: A great release"), &mut parser);
456
457        assert_eq!(
458            parser.attribute_value("revnumber").as_maybe_str(),
459            Some("1.2.3")
460        );
461
462        assert_eq!(parser.attribute_value("revdate").as_maybe_str(), Some(""));
463
464        assert_eq!(
465            parser.attribute_value("revremark").as_maybe_str(),
466            Some("A great release")
467        );
468    }
469
470    #[test]
471    fn sets_document_attributes_with_whitespace_handling() {
472        let mut parser = Parser::default();
473        let _result = crate::document::RevisionLine::parse(
474            Span::new("  v1.0.0  ,   Jan 1, 2023   :   Initial release  "),
475            &mut parser,
476        );
477
478        assert_eq!(
479            parser.attribute_value("revnumber").as_maybe_str(),
480            Some("1.0.0")
481        );
482
483        assert_eq!(
484            parser.attribute_value("revdate").as_maybe_str(),
485            Some("Jan 1, 2023")
486        );
487
488        assert_eq!(
489            parser.attribute_value("revremark").as_maybe_str(),
490            Some("Initial release")
491        );
492    }
493
494    #[test]
495    fn sets_document_attributes_with_prefix_stripping() {
496        let mut parser = Parser::default();
497        let _result =
498            crate::document::RevisionLine::parse(Span::new("abc123def, 2023-01-01"), &mut parser);
499
500        assert_eq!(
501            parser.attribute_value("revnumber").as_maybe_str(),
502            Some("123def")
503        );
504
505        assert_eq!(
506            parser.attribute_value("revdate").as_maybe_str(),
507            Some("2023-01-01")
508        );
509
510        assert_eq!(parser.attribute_value("revremark").as_maybe_str(), None);
511    }
512
513    #[test]
514    fn sets_document_attributes_complex_version() {
515        let mut parser = Parser::default();
516        let _result = crate::document::RevisionLine::parse(Span::new("v1.2.3-beta.1"), &mut parser);
517
518        assert_eq!(
519            parser.attribute_value("revnumber").as_maybe_str(),
520            Some("1.2.3-beta.1")
521        );
522
523        assert_eq!(parser.attribute_value("revdate").as_maybe_str(), Some(""));
524        assert_eq!(parser.attribute_value("revremark").as_maybe_str(), None);
525    }
526}