Skip to main content

asciidoc_parser/document/
attribute.rs

1use std::sync::LazyLock;
2
3use regex::Regex;
4
5use crate::{
6    HasSpan, Parser, Span,
7    attributes::Attrlist,
8    blocks::{ContentModel, IsBlock},
9    content::{Content, SubstitutionGroup},
10    span::MatchedItem,
11    strings::CowStr,
12};
13
14/// Document attributes are effectively document-scoped variables for the
15/// AsciiDoc language. The AsciiDoc language defines a set of built-in
16/// attributes, and also allows the author (or extensions) to define additional
17/// document attributes, which may replace built-in attributes when permitted.
18///
19/// An attribute entry is most often declared in the document header. For
20/// attributes that allow it (which includes general purpose attributes), the
21/// attribute entry can alternately be declared between blocks in the document
22/// body (i.e., the portion of the document below the header).
23///
24/// When an attribute is defined in the document body using an attribute entry,
25/// that’s simply referred to as a document attribute. For any attribute defined
26/// in the body, the attribute is available from the point it is set until it is
27/// unset. Attributes defined in the body are not available via the document
28/// metadata.
29///
30/// An attribute declared between blocks (i.e. in the document body) is
31/// represented in this using the same structure (`Attribute`) as a header
32/// attribute. Since it lives between blocks, we treat it as though it was a
33/// block (and thus implement [`IsBlock`] on this type) even though is not
34/// technically a block.
35#[derive(Clone, Debug, Eq, Hash, PartialEq)]
36pub struct Attribute<'src> {
37    name: Span<'src>,
38    value_source: Option<Span<'src>>,
39    value: InterpretedValue,
40    source: Span<'src>,
41}
42
43impl<'src> Attribute<'src> {
44    pub(crate) fn parse(source: Span<'src>, parser: &Parser) -> Option<MatchedItem<'src, Self>> {
45        let colon = source.take_prefix(":")?;
46
47        let mut unset = false;
48        let line = if colon.after.starts_with('!') {
49            unset = true;
50            colon.after.slice_from(1..)
51        } else {
52            colon.after
53        };
54
55        let name = line.take_user_attr_name()?;
56
57        // A trailing `!` on the name (immediately before the closing colon) is
58        // the postfix unset marker, e.g. `:foo!:`. It is stripped from the
59        // stored name. A name may not carry both a leading and a trailing `!`.
60        let (name_item, after_name) = if name.item.data().ends_with('!') {
61            if unset {
62                return None;
63            }
64            unset = true;
65            let len = name.item.data().len();
66            (name.item.slice_to(..len - 1), name.after)
67        } else {
68            (name.item, name.after)
69        };
70
71        // `line.after` begins immediately after the closing `:` and runs to the
72        // end of the source. The value (and any continuation lines) live here.
73        let line = after_name.take_prefix(":")?;
74
75        let (value, value_source, after) = if unset {
76            // The value (if any) is ignored, but any continuation lines are
77            // still consumed so they don't leak into the block stream.
78            let extent = line
79                .after
80                .take_whitespace()
81                .after
82                .take_value_with_continuation();
83            (InterpretedValue::Unset, None, extent.after)
84        } else {
85            // The first line's content after the closing colon, with trailing
86            // spaces trimmed, decides whether this is a set-only entry.
87            let first_line = line.after.take_normalized_line();
88
89            if first_line.item.is_empty() {
90                // `:name:` with nothing (but optional trailing whitespace) after
91                // the closing colon is a set-only entry.
92                (InterpretedValue::Set, None, first_line.after)
93            } else {
94                // Asciidoctor requires at least one space or tab between the
95                // closing colon and the value. A non-blank character immediately
96                // after the colon means the line is not a valid attribute entry:
97                // the name either contains a colon (`:foo:bar: baz`) or ends with
98                // one (`:foo:: bar`), so the whole line falls through to be parsed
99                // as an ordinary block. See #728.
100                let extent = line
101                    .after
102                    .take_required_whitespace()?
103                    .after
104                    .take_value_with_continuation();
105                (
106                    InterpretedValue::from_raw_value(&extent.item, parser),
107                    Some(extent.item),
108                    extent.after,
109                )
110            }
111        };
112
113        let source = source.trim_remainder(after);
114        Some(MatchedItem {
115            item: Self {
116                name: name_item,
117                value_source,
118                value,
119                source: source.trim_trailing_whitespace(),
120            },
121            after,
122        })
123    }
124
125    /// Return a [`Span`] describing the attribute name.
126    pub fn name(&'src self) -> &'src Span<'src> {
127        &self.name
128    }
129
130    /// Return a [`Span`] containing the attribute's raw value (if present).
131    pub fn raw_value(&'src self) -> Option<Span<'src>> {
132        self.value_source
133    }
134
135    /// Return the attribute's interpolated value.
136    pub fn value(&'src self) -> &'src InterpretedValue {
137        &self.value
138    }
139}
140
141impl<'src> HasSpan<'src> for Attribute<'src> {
142    fn span(&self) -> Span<'src> {
143        self.source
144    }
145}
146
147impl<'src> IsBlock<'src> for Attribute<'src> {
148    fn content_model(&self) -> ContentModel {
149        ContentModel::Empty
150    }
151
152    fn raw_context(&self) -> CowStr<'src> {
153        "attribute".into()
154    }
155
156    fn title_source(&'src self) -> Option<Span<'src>> {
157        None
158    }
159
160    fn title(&self) -> Option<&str> {
161        None
162    }
163
164    fn anchor(&'src self) -> Option<Span<'src>> {
165        None
166    }
167
168    fn anchor_reftext(&'src self) -> Option<Span<'src>> {
169        None
170    }
171
172    fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
173        None
174    }
175}
176
177/// The interpreted value of an [`Attribute`].
178///
179/// If the value contains a textual value, this value will
180/// have any continuation markers resolved, but will no longer
181/// contain a reference to the [`Span`] that contains the value.
182#[derive(Clone, Eq, Hash, PartialEq)]
183pub enum InterpretedValue {
184    /// A custom value with all necessary interpolations applied.
185    Value(String),
186
187    /// No explicit value. This is typically interpreted as either
188    /// boolean `true` or a default value for a built-in attribute.
189    Set,
190
191    /// Explicitly unset. This is typically interpreted as boolean `false`.
192    Unset,
193}
194
195impl std::fmt::Debug for InterpretedValue {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        match self {
198            InterpretedValue::Value(value) => f
199                .debug_tuple("InterpretedValue::Value")
200                .field(value)
201                .finish(),
202
203            InterpretedValue::Set => write!(f, "InterpretedValue::Set"),
204            InterpretedValue::Unset => write!(f, "InterpretedValue::Unset"),
205        }
206    }
207}
208
209impl InterpretedValue {
210    fn from_raw_value(raw_value: &Span<'_>, parser: &Parser) -> Self {
211        let mut content = Content::from(*raw_value);
212
213        // Fold any soft-wrap (`\`) or legacy (`+`) line continuation. When there
214        // is no continuation marker, the value is a plain single line and is
215        // left untouched.
216        let folded = fold_continuation_value(raw_value.data());
217        if let Some(ref folded) = folded {
218            content.rendered = CowStr::Boxed(folded.clone().into_boxed_str());
219        }
220
221        // Asciidoctor's `apply_attribute_value_subs`: when the *entire* value is
222        // a `pass:subs[…]` macro, its bracketed content is taken greedily (up to
223        // the final `]`) and only the named substitutions are applied to it.
224        // Because the capture is greedy and anchored to the whole value, content
225        // that itself contains `[…]` – e.g. a link macro – is not truncated at
226        // the first inner bracket, unlike the general inline pass macro. Any
227        // other value gets the normal header substitutions.
228        let value = folded.as_deref().unwrap_or_else(|| raw_value.data());
229
230        if let Some(captures) = ATTRIBUTE_ENTRY_PASS_MACRO.captures(value) {
231            let inner = captures.get(2).map_or("", |m| m.as_str());
232
233            let rendered = match captures.get(1).map(|m| m.as_str()) {
234                // `pass:[…]` with no substitution list keeps its content verbatim.
235                None => inner.to_string(),
236
237                Some(subs) => {
238                    let (group, _invalid) = SubstitutionGroup::from_custom_string(None, subs);
239                    let mut inner_content = Content::from(Span::new(inner));
240                    group.apply(&mut inner_content, parser, None);
241                    inner_content.rendered().to_string()
242                }
243            };
244
245            return InterpretedValue::Value(rendered);
246        }
247
248        SubstitutionGroup::Header.apply(&mut content, parser, None);
249
250        InterpretedValue::Value(content.rendered.into_string())
251    }
252
253    pub(crate) fn as_maybe_str(&self) -> Option<&str> {
254        match self {
255            InterpretedValue::Value(value) => Some(value.as_ref()),
256            _ => None,
257        }
258    }
259}
260
261/// Matches an attribute-entry value that is *entirely* a `pass:subs[…]` macro,
262/// mirroring Asciidoctor's `AttributeEntryPassMacroRx`
263/// (`/\Apass:([a-z]+(?:,[a-z-]+)*)?\[(.*)\]\Z/m`). Group 1 is the optional
264/// substitution list and group 2 is the bracketed content, captured greedily so
265/// that content containing its own `[…]` is kept intact.
266static ATTRIBUTE_ENTRY_PASS_MACRO: LazyLock<Regex> = LazyLock::new(|| {
267    #[allow(clippy::unwrap_used)]
268    Regex::new(r"(?s)\Apass:([a-z]+(?:,[a-z-]+)*)?\[(.*)\]\z").unwrap()
269});
270
271/// Returns whether `value` is *entirely* a `pass:subs[…]` attribute-entry macro
272/// (see [`ATTRIBUTE_ENTRY_PASS_MACRO`]).
273///
274/// The `:author:` handling uses this to decide whether the stored attribute
275/// value is a *resolved* pass macro. When it is, that substituted value – not
276/// the raw macro syntax – is what should be partitioned into name parts.
277pub(crate) fn is_attribute_entry_pass_macro(value: &str) -> bool {
278    ATTRIBUTE_ENTRY_PASS_MACRO.is_match(value)
279}
280
281/// ASCII whitespace stripped by Ruby's `String#lstrip` / `#rstrip`, which
282/// Asciidoctor applies while fusing a continued attribute value. Unicode
283/// whitespace (e.g. a non-breaking space) is significant and is preserved.
284///
285/// This MUST stay identical to `CONTINUATION_WHITESPACE` in `span::line` (which
286/// the extent scanner `Span::take_value_with_continuation` uses) so the extent
287/// that scanner consumes and the lines this folder actually joins can never
288/// disagree.
289const ASCII_WHITESPACE: [char; 6] = [' ', '\t', '\n', '\r', '\x0C', '\x0B'];
290
291/// Fold an attribute entry value that carries a soft-wrap (`\`) or legacy (`+`)
292/// line continuation across physical lines, mirroring Asciidoctor's
293/// `Parser.process_attribute_entry`.
294///
295/// The continuation marker is fixed by the first line: a modern soft-wrap
296/// marker (a space followed by `\`) or a legacy marker (a space followed by
297/// `+`). Continued lines are left-trimmed and, while they carry the same
298/// marker, keep the value open. Lines are joined with a newline when the
299/// accumulated value ends in a hard line break marker (` +`), otherwise they
300/// are folded into a single space.
301///
302/// The `+` of a preserved hard line break is left in the value verbatim: the
303/// `post_replacements` substitution step is not applied to attribute entry
304/// values, so the `+` is only interpreted as a line break later, when the value
305/// is used in a block whose substitutions include `post_replacements`.
306///
307/// Returns `None` when the value has no continuation marker (a plain
308/// single-line value that needs no folding).
309fn fold_continuation_value(data: &str) -> Option<String> {
310    const HARD_LINE_BREAK: &str = " +";
311
312    let mut lines = data
313        .split('\n')
314        .map(|line| line.strip_suffix('\r').unwrap_or(line));
315
316    // `str::split` always yields at least one item, so `first` is always present.
317    // The first line is never left-trimmed (its leading whitespace was consumed
318    // with the `:name:` prefix), but it is right-trimmed so marker detection
319    // tolerates trailing whitespace, matching the extent scanner.
320    let first = lines
321        .next()
322        .unwrap_or_default()
323        .trim_end_matches(ASCII_WHITESPACE);
324
325    // The continuation marker is fixed by the first line. Without one, the value
326    // is a single line and needs no folding.
327    let con: &str = if first.ends_with(" \\") {
328        " \\"
329    } else if first.ends_with(" +") {
330        " +"
331    } else {
332        return None;
333    };
334
335    // Drop the marker from the first line (already right-trimmed above).
336    let mut value = first[..first.len() - con.len()]
337        .trim_end_matches(ASCII_WHITESPACE)
338        .to_string();
339
340    for line in lines {
341        // A blank line (or end of input) terminates the value.
342        if line.is_empty() {
343            break;
344        }
345
346        // Continuation lines are left- and right-trimmed before the marker is
347        // tested, matching the extent scanner (`take_value_with_continuation`) so
348        // the two never disagree about where the value ends.
349        let line = line.trim_matches(ASCII_WHITESPACE);
350
351        // A line still carrying the marker keeps the value open; strip the
352        // marker (and right-trim) before appending.
353        let keep_open = line.ends_with(con);
354        let piece = if keep_open {
355            line[..line.len() - con.len()].trim_end_matches(ASCII_WHITESPACE)
356        } else {
357            line
358        };
359
360        // Join with a newline when the accumulated value ends in a hard line
361        // break marker (` +`), otherwise fold into a single space.
362        value.push_str(if value.ends_with(HARD_LINE_BREAK) {
363            "\n"
364        } else {
365            " "
366        });
367
368        value.push_str(piece);
369
370        if !keep_open {
371            break;
372        }
373    }
374
375    Some(value)
376}
377
378#[cfg(test)]
379mod tests {
380    #![allow(clippy::panic)]
381    #![allow(clippy::unwrap_used)]
382
383    use std::ops::Deref;
384
385    use crate::{blocks::ContentModel, tests::prelude::*};
386
387    #[test]
388    fn impl_clone() {
389        // Silly test to mark the #[derive(...)] line as covered.
390        let h1 =
391            crate::document::Attribute::parse(crate::Span::new(":foo: bar"), &Parser::default())
392                .unwrap();
393        let h2 = h1.clone();
394        assert_eq!(h1, h2);
395    }
396
397    #[test]
398    fn whole_value_pass_macro_keeps_nested_brackets() {
399        // A value that is entirely a `pass:subs[…]` macro captures its content
400        // greedily to the final `]`, so content that itself contains `[…]` – a
401        // link macro here – is not truncated at the first inner bracket. With
402        // the `n` (normal) substitutions the link and its formatting render.
403        let value = |src| {
404            crate::document::Attribute::parse(crate::Span::new(src), &Parser::default())
405                .unwrap()
406                .item
407                .value()
408                .clone()
409        };
410
411        assert_eq!(
412            value(":foo: pass:n[https://example.org/x[a *b* c]]"),
413            InterpretedValue::Value("<a href=\"https://example.org/x\">a <strong>b</strong> c</a>")
414        );
415
416        // `pass:[…]` with no substitution list keeps its content verbatim,
417        // including nested brackets.
418        assert_eq!(
419            value(":foo: pass:[a[b] *c*]"),
420            InterpretedValue::Value("a[b] *c*")
421        );
422    }
423
424    #[test]
425    fn simple_value() {
426        let mi = crate::document::Attribute::parse(
427            crate::Span::new(":foo: bar\nblah"),
428            &Parser::default(),
429        )
430        .unwrap();
431
432        assert_eq!(
433            mi.item,
434            Attribute {
435                name: Span {
436                    data: "foo",
437                    line: 1,
438                    col: 2,
439                    offset: 1,
440                },
441                value_source: Some(Span {
442                    data: "bar",
443                    line: 1,
444                    col: 7,
445                    offset: 6,
446                }),
447                value: InterpretedValue::Value("bar"),
448                source: Span {
449                    data: ":foo: bar",
450                    line: 1,
451                    col: 1,
452                    offset: 0,
453                }
454            }
455        );
456
457        assert_eq!(mi.item.value(), InterpretedValue::Value("bar"));
458
459        assert_eq!(
460            mi.after,
461            Span {
462                data: "blah",
463                line: 2,
464                col: 1,
465                offset: 10
466            }
467        );
468    }
469
470    #[test]
471    fn no_value() {
472        let mi =
473            crate::document::Attribute::parse(crate::Span::new(":foo:\nblah"), &Parser::default())
474                .unwrap();
475
476        assert_eq!(
477            mi.item,
478            Attribute {
479                name: Span {
480                    data: "foo",
481                    line: 1,
482                    col: 2,
483                    offset: 1,
484                },
485                value_source: None,
486                value: InterpretedValue::Set,
487                source: Span {
488                    data: ":foo:",
489                    line: 1,
490                    col: 1,
491                    offset: 0,
492                }
493            }
494        );
495
496        assert_eq!(mi.item.value(), InterpretedValue::Set);
497
498        assert_eq!(
499            mi.after,
500            Span {
501                data: "blah",
502                line: 2,
503                col: 1,
504                offset: 6
505            }
506        );
507    }
508
509    #[test]
510    fn name_with_hyphens() {
511        let mi = crate::document::Attribute::parse(
512            crate::Span::new(":name-with-hyphen:"),
513            &Parser::default(),
514        )
515        .unwrap();
516
517        assert_eq!(
518            mi.item,
519            Attribute {
520                name: Span {
521                    data: "name-with-hyphen",
522                    line: 1,
523                    col: 2,
524                    offset: 1,
525                },
526                value_source: None,
527                value: InterpretedValue::Set,
528                source: Span {
529                    data: ":name-with-hyphen:",
530                    line: 1,
531                    col: 1,
532                    offset: 0,
533                }
534            }
535        );
536
537        assert_eq!(mi.item.value(), InterpretedValue::Set);
538
539        assert_eq!(
540            mi.after,
541            Span {
542                data: "",
543                line: 1,
544                col: 19,
545                offset: 18
546            }
547        );
548    }
549
550    #[test]
551    fn unset_prefix() {
552        let mi =
553            crate::document::Attribute::parse(crate::Span::new(":!foo:\nblah"), &Parser::default())
554                .unwrap();
555
556        assert_eq!(
557            mi.item,
558            Attribute {
559                name: Span {
560                    data: "foo",
561                    line: 1,
562                    col: 3,
563                    offset: 2,
564                },
565                value_source: None,
566                value: InterpretedValue::Unset,
567                source: Span {
568                    data: ":!foo:",
569                    line: 1,
570                    col: 1,
571                    offset: 0,
572                }
573            }
574        );
575
576        assert_eq!(mi.item.value(), InterpretedValue::Unset);
577
578        assert_eq!(
579            mi.after,
580            Span {
581                data: "blah",
582                line: 2,
583                col: 1,
584                offset: 7
585            }
586        );
587    }
588
589    #[test]
590    fn unset_postfix() {
591        let mi =
592            crate::document::Attribute::parse(crate::Span::new(":foo!:\nblah"), &Parser::default())
593                .unwrap();
594
595        assert_eq!(
596            mi.item,
597            Attribute {
598                name: Span {
599                    data: "foo",
600                    line: 1,
601                    col: 2,
602                    offset: 1,
603                },
604                value_source: None,
605                value: InterpretedValue::Unset,
606                source: Span {
607                    data: ":foo!:",
608                    line: 1,
609                    col: 1,
610                    offset: 0,
611                }
612            }
613        );
614
615        assert_eq!(mi.item.value(), InterpretedValue::Unset);
616
617        assert_eq!(
618            mi.after,
619            Span {
620                data: "blah",
621                line: 2,
622                col: 1,
623                offset: 7
624            }
625        );
626    }
627
628    #[test]
629    fn err_unset_prefix_and_postfix() {
630        assert!(
631            crate::document::Attribute::parse(
632                crate::Span::new(":!foo!:\nblah"),
633                &Parser::default()
634            )
635            .is_none()
636        );
637    }
638
639    #[test]
640    fn err_invalid_ident1() {
641        assert!(
642            crate::document::Attribute::parse(
643                crate::Span::new(":@invalid:\nblah"),
644                &Parser::default()
645            )
646            .is_none()
647        );
648    }
649
650    #[test]
651    fn err_missing_closing_colon() {
652        // A valid attribute name that is not followed by a closing colon is not
653        // an attribute entry.
654        assert!(
655            crate::document::Attribute::parse(
656                crate::Span::new(":foo bar\nblah"),
657                &Parser::default()
658            )
659            .is_none()
660        );
661
662        // ... including at end of input.
663        assert!(
664            crate::document::Attribute::parse(crate::Span::new(":foo"), &Parser::default())
665                .is_none()
666        );
667    }
668
669    #[test]
670    fn name_captures_trailing_non_word_char() {
671        // A name may contain (and end with) characters that are not valid in a
672        // canonical attribute name; the raw name runs up to the closing colon.
673        // The stray characters are dropped later, when the name is sanitized
674        // into an attribute key (`invalid@` becomes `invalid`).
675        let mi = crate::document::Attribute::parse(
676            crate::Span::new(":invalid@:\nblah"),
677            &Parser::default(),
678        )
679        .unwrap();
680
681        assert_eq!(
682            mi.item,
683            Attribute {
684                name: Span {
685                    data: "invalid@",
686                    line: 1,
687                    col: 2,
688                    offset: 1,
689                },
690                value_source: None,
691                value: InterpretedValue::Set,
692                source: Span {
693                    data: ":invalid@:",
694                    line: 1,
695                    col: 1,
696                    offset: 0,
697                }
698            }
699        );
700
701        assert_eq!(
702            mi.after,
703            Span {
704                data: "blah",
705                line: 2,
706                col: 1,
707                offset: 11
708            }
709        );
710    }
711
712    #[test]
713    fn name_with_spaces() {
714        // A name containing spaces is captured verbatim up to the closing colon.
715        // It is sanitized to `authorinitials` before it is stored as an
716        // attribute (see the parser's `remap_attr_name`); here we only check
717        // that the entry parses and preserves the raw name span.
718        let mi = crate::document::Attribute::parse(
719            crate::Span::new(":Author Initials: SJR"),
720            &Parser::default(),
721        )
722        .unwrap();
723
724        assert_eq!(
725            mi.item,
726            Attribute {
727                name: Span {
728                    data: "Author Initials",
729                    line: 1,
730                    col: 2,
731                    offset: 1,
732                },
733                value_source: Some(Span {
734                    data: "SJR",
735                    line: 1,
736                    col: 19,
737                    offset: 18,
738                }),
739                value: InterpretedValue::Value("SJR"),
740                source: Span {
741                    data: ":Author Initials: SJR",
742                    line: 1,
743                    col: 1,
744                    offset: 0,
745                }
746            }
747        );
748    }
749
750    #[test]
751    fn err_invalid_ident3() {
752        assert!(
753            crate::document::Attribute::parse(
754                crate::Span::new(":-invalid:\nblah"),
755                &Parser::default()
756            )
757            .is_none()
758        );
759    }
760
761    #[test]
762    fn value_with_soft_wrap() {
763        let mi = crate::document::Attribute::parse(
764            crate::Span::new(":foo: bar \\\n blah"),
765            &Parser::default(),
766        )
767        .unwrap();
768
769        assert_eq!(
770            mi.item,
771            Attribute {
772                name: Span {
773                    data: "foo",
774                    line: 1,
775                    col: 2,
776                    offset: 1,
777                },
778                value_source: Some(Span {
779                    data: "bar \\\n blah",
780                    line: 1,
781                    col: 7,
782                    offset: 6,
783                }),
784                value: InterpretedValue::Value("bar blah"),
785                source: Span {
786                    data: ":foo: bar \\\n blah",
787                    line: 1,
788                    col: 1,
789                    offset: 0,
790                }
791            }
792        );
793
794        assert_eq!(mi.item.value(), InterpretedValue::Value("bar blah"));
795
796        assert_eq!(
797            mi.after,
798            Span {
799                data: "",
800                line: 2,
801                col: 6,
802                offset: 17
803            }
804        );
805    }
806
807    #[test]
808    fn bare_trailing_backslash_is_literal() {
809        // A bare trailing backslash (no preceding space) is not a soft-wrap line
810        // continuation; it is a literal character and the value ends at that line.
811        // See https://github.com/asciidoc-rs/asciidoc-parser/issues/666.
812        let mi = crate::document::Attribute::parse(
813            crate::Span::new(":longpath: very/long/path/to/some/\\\nsubdirectory"),
814            &Parser::default(),
815        )
816        .unwrap();
817
818        assert_eq!(
819            mi.item,
820            Attribute {
821                name: Span {
822                    data: "longpath",
823                    line: 1,
824                    col: 2,
825                    offset: 1,
826                },
827                value_source: Some(Span {
828                    data: "very/long/path/to/some/\\",
829                    line: 1,
830                    col: 12,
831                    offset: 11,
832                }),
833                value: InterpretedValue::Value("very/long/path/to/some/\\"),
834                source: Span {
835                    data: ":longpath: very/long/path/to/some/\\",
836                    line: 1,
837                    col: 1,
838                    offset: 0,
839                }
840            }
841        );
842
843        assert_eq!(
844            mi.item.value(),
845            InterpretedValue::Value("very/long/path/to/some/\\")
846        );
847
848        // `subdirectory` is left as a separate line, not folded into the value.
849        assert_eq!(
850            mi.after,
851            Span {
852                data: "subdirectory",
853                line: 2,
854                col: 1,
855                offset: 36
856            }
857        );
858    }
859
860    #[test]
861    fn literal_trailing_backslash_on_final_line() {
862        // The soft-wrap continuation on the first line is folded, but the bare
863        // trailing backslash on the final line is kept as a literal character.
864        let mi = crate::document::Attribute::parse(
865            crate::Span::new(":foo: bar \\\nbaz\\"),
866            &Parser::default(),
867        )
868        .unwrap();
869
870        assert_eq!(mi.item.value(), InterpretedValue::Value("bar baz\\"));
871    }
872
873    #[test]
874    fn value_with_hard_wrap() {
875        let mi = crate::document::Attribute::parse(
876            crate::Span::new(":foo: bar + \\\n blah"),
877            &Parser::default(),
878        )
879        .unwrap();
880
881        assert_eq!(
882            mi.item,
883            Attribute {
884                name: Span {
885                    data: "foo",
886                    line: 1,
887                    col: 2,
888                    offset: 1,
889                },
890                value_source: Some(Span {
891                    data: "bar + \\\n blah",
892                    line: 1,
893                    col: 7,
894                    offset: 6,
895                }),
896                value: InterpretedValue::Value("bar +\nblah"),
897                source: Span {
898                    data: ":foo: bar + \\\n blah",
899                    line: 1,
900                    col: 1,
901                    offset: 0,
902                }
903            }
904        );
905
906        assert_eq!(mi.item.value(), InterpretedValue::Value("bar +\nblah"));
907
908        assert_eq!(
909            mi.after,
910            Span {
911                data: "",
912                line: 2,
913                col: 6,
914                offset: 19
915            }
916        );
917    }
918
919    #[test]
920    fn single_line_trailing_hard_break_marker_is_stripped() {
921        // A single-line value ending in a legacy continuation marker (a space
922        // followed by a single `+`) with no following non-blank line has that
923        // marker stripped from the interpreted value, matching Asciidoctor. The
924        // raw `value_source` still contains the literal ` +`. Contrast with
925        // `legacy_multi_line_value_is_fused`, where a following line _is_ folded
926        // in.
927        let mi =
928            crate::document::Attribute::parse(crate::Span::new(":foo: bar +"), &Parser::default())
929                .unwrap();
930
931        assert_eq!(
932            mi.item,
933            Attribute {
934                name: Span {
935                    data: "foo",
936                    line: 1,
937                    col: 2,
938                    offset: 1,
939                },
940                value_source: Some(Span {
941                    data: "bar +",
942                    line: 1,
943                    col: 7,
944                    offset: 6,
945                }),
946                value: InterpretedValue::Value("bar"),
947                source: Span {
948                    data: ":foo: bar +",
949                    line: 1,
950                    col: 1,
951                    offset: 0,
952                }
953            }
954        );
955
956        assert_eq!(mi.item.value(), InterpretedValue::Value("bar"));
957
958        assert_eq!(
959            mi.after,
960            Span {
961                data: "",
962                line: 1,
963                col: 12,
964                offset: 11
965            }
966        );
967    }
968
969    #[test]
970    fn legacy_multi_line_value_is_fused() {
971        // A legacy `+`-continued value fuses the following line: the trailing
972        // ` +` marker is stripped and the lines are folded with a space (the
973        // value does not end in a hard line break marker after the strip). The
974        // following line is consumed, so `after` is empty. See #729.
975        let mi = crate::document::Attribute::parse(
976            crate::Span::new(":foo: bar +\nblah"),
977            &Parser::default(),
978        )
979        .unwrap();
980
981        assert_eq!(
982            mi.item,
983            Attribute {
984                name: Span {
985                    data: "foo",
986                    line: 1,
987                    col: 2,
988                    offset: 1,
989                },
990                value_source: Some(Span {
991                    data: "bar +\nblah",
992                    line: 1,
993                    col: 7,
994                    offset: 6,
995                }),
996                value: InterpretedValue::Value("bar blah"),
997                source: Span {
998                    data: ":foo: bar +\nblah",
999                    line: 1,
1000                    col: 1,
1001                    offset: 0,
1002                }
1003            }
1004        );
1005
1006        assert_eq!(mi.item.value(), InterpretedValue::Value("bar blah"));
1007
1008        assert_eq!(
1009            mi.after,
1010            Span {
1011                data: "",
1012                line: 2,
1013                col: 5,
1014                offset: 16
1015            }
1016        );
1017    }
1018
1019    #[test]
1020    fn legacy_hard_break_marker_edge_cases() {
1021        // The legacy continuation marker is exactly a space followed by a single
1022        // `+`. When a following non-blank line is present, the marker fuses the
1023        // lines (stripping the marker and right-trimming). Contrast with markers
1024        // that are _not_ legacy continuations (`++`, a bare `+`, a tab before the
1025        // `+`, or a lone `+`), which leave the value on a single line.
1026        let value = |src| {
1027            crate::document::Attribute::parse(crate::Span::new(src), &Parser::default())
1028                .unwrap()
1029                .item
1030                .value()
1031                .clone()
1032        };
1033
1034        // Extra space(s) before the `+` are trimmed away with the marker, and the
1035        // following line is folded in with a single space.
1036        assert_eq!(value(":foo: bar  +\nx"), InterpretedValue::Value("bar x"));
1037
1038        // A tab preceding the marker's space is also trimmed.
1039        assert_eq!(value(":foo: bar\t +\nx"), InterpretedValue::Value("bar x"));
1040
1041        // `++` is not a continuation marker; the value stays on one line verbatim.
1042        assert_eq!(value(":foo: bar ++\nx"), InterpretedValue::Value("bar ++"));
1043
1044        // A `+` with no preceding space is a literal character (no continuation).
1045        assert_eq!(value(":foo: bar+\nx"), InterpretedValue::Value("bar+"));
1046
1047        // A tab (rather than a space) before the `+` does not form a marker.
1048        assert_eq!(value(":foo: bar\t+\nx"), InterpretedValue::Value("bar\t+"));
1049
1050        // A lone `+` (nothing before it) is not a marker; it is preserved.
1051        assert_eq!(value(":foo: +\nx"), InterpretedValue::Value("+"));
1052
1053        // An earlier ` +` in the fused first line ends in a hard line break
1054        // marker, so the following line is joined with a newline rather than a
1055        // space.
1056        assert_eq!(
1057            value(":foo: bar + +\nx"),
1058            InterpretedValue::Value("bar +\nx")
1059        );
1060
1061        // Right-trimming after the marker uses ASCII whitespace rules (Ruby
1062        // `rstrip`); a preceding non-breaking space is significant and preserved.
1063        assert_eq!(
1064            value(":foo: bar\u{00a0} +\nx"),
1065            InterpretedValue::Value("bar\u{00a0} x")
1066        );
1067    }
1068
1069    #[test]
1070    fn bare_marker_only_continuation_line_does_not_swallow_next_line() {
1071        // A continuation line that is only a bare marker (` +`) terminates the
1072        // value. The line after it must remain in the stream, not be consumed by
1073        // the extent scanner and then dropped by the folder. The extent scanner
1074        // and the folder must agree on where the value ends.
1075        let mi = crate::document::Attribute::parse(
1076            crate::Span::new(":foo: text +\n +\nmore"),
1077            &Parser::default(),
1078        )
1079        .unwrap();
1080
1081        assert_eq!(
1082            mi.item,
1083            Attribute {
1084                name: Span {
1085                    data: "foo",
1086                    line: 1,
1087                    col: 2,
1088                    offset: 1,
1089                },
1090                value_source: Some(Span {
1091                    data: "text +\n +",
1092                    line: 1,
1093                    col: 7,
1094                    offset: 6,
1095                }),
1096                value: InterpretedValue::Value("text +"),
1097                source: Span {
1098                    data: ":foo: text +\n +",
1099                    line: 1,
1100                    col: 1,
1101                    offset: 0,
1102                }
1103            }
1104        );
1105
1106        // `more` is preserved for the following block, not swallowed.
1107        assert_eq!(
1108            mi.after,
1109            Span {
1110                data: "more",
1111                line: 3,
1112                col: 1,
1113                offset: 16,
1114            }
1115        );
1116    }
1117
1118    #[test]
1119    fn is_block() {
1120        let mut parser = Parser::default();
1121        let maw = crate::blocks::Block::parse(crate::Span::new(":foo: bar\nblah"), &mut parser);
1122
1123        let mi = maw.item.unwrap();
1124        let block = mi.item;
1125
1126        assert_eq!(
1127            block,
1128            Block::DocumentAttribute(Attribute {
1129                name: Span {
1130                    data: "foo",
1131                    line: 1,
1132                    col: 2,
1133                    offset: 1,
1134                },
1135                value_source: Some(Span {
1136                    data: "bar",
1137                    line: 1,
1138                    col: 7,
1139                    offset: 6,
1140                }),
1141                value: InterpretedValue::Value("bar"),
1142                source: Span {
1143                    data: ":foo: bar",
1144                    line: 1,
1145                    col: 1,
1146                    offset: 0,
1147                }
1148            })
1149        );
1150
1151        assert_eq!(block.content_model(), ContentModel::Empty);
1152        assert!(block.rendered_content().is_none());
1153        assert_eq!(block.raw_context().deref(), "attribute");
1154        assert!(block.child_blocks().next().is_none());
1155        assert!(block.title_source().is_none());
1156        assert!(block.title().is_none());
1157        assert!(block.anchor().is_none());
1158        assert!(block.anchor_reftext().is_none());
1159        assert!(block.attrlist().is_none());
1160        assert_eq!(block.substitution_group(), SubstitutionGroup::Normal);
1161
1162        assert_eq!(
1163            block.span(),
1164            Span {
1165                data: ":foo: bar",
1166                line: 1,
1167                col: 1,
1168                offset: 0,
1169            }
1170        );
1171
1172        let crate::blocks::Block::DocumentAttribute(attr) = block else {
1173            panic!("Wrong type");
1174        };
1175
1176        assert_eq!(attr.value(), InterpretedValue::Value("bar"));
1177
1178        assert_eq!(
1179            mi.after,
1180            Span {
1181                data: "blah",
1182                line: 2,
1183                col: 1,
1184                offset: 10
1185            }
1186        );
1187    }
1188
1189    #[test]
1190    fn affects_document_state() {
1191        let mut parser = Parser::default().with_intrinsic_attribute(
1192            "agreed",
1193            "yes",
1194            ModificationContext::Anywhere,
1195        );
1196
1197        let doc =
1198            parser.parse("We are agreed? {agreed}\n\n:agreed: no\n\nAre we still agreed? {agreed}");
1199
1200        let mut blocks = doc.child_blocks();
1201
1202        let block1 = blocks.next().unwrap();
1203
1204        assert_eq!(
1205            block1,
1206            &Block::Simple(SimpleBlock {
1207                content: Content {
1208                    original: Span {
1209                        data: "We are agreed? {agreed}",
1210                        line: 1,
1211                        col: 1,
1212                        offset: 0,
1213                    },
1214                    rendered: "We are agreed? yes",
1215                },
1216                source: Span {
1217                    data: "We are agreed? {agreed}",
1218                    line: 1,
1219                    col: 1,
1220                    offset: 0,
1221                },
1222                style: SimpleBlockStyle::Paragraph,
1223                title_source: None,
1224                title: None,
1225                caption: None,
1226                number: None,
1227                anchor: None,
1228                anchor_reftext: None,
1229                attrlist: None,
1230            })
1231        );
1232
1233        let _ = blocks.next().unwrap();
1234
1235        let block3 = blocks.next().unwrap();
1236
1237        assert_eq!(
1238            block3,
1239            &Block::Simple(SimpleBlock {
1240                content: Content {
1241                    original: Span {
1242                        data: "Are we still agreed? {agreed}",
1243                        line: 5,
1244                        col: 1,
1245                        offset: 38,
1246                    },
1247                    rendered: "Are we still agreed? no",
1248                },
1249                source: Span {
1250                    data: "Are we still agreed? {agreed}",
1251                    line: 5,
1252                    col: 1,
1253                    offset: 38,
1254                },
1255                style: SimpleBlockStyle::Paragraph,
1256                title_source: None,
1257                title: None,
1258                caption: None,
1259                number: None,
1260                anchor: None,
1261                anchor_reftext: None,
1262                attrlist: None,
1263            })
1264        );
1265
1266        let mut warnings = doc.warnings();
1267        assert!(warnings.next().is_none());
1268    }
1269
1270    #[test]
1271    fn block_enforces_permission() {
1272        let mut parser = Parser::default().with_intrinsic_attribute(
1273            "agreed",
1274            "yes",
1275            ModificationContext::ApiOnly,
1276        );
1277
1278        let doc = parser.parse("Hello\n\n:agreed: no\n\nAre we agreed? {agreed}");
1279
1280        let mut blocks = doc.child_blocks();
1281        let _ = blocks.next().unwrap();
1282        let _ = blocks.next().unwrap();
1283        let block3 = blocks.next().unwrap();
1284
1285        assert_eq!(
1286            block3,
1287            &Block::Simple(SimpleBlock {
1288                content: Content {
1289                    original: Span {
1290                        data: "Are we agreed? {agreed}",
1291                        line: 5,
1292                        col: 1,
1293                        offset: 20,
1294                    },
1295                    rendered: "Are we agreed? yes",
1296                },
1297                source: Span {
1298                    data: "Are we agreed? {agreed}",
1299                    line: 5,
1300                    col: 1,
1301                    offset: 20,
1302                },
1303                style: SimpleBlockStyle::Paragraph,
1304                title_source: None,
1305                title: None,
1306                caption: None,
1307                number: None,
1308                anchor: None,
1309                anchor_reftext: None,
1310                attrlist: None,
1311            })
1312        );
1313
1314        let mut warnings = doc.warnings();
1315        let warning1 = warnings.next().unwrap();
1316
1317        assert_eq!(
1318            &warning1.source,
1319            Span {
1320                data: ":agreed: no",
1321                line: 3,
1322                col: 1,
1323                offset: 7,
1324            }
1325        );
1326
1327        assert_eq!(
1328            warning1.warning,
1329            WarningType::AttributeValueIsLocked("agreed".to_owned(),)
1330        );
1331
1332        assert!(warnings.next().is_none());
1333    }
1334
1335    mod fold_continuation_value {
1336        use super::super::fold_continuation_value;
1337
1338        #[test]
1339        fn no_marker_returns_none() {
1340            // A plain single-line value has no continuation marker and needs no
1341            // folding.
1342            assert_eq!(fold_continuation_value("bar"), None);
1343            assert_eq!(fold_continuation_value("bar+"), None);
1344            assert_eq!(fold_continuation_value("bar ++"), None);
1345        }
1346
1347        #[test]
1348        fn modern_soft_wrap_folds_with_space() {
1349            assert_eq!(
1350                fold_continuation_value("bar \\\nblah"),
1351                Some("bar blah".to_string())
1352            );
1353        }
1354
1355        #[test]
1356        fn legacy_marker_folds_with_space() {
1357            assert_eq!(
1358                fold_continuation_value("This is the first +\nRuby implementation of +\nAsciiDoc."),
1359                Some("This is the first Ruby implementation of AsciiDoc.".to_string())
1360            );
1361        }
1362
1363        #[test]
1364        fn hard_line_break_joins_with_newline() {
1365            // A soft-wrap value whose text ends in a hard line break marker
1366            // (` +`) is joined with a newline rather than a space.
1367            assert_eq!(
1368                fold_continuation_value("bar + \\\nblah"),
1369                Some("bar +\nblah".to_string())
1370            );
1371        }
1372
1373        #[test]
1374        fn blank_line_terminates_value() {
1375            // A blank line inside the (already-extent-trimmed) data terminates
1376            // the fold without consuming further lines.
1377            assert_eq!(fold_continuation_value("a +\n\nb"), Some("a".to_string()));
1378        }
1379
1380        #[test]
1381        fn crlf_line_endings_are_normalized() {
1382            assert_eq!(
1383                fold_continuation_value("bar +\r\nblah"),
1384                Some("bar blah".to_string())
1385            );
1386        }
1387
1388        #[test]
1389        fn bare_marker_only_line_terminates_value() {
1390            // A continuation line that is *only* the marker (a bare ` +` after
1391            // left-trimming) does not keep the value open. It terminates the fold
1392            // and its literal `+` is appended, matching Asciidoctor. The folder
1393            // and `Span::take_value_with_continuation` must agree on this so the
1394            // line after the bare marker is not consumed-then-dropped.
1395            assert_eq!(
1396                fold_continuation_value("text +\n +\nmore"),
1397                Some("text +".to_string())
1398            );
1399        }
1400
1401        #[test]
1402        fn trailing_whitespace_after_marker_still_continues() {
1403            // Trailing whitespace after the marker is tolerated (right-trimmed)
1404            // consistently with the extent scanner, so the following line is still
1405            // folded in rather than dropped.
1406            assert_eq!(
1407                fold_continuation_value("a +\nb + \nmore"),
1408                Some("a b more".to_string())
1409            );
1410        }
1411    }
1412
1413    mod interpreted_value {
1414        mod impl_debug {
1415            use crate::document::InterpretedValue;
1416
1417            #[test]
1418            fn value_empty_string() {
1419                let interpreted_value = InterpretedValue::Value("".to_string());
1420                let debug_output = format!("{:?}", interpreted_value);
1421                assert_eq!(debug_output, "InterpretedValue::Value(\"\")");
1422            }
1423
1424            #[test]
1425            fn value_simple_string() {
1426                let interpreted_value = InterpretedValue::Value("hello".to_string());
1427                let debug_output = format!("{:?}", interpreted_value);
1428                assert_eq!(debug_output, "InterpretedValue::Value(\"hello\")");
1429            }
1430
1431            #[test]
1432            fn value_string_with_spaces() {
1433                let interpreted_value = InterpretedValue::Value("hello world".to_string());
1434                let debug_output = format!("{:?}", interpreted_value);
1435                assert_eq!(debug_output, "InterpretedValue::Value(\"hello world\")");
1436            }
1437
1438            #[test]
1439            fn value_string_with_special_chars() {
1440                let interpreted_value = InterpretedValue::Value("test!@#$%^&*()".to_string());
1441                let debug_output = format!("{:?}", interpreted_value);
1442                assert_eq!(debug_output, "InterpretedValue::Value(\"test!@#$%^&*()\")");
1443            }
1444
1445            #[test]
1446            fn value_string_with_quotes() {
1447                let interpreted_value = InterpretedValue::Value("value\"with'quotes".to_string());
1448                let debug_output = format!("{:?}", interpreted_value);
1449                assert_eq!(
1450                    debug_output,
1451                    "InterpretedValue::Value(\"value\\\"with'quotes\")"
1452                );
1453            }
1454
1455            #[test]
1456            fn value_string_with_newlines() {
1457                let interpreted_value = InterpretedValue::Value("line1\nline2\nline3".to_string());
1458                let debug_output = format!("{:?}", interpreted_value);
1459                assert_eq!(
1460                    debug_output,
1461                    "InterpretedValue::Value(\"line1\\nline2\\nline3\")"
1462                );
1463            }
1464
1465            #[test]
1466            fn value_string_with_backslashes() {
1467                let interpreted_value = InterpretedValue::Value("path\\to\\file".to_string());
1468                let debug_output = format!("{:?}", interpreted_value);
1469                assert_eq!(
1470                    debug_output,
1471                    "InterpretedValue::Value(\"path\\\\to\\\\file\")"
1472                );
1473            }
1474
1475            #[test]
1476            fn value_string_with_unicode() {
1477                let interpreted_value = InterpretedValue::Value("café 🚀 ñoño".to_string());
1478                let debug_output = format!("{:?}", interpreted_value);
1479                assert_eq!(debug_output, "InterpretedValue::Value(\"café 🚀 ñoño\")");
1480            }
1481
1482            #[test]
1483            fn set() {
1484                let interpreted_value = InterpretedValue::Set;
1485                let debug_output = format!("{:?}", interpreted_value);
1486                assert_eq!(debug_output, "InterpretedValue::Set");
1487            }
1488
1489            #[test]
1490            fn unset() {
1491                let interpreted_value = InterpretedValue::Unset;
1492                let debug_output = format!("{:?}", interpreted_value);
1493                assert_eq!(debug_output, "InterpretedValue::Unset");
1494            }
1495        }
1496    }
1497}