Skip to main content

asciidoc_parser/document/
attribute.rs

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