1use crate::{Diagnostic, DiagnosticKind, YamlError};
2
3pub const TARGET_YAML_VERSION: &str = "1.2.2";
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
8pub struct NodeId(pub u32);
9
10impl NodeId {
11 #[must_use]
17 pub fn from_usize(index: usize) -> Self {
18 Self(u32::try_from(index).expect("node arena is too large for u32-based node IDs"))
19 }
20
21 #[must_use]
23 pub const fn as_usize(self) -> usize {
24 self.0 as usize
25 }
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
30pub struct Span {
31 pub start: u32,
33 pub end: u32,
35}
36
37impl Span {
38 #[must_use]
40 pub const fn new(start: u32, end: u32) -> Self {
41 Self { start, end }
42 }
43
44 #[must_use]
50 pub fn from_usize(start: usize, end: usize) -> Self {
51 Self::try_from((start, end)).expect("YAML source is too large for u32-based spans")
52 }
53
54 #[must_use]
56 pub const fn empty(offset: u32) -> Self {
57 Self {
58 start: offset,
59 end: offset,
60 }
61 }
62
63 pub(crate) fn usize_to_u32(offset: usize) -> u32 {
64 u32::try_from(offset).expect("YAML source is too large for u32-based spans")
65 }
66
67 pub(crate) fn offset_from_usize(base: u32, offset: usize) -> u32 {
68 base.checked_add(Self::usize_to_u32(offset))
69 .expect("YAML source is too large for u32-based spans")
70 }
71
72 #[must_use]
74 pub fn empty_from_usize(offset: usize) -> Self {
75 Self::empty(Self::usize_to_u32(offset))
76 }
77
78 #[must_use]
80 pub const fn len(self) -> u32 {
81 self.end.saturating_sub(self.start)
82 }
83
84 #[must_use]
86 pub const fn is_empty(self) -> bool {
87 self.start == self.end
88 }
89
90 #[must_use]
92 pub const fn contains(self, offset: u32) -> bool {
93 self.start <= offset && offset < self.end
94 }
95}
96
97impl TryFrom<(usize, usize)> for Span {
98 type Error = std::num::TryFromIntError;
99
100 fn try_from((start, end): (usize, usize)) -> Result<Self, Self::Error> {
101 Ok(Self {
102 start: Self::usize_to_u32(start),
103 end: Self::usize_to_u32(end),
104 })
105 }
106}
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub struct LineCol {
110 pub line: usize,
112 pub column: usize,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct Source {
119 text: String,
120 line_starts: Vec<u32>,
121 line_facts: Vec<LineFacts>,
122}
123
124const NO_LINE_OFFSET: u16 = u16::MAX;
125const NO_LINE_INDEX: u32 = u32::MAX;
126const LINE_BLANK: u16 = 1 << 0;
127const LINE_SIMPLE_MAPPING: u16 = 1 << 1;
128const LINE_OFFSET_OVERFLOW: u16 = 1 << 2;
129const LINE_COMMENT: u16 = 1 << 3;
130const LINE_SCALAR_PLAIN: u16 = 1 << 4;
131const LINE_SCALAR_SINGLE_QUOTED: u16 = 1 << 5;
132const LINE_SCALAR_DOUBLE_QUOTED: u16 = 1 << 6;
133const LINE_FACTS_MIN_SOURCE_BYTES: usize = 1024;
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub(crate) struct LineFacts {
142 indent: u16,
143 mapping_colon: u16,
144 value_start: u16,
145 scalar_end: u16,
146 flags: u16,
147 next_significant: u32,
148}
149
150impl LineFacts {
151 const FALLBACK: Self = Self {
152 indent: NO_LINE_OFFSET,
153 mapping_colon: NO_LINE_OFFSET,
154 value_start: NO_LINE_OFFSET,
155 scalar_end: NO_LINE_OFFSET,
156 flags: LINE_OFFSET_OVERFLOW,
157 next_significant: NO_LINE_INDEX,
158 };
159
160 pub(crate) fn indent(self) -> Option<usize> {
161 (!self.has(LINE_OFFSET_OVERFLOW)).then_some(self.indent as usize)
162 }
163
164 pub(crate) fn simple_mapping(self) -> Option<(usize, usize)> {
165 self.has(LINE_SIMPLE_MAPPING)
166 .then_some((self.mapping_colon as usize, self.value_start as usize))
167 }
168
169 pub(crate) fn mapping_colon(self) -> Option<usize> {
170 (self.mapping_colon != NO_LINE_OFFSET).then_some(self.mapping_colon as usize)
171 }
172
173 pub(crate) fn scalar_mapping(self) -> Option<(usize, usize, usize, CachedScalarStyle)> {
174 let style = if self.has(LINE_SCALAR_PLAIN) {
175 CachedScalarStyle::Plain
176 } else if self.has(LINE_SCALAR_SINGLE_QUOTED) {
177 CachedScalarStyle::SingleQuoted
178 } else if self.has(LINE_SCALAR_DOUBLE_QUOTED) {
179 CachedScalarStyle::DoubleQuoted
180 } else {
181 return None;
182 };
183 Some((
184 self.mapping_colon as usize,
185 self.value_start as usize,
186 self.scalar_end as usize,
187 style,
188 ))
189 }
190
191 pub(crate) const fn is_blank(self) -> bool {
192 self.has(LINE_BLANK)
193 }
194
195 fn next_significant(self) -> Option<usize> {
196 (self.next_significant != NO_LINE_INDEX).then_some(self.next_significant as usize)
197 }
198
199 const fn has(self, flag: u16) -> bool {
200 self.flags & flag != 0
201 }
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub(crate) enum CachedScalarStyle {
206 Plain,
207 SingleQuoted,
208 DoubleQuoted,
209}
210
211impl Source {
212 pub fn new(text: String) -> Result<Self, YamlError> {
220 let bytes = text.as_bytes();
221 let mut line_starts = Vec::with_capacity(text.len() / 32 + 1);
222 line_starts.push(0);
223 const SOURCE_SCAN_CHUNK: usize = 32;
224 let (chunks, remainder) = bytes.as_chunks::<SOURCE_SCAN_CHUNK>();
225 for (chunk_index, chunk) in chunks.iter().enumerate() {
226 validate_source_chunk(
227 &text,
228 bytes,
229 chunk_index * SOURCE_SCAN_CHUNK,
230 chunk,
231 &mut line_starts,
232 )?;
233 }
234 validate_source_chunk(
235 &text,
236 bytes,
237 chunks.len() * SOURCE_SCAN_CHUNK,
238 remainder,
239 &mut line_starts,
240 )?;
241 let line_facts = if text.len() >= LINE_FACTS_MIN_SOURCE_BYTES {
242 build_line_facts(&text, &line_starts)
243 } else {
244 Vec::new()
245 };
246
247 Ok(Self {
248 text,
249 line_starts,
250 line_facts,
251 })
252 }
253
254 #[must_use]
256 pub fn as_str(&self) -> &str {
257 &self.text
258 }
259
260 #[must_use]
262 pub fn len(&self) -> usize {
263 self.text.len()
264 }
265
266 #[must_use]
268 pub fn is_empty(&self) -> bool {
269 self.text.is_empty()
270 }
271
272 #[must_use]
274 pub fn line_starts(&self) -> &[u32] {
275 &self.line_starts
276 }
277
278 pub(crate) fn line_facts(&self, index: usize) -> LineFacts {
279 self.line_facts
280 .get(index)
281 .copied()
282 .unwrap_or(LineFacts::FALLBACK)
283 }
284
285 pub(crate) fn cached_next_significant_line(&self, index: usize) -> Option<Option<usize>> {
286 self.line_facts
287 .get(index)
288 .copied()
289 .map(LineFacts::next_significant)
290 }
291
292 pub(crate) fn has_line_facts(&self) -> bool {
293 !self.line_facts.is_empty()
294 }
295
296 pub(crate) fn validated_line_slice(&self, start: usize, end: usize) -> &str {
297 debug_assert!(start <= end);
298 debug_assert!(end <= self.text.len());
299 debug_assert!(self.text.is_char_boundary(start));
300 debug_assert!(self.text.is_char_boundary(end));
301 unsafe { self.text.get_unchecked(start..end) }
304 }
305
306 #[must_use]
313 pub fn slice(&self, span: Span) -> &str {
314 self.try_slice(span)
315 .expect("span must be in bounds and on UTF-8 boundaries")
316 }
317
318 pub fn try_slice(&self, span: Span) -> Result<&str, YamlError> {
325 let start = span.start as usize;
326 let end = span.end as usize;
327
328 if start > end || end > self.text.len() {
329 return Err(YamlError::new(Diagnostic::new(
330 DiagnosticKind::Source,
331 "span is outside the source text",
332 span,
333 )));
334 }
335
336 self.text.get(start..end).ok_or_else(|| {
337 YamlError::new(Diagnostic::new(
338 DiagnosticKind::Source,
339 "span does not align with UTF-8 character boundaries",
340 span,
341 ))
342 })
343 }
344
345 #[must_use]
347 pub fn line_col(&self, offset: usize) -> LineCol {
348 let offset = Span::usize_to_u32(offset.min(self.text.len()));
349 let line_index = match self.line_starts.binary_search(&offset) {
350 Ok(index) => index,
351 Err(index) => index.saturating_sub(1),
352 };
353 let line_start = self.line_starts[line_index];
354
355 LineCol {
356 line: line_index + 1,
357 column: (offset - line_start) as usize + 1,
358 }
359 }
360
361 #[must_use]
363 pub fn diagnostic_position(&self, diagnostic: &Diagnostic) -> LineCol {
364 self.line_col(diagnostic.span.start as usize)
365 }
366}
367
368fn validate_source_chunk(
369 text: &str,
370 bytes: &[u8],
371 base: usize,
372 chunk: &[u8],
373 line_starts: &mut Vec<u32>,
374) -> Result<(), YamlError> {
375 let mut cursor = 0;
376 while let Some(relative) = chunk[cursor..]
377 .iter()
378 .position(|byte| *byte < b' ' || matches!(*byte, 0x7F | 0xC2 | 0xEF))
379 {
380 let relative = cursor + relative;
381 let byte = chunk[relative];
382 let offset = base + relative;
383 if byte < 0x80 {
384 if !matches!(byte, b'\t' | b'\n' | b'\r' | b' '..=b'~') {
385 return Err(invalid_yaml_character(offset, char::from(byte)));
386 }
387 if byte == b'\n' {
388 line_starts.push(Span::usize_to_u32(offset + 1));
389 }
390 } else if unicode_sequence_may_be_non_printable(bytes, offset) {
391 let character = text[offset..]
392 .chars()
393 .next()
394 .expect("offset starts a valid UTF-8 character");
395 if !is_yaml_printable(character) {
396 return Err(invalid_yaml_character(offset, character));
397 }
398 }
399 cursor = relative + 1;
400 }
401 Ok(())
402}
403
404fn unicode_sequence_may_be_non_printable(bytes: &[u8], offset: usize) -> bool {
405 match bytes[offset..] {
406 [0xC2, continuation, ..] => (0x80..=0x9F).contains(&continuation) && continuation != 0x85,
407 [0xEF, 0xBF, 0xBE | 0xBF, ..] => true,
408 _ => false,
409 }
410}
411
412fn build_line_facts(text: &str, line_starts: &[u32]) -> Vec<LineFacts> {
413 let mut facts = Vec::with_capacity(line_starts.len());
414 for (index, &start) in line_starts.iter().enumerate() {
415 let start = start as usize;
416 let mut end = line_starts
417 .get(index + 1)
418 .map_or(text.len(), |next| *next as usize);
419 if end > start && text.as_bytes()[end - 1] == b'\n' {
420 end -= 1;
421 if end > start && text.as_bytes()[end - 1] == b'\r' {
422 end -= 1;
423 }
424 } else if end > start && text.as_bytes()[end - 1] == b'\r' {
425 end -= 1;
426 }
427 facts.push(analyze_line(&text.as_bytes()[start..end]));
428 }
429 populate_next_significant_lines(&mut facts);
430 facts
431}
432
433fn populate_next_significant_lines(facts: &mut [LineFacts]) {
434 let mut next = NO_LINE_INDEX;
435 for (index, fact) in facts.iter_mut().enumerate().rev() {
436 fact.next_significant = next;
437 if !fact.has(LINE_BLANK | LINE_COMMENT) {
438 next = u32::try_from(index).expect("line index exceeds u32 capacity");
439 }
440 }
441}
442
443fn analyze_line(line: &[u8]) -> LineFacts {
444 let indent = line.iter().take_while(|byte| **byte == b' ').count();
445 let mut flags = 0;
446 if line[indent..].is_empty() {
447 flags |= LINE_BLANK;
448 } else if line[indent] == b'#' {
449 flags |= LINE_COMMENT;
450 }
451
452 if line.len() >= NO_LINE_OFFSET as usize {
453 return LineFacts {
454 flags: flags | LINE_OFFSET_OVERFLOW,
455 ..LineFacts::FALLBACK
456 };
457 }
458
459 let Some(indent) = u16::try_from(indent)
460 .ok()
461 .filter(|value| *value != NO_LINE_OFFSET)
462 else {
463 return LineFacts {
464 indent: NO_LINE_OFFSET,
465 mapping_colon: NO_LINE_OFFSET,
466 value_start: NO_LINE_OFFSET,
467 scalar_end: NO_LINE_OFFSET,
468 flags: flags | LINE_OFFSET_OVERFLOW,
469 next_significant: NO_LINE_INDEX,
470 };
471 };
472
473 let body = &line[indent as usize..];
474 if let Some((colon, value_start)) = plain_key_mapping_offsets(body)
475 && let Ok(colon) = u16::try_from(colon)
476 && colon != NO_LINE_OFFSET
477 {
478 let scalar_value_start = value_start;
479 let value_start = scalar_value_start
480 .and_then(|offset| u16::try_from(offset).ok())
481 .filter(|offset| *offset != NO_LINE_OFFSET);
482 let mut scalar_end = NO_LINE_OFFSET;
483 if let Some(start) = scalar_value_start
484 && let Some((end, scalar_flag)) = cached_scalar_offsets(&body[start..])
485 && let Ok(end) = u16::try_from(start + end)
486 && end != NO_LINE_OFFSET
487 {
488 scalar_end = end;
489 flags |= scalar_flag;
490 if scalar_flag == LINE_SCALAR_PLAIN {
491 flags |= LINE_SIMPLE_MAPPING;
492 }
493 }
494 return LineFacts {
495 indent,
496 mapping_colon: colon,
497 value_start: value_start.unwrap_or(NO_LINE_OFFSET),
498 scalar_end,
499 flags,
500 next_significant: NO_LINE_INDEX,
501 };
502 }
503
504 LineFacts {
505 indent,
506 mapping_colon: NO_LINE_OFFSET,
507 value_start: NO_LINE_OFFSET,
508 scalar_end: NO_LINE_OFFSET,
509 flags,
510 next_significant: NO_LINE_INDEX,
511 }
512}
513
514fn plain_key_mapping_offsets(body: &[u8]) -> Option<(usize, Option<usize>)> {
515 let mut colon = 0;
516 while colon < body.len() && body[colon] != b':' {
517 if !is_simple_plain_byte(body[colon]) {
518 return None;
519 }
520 colon += 1;
521 }
522 if colon == 0 || colon == body.len() {
523 return None;
524 }
525
526 let mut value_start = colon + 1;
527 if value_start == body.len() {
528 return Some((colon, None));
529 }
530 if body.get(value_start) != Some(&b' ') {
531 return None;
532 }
533 while body.get(value_start) == Some(&b' ') {
534 value_start += 1;
535 }
536 if value_start == body.len() || &body[value_start..] == b"-" {
537 return Some((colon, None));
538 }
539 Some((colon, Some(value_start)))
540}
541
542fn cached_scalar_offsets(text: &[u8]) -> Option<(usize, u16)> {
543 match text.first().copied()? {
544 b'"' => {
545 let end = text[1..]
546 .iter()
547 .position(|byte| matches!(*byte, b'"' | b'\\'))?
548 + 1;
549 if text[end] == b'\\' || !valid_cached_quoted_trailing_text(&text[end + 1..]) {
550 return None;
551 }
552 Some((end + 1, LINE_SCALAR_DOUBLE_QUOTED))
553 }
554 b'\'' => {
555 let mut position = 1;
556 loop {
557 let quote = text[position..].iter().position(|byte| *byte == b'\'')? + position;
558 if text.get(quote + 1) == Some(&b'\'') {
559 position = quote + 2;
560 continue;
561 }
562 if !valid_cached_quoted_trailing_text(&text[quote + 1..]) {
563 return None;
564 }
565 return Some((quote + 1, LINE_SCALAR_SINGLE_QUOTED));
566 }
567 }
568 _ if text.iter().all(|byte| is_simple_plain_byte(*byte)) => {
569 Some((text.len(), LINE_SCALAR_PLAIN))
570 }
571 _ => None,
572 }
573}
574
575fn valid_cached_quoted_trailing_text(trailing: &[u8]) -> bool {
576 if trailing.iter().all(|byte| *byte == b' ') {
577 return true;
578 }
579 let whitespace = trailing.iter().take_while(|byte| **byte == b' ').count();
580 whitespace > 0 && trailing.get(whitespace) == Some(&b'#')
581}
582
583const fn is_simple_plain_byte(byte: u8) -> bool {
584 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'/')
585}
586
587fn invalid_yaml_character(offset: usize, character: char) -> YamlError {
588 let span = Span::from_usize(offset, offset + character.len_utf8());
589 YamlError::new(
590 Diagnostic::new(
591 DiagnosticKind::Source,
592 format!("invalid YAML 1.2.2 character U+{:04X}", character as u32),
593 span,
594 )
595 .with_note(
596 "YAML streams may contain tab, line feeds, carriage returns, printable Unicode characters, and non-breaking spaces",
597 ),
598 )
599}
600
601pub(crate) fn validate_yaml_chars(text: &str) -> Result<(), YamlError> {
602 for (offset, character) in text.char_indices() {
603 if !is_yaml_printable(character) {
604 let span = Span::from_usize(offset, offset + character.len_utf8());
605 return Err(YamlError::new(
606 Diagnostic::new(
607 DiagnosticKind::Source,
608 format!(
609 "invalid YAML 1.2.2 character U+{:04X}",
610 character as u32
611 ),
612 span,
613 )
614 .with_note(
615 "YAML streams may contain tab, line feeds, carriage returns, printable Unicode characters, and non-breaking spaces",
616 ),
617 ));
618 }
619 }
620
621 Ok(())
622}
623
624const fn is_yaml_printable(character: char) -> bool {
625 matches!(
626 character as u32,
627 0x09 | 0x0A | 0x0D | 0x20..=0x7E | 0x85 | 0xA0..=0xD7FF | 0xE000..=0xFFFD | 0x001_0000..=0x0010_FFFF
628 )
629}
630
631#[cfg(test)]
632mod line_facts_tests {
633 use std::fmt::Write;
634
635 use super::*;
636 use crate::parser::Parser;
637 use crate::semantic::SemanticStore;
638 use crate::{Node, ParsedYaml, YamlDoc, YamlEvent};
639
640 #[derive(Debug, PartialEq, Eq)]
641 struct ParseFingerprint {
642 nodes: Vec<Node>,
643 semantics: SemanticStore,
644 events: Vec<YamlEvent>,
645 rendering: String,
646 }
647
648 fn parse_fingerprint(source: Source, parsed: ParsedYaml) -> ParseFingerprint {
649 let document = YamlDoc {
650 source,
651 nodes: parsed.nodes.clone(),
652 semantics: parsed.semantics.clone(),
653 root_override: None,
654 edits: Vec::new(),
655 };
656 ParseFingerprint {
657 nodes: parsed.nodes,
658 semantics: parsed.semantics,
659 events: document.events().collect(),
660 rendering: document.to_string(),
661 }
662 }
663
664 #[test]
665 fn caches_common_lines_and_leaves_complex_lines_on_the_fallback_path() {
666 let source = Source::new(
667 "alpha: beta\r\nunicode: café\n\tbad: tab\n\"quoted\": value\n&anchor key: value\nflow: [one, two]\nkey: value # comment\n# comment\nliteral: |\n text\n"
668 .to_owned(),
669 )
670 .expect("fixture is printable YAML");
671
672 let facts = build_line_facts(source.as_str(), source.line_starts());
673 assert_eq!(facts[0].simple_mapping(), Some((5, 7)));
674 assert_eq!(facts[1].mapping_colon(), Some(7));
675 assert_eq!(facts[1].simple_mapping(), None);
676 assert_eq!(facts[2].mapping_colon(), None);
677 assert_eq!(facts[3].mapping_colon(), None);
678 assert_eq!(facts[4].mapping_colon(), None);
679 assert_eq!(facts[5].mapping_colon(), Some(4));
680 assert_eq!(facts[5].simple_mapping(), None);
681 assert_eq!(facts[6].mapping_colon(), Some(3));
682 assert_eq!(facts[6].simple_mapping(), None);
683 assert_eq!(facts[7].mapping_colon(), None);
684 assert_eq!(facts[8].mapping_colon(), Some(7));
685 assert_eq!(facts[9].indent(), Some(2));
686 assert_eq!(facts[5].next_significant(), Some(6));
687 assert_eq!(facts[6].next_significant(), Some(8));
688 assert_eq!(facts[9].next_significant(), None);
689 assert_eq!(std::mem::size_of::<LineFacts>(), 16);
690 }
691
692 #[test]
693 fn cached_scalar_facts_match_the_general_parser() {
694 let mut input = String::from("root:\n");
695 for index in 0..40 {
696 writeln!(input, " plain_{index}: value_{index}")
697 .expect("writing to a String cannot fail");
698 writeln!(input, " single_{index}: 'quoted # {index}' # trailing")
699 .expect("writing to a String cannot fail");
700 writeln!(
701 input,
702 " double_{index}: \"Unicode café {index}\" # trailing"
703 )
704 .expect("writing to a String cannot fail");
705 }
706
707 let optimized = Source::new(input.clone()).expect("fixture is printable YAML");
708 assert!(optimized.line_facts(2).scalar_mapping().is_some());
709 assert!(optimized.line_facts(3).scalar_mapping().is_some());
710 let optimized_parse = Parser::new(&optimized)
711 .parse()
712 .expect("optimized fixture should parse");
713
714 let mut general = Source::new(input).expect("fixture is printable YAML");
715 general.line_facts.clear();
716 let general_parse = Parser::new(&general)
717 .parse()
718 .expect("general fixture should parse");
719 assert_eq!(
720 parse_fingerprint(optimized, optimized_parse),
721 parse_fingerprint(general, general_parse)
722 );
723 }
724
725 #[test]
726 fn cached_line_path_matches_fallback_diagnostics() {
727 let input = format!(
728 "{}---\nquoted: \"a\nb\nc\"\n",
729 "# oracle padding\n".repeat(80)
730 );
731 let optimized = Source::new(input.clone()).expect("fixture is printable YAML");
732 let optimized_error = Parser::new(&optimized)
733 .parse()
734 .expect_err("unindented quoted continuation is invalid")
735 .with_position_from(&optimized);
736
737 let mut general = Source::new(input).expect("fixture is printable YAML");
738 general.line_facts.clear();
739 let general_error = Parser::new(&general)
740 .parse()
741 .expect_err("fallback path must reject the same input")
742 .with_position_from(&general);
743
744 assert_eq!(optimized_error.diagnostic, general_error.diagnostic);
745 }
746
747 #[test]
748 fn cached_common_mapping_path_preserves_the_complete_source() {
749 let mut input = String::new();
750 for index in 0..100 {
751 writeln!(input, "key_{index:04}: value_{index:04}")
752 .expect("writing to a String cannot fail");
753 }
754 let source = Source::new(input.clone()).expect("generated mapping is printable YAML");
755 assert_eq!(source.line_facts(0).simple_mapping(), Some((8, 10)));
756
757 let doc = YamlDoc::parse(&input).expect("cached mapping should parse");
758 assert_eq!(doc.to_string(), input);
759 }
760
761 #[test]
762 fn long_line_offsets_fall_back_without_changing_parse_behavior() {
763 let key = "k".repeat(u16::MAX as usize);
764 let input = format!("{key}: value\n");
765 let source = Source::new(input.clone()).expect("long fixture is printable YAML");
766 assert_eq!(source.line_facts(0).mapping_colon(), None);
767
768 let doc = YamlDoc::parse(&input).expect("long mapping key should use the full scanner");
769 assert_eq!(doc.to_string(), input);
770 }
771}