yaml-edit 0.3.5

A lossless parser and editor for YAML files
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
//! Scalar parsing: aliases, plain / quoted / block scalars, and the
//! tagged-collection wrappers (`!!set`, `!!omap`, `!!pairs`).
//!
//! Split out of `parser/mod.rs` unchanged.

use super::{BlockCtx, Lookahead, Parser};
use crate::lex::{block_scalar_header, SyntaxKind};
use crate::ParseErrorKind;

impl Parser<'_> {
    pub(super) fn parse_alias(&mut self) {
        // Create an alias node and consume the reference token
        // The token itself already contains the full "*alias_name" text
        self.builder.start_node(SyntaxKind::ALIAS.into());
        if self.current() == Some(SyntaxKind::REFERENCE) {
            self.bump(); // This preserves the original "*alias_name" token
        }
        self.builder.finish_node();
    }

    pub(super) fn parse_scalar(&mut self) {
        self.builder.start_node(SyntaxKind::SCALAR.into());
        let tokens_before = self.tokens.len();

        if self
            .current()
            .is_some_and(|k| is_plain_scalar_kind(k) || k == SyntaxKind::UNTERMINATED_STRING)
        {
            if self.current() == Some(SyntaxKind::UNTERMINATED_STRING) {
                self.add_error(
                    "Unterminated quoted string".to_string(),
                    ParseErrorKind::UnterminatedString,
                );
            }
            if self.in_flow_context {
                self.parse_flow_plain_scalar();
            } else {
                self.parse_block_plain_scalar();
            }
        } else {
            self.parse_untyped_scalar();
        }
        // Nothing here to read (`[}`, `{?`): the node is empty, which is
        // the implicit null.
        if self.tokens.len() == tokens_before {
            self.builder.token(SyntaxKind::NULL.into(), "");
        }

        self.builder.finish_node();
    }

    /// Consume a plain scalar in block context.
    ///
    /// Per the YAML spec a plain scalar may continue on following lines as
    /// long as they are more indented than the line it started on; the
    /// newline is folded into the scalar. Comments and flow delimiters end
    /// it.
    fn parse_block_plain_scalar(&mut self) {
        // A quoted scalar arrives as one complete STRING token. Text after it
        // on the same line is kept in the scalar for the validator to report,
        // but the next line never continues it: `a: "b"\n  c\n` is an error,
        // not the scalar `"b" c`.
        let quoted = self.current_is_quoted_string();

        // current_line_indent tracks the actual line indentation. For inline
        // scalars in sequence items (indent == 0 because the INDENT token was
        // already consumed) continuation must not be attempted: it cannot be
        // told apart from the next mapping key.
        //
        // A value on its key's line belongs to that key's mapping, so a
        // continuation has to clear the mapping's column, which in a sequence
        // entry is right of the line's indent: `- a: b\n  c\n` puts `a` at
        // column 2, so `c` cannot continue `b`.
        let scalar_indent = self.plain_scalar_indent();

        while let Some(kind) = self.current() {
            if kind == SyntaxKind::COMMENT {
                break;
            }

            if kind == SyntaxKind::NEWLINE {
                if !quoted && self.is_plain_scalar_continuation(0, scalar_indent) {
                    self.bump(); // consume NEWLINE
                    while matches!(
                        self.current(),
                        Some(SyntaxKind::INDENT | SyntaxKind::WHITESPACE)
                    ) {
                        self.bump();
                    }
                    continue;
                }
                // Next line is not a continuation - stop here
                break;
            }

            // Stop at a flow collection delimiter inside a flow collection,
            // where it really delimits.
            //
            // Outside one it is ordinary scalar content, and the lexer has
            // already folded any that share a line with the scalar; reaching
            // here means we are on a continuation line, where `a\n}` is the
            // scalar `a }`, as both saphyr and PyYAML read it.
            if matches!(
                kind,
                SyntaxKind::LEFT_BRACKET
                    | SyntaxKind::LEFT_BRACE
                    | SyntaxKind::RIGHT_BRACKET
                    | SyntaxKind::RIGHT_BRACE
                    | SyntaxKind::COMMA
            ) && (self.flow_depth > 0 || self.is_complex_mapping_key())
            {
                break;
            }

            // Leave whitespace that precedes a comment to the comment itself,
            // and trailing whitespace at the end of the scalar's last line
            // outside it: `a: b \n` has the value `b`. Whitespace before a
            // line break the scalar continues past is folded into it.
            if kind == SyntaxKind::WHITESPACE {
                let ends_scalar = match self.peek_kind(1) {
                    Some(SyntaxKind::COMMENT) | None => true,
                    Some(SyntaxKind::NEWLINE) => {
                        quoted || !self.is_plain_scalar_continuation(1, scalar_indent)
                    }
                    Some(_) => false,
                };
                if ends_scalar {
                    break;
                }
            }

            // A quote left open after a quoted scalar (`a: "b" "\n`) runs on
            // to the end of the input, as one opening the scalar does.
            if quoted && kind == SyntaxKind::UNTERMINATED_STRING {
                self.add_error(
                    "Unterminated quoted string".to_string(),
                    ParseErrorKind::UnterminatedString,
                );
            }
            self.bump();
        }
    }

    /// The indentation a block plain scalar starting on the current line
    /// has to clear to continue on the next.
    pub(super) fn plain_scalar_indent(&self) -> usize {
        self.current_line_indent
            .max(self.ctx.mapping_value_column.unwrap_or(0))
    }

    /// Consume a plain scalar inside a flow collection.
    ///
    /// A quoted STRING arrives as one complete token and stops there.
    /// Otherwise keep reading multi-word and multi-line content until a flow
    /// delimiter, a comment, or a colon acting as a key separator.
    fn parse_flow_plain_scalar(&mut self) {
        let is_quoted_string = self.current_is_quoted_string();

        self.bump(); // Consume the initial typed token

        if is_quoted_string {
            return;
        }

        while let Some(kind) = self.current() {
            // NEWLINE is not a terminator here: plain scalars span lines.
            if matches!(
                kind,
                SyntaxKind::COMMA
                    | SyntaxKind::RIGHT_BRACE
                    | SyntaxKind::RIGHT_BRACKET
                    | SyntaxKind::COMMENT
            ) {
                break;
            }

            if kind == SyntaxKind::NEWLINE {
                // Only take the line break if the scalar goes on after it, so
                // that one before a delimiter stays outside the scalar.
                let mut n = 1;
                while matches!(
                    self.peek_kind(n),
                    Some(SyntaxKind::WHITESPACE | SyntaxKind::INDENT | SyntaxKind::NEWLINE)
                ) {
                    n += 1;
                }
                let continues = !matches!(
                    self.peek_kind(n),
                    None | Some(
                        SyntaxKind::COMMA
                            | SyntaxKind::RIGHT_BRACE
                            | SyntaxKind::RIGHT_BRACKET
                            | SyntaxKind::COMMENT
                    )
                ) && !self.flow_key_separator_at(n);
                if !continues {
                    break;
                }
                for _ in 0..n {
                    self.bump();
                }
                continue;
            }

            // Whitespace before a delimiter ends the scalar (`[ a , b ]`,
            // `{ a : b }`); whitespace between words is part of it
            // (`{omitted value:,}`).
            if kind == SyntaxKind::WHITESPACE
                && (matches!(
                    self.peek_kind(1),
                    Some(
                        SyntaxKind::COMMA
                            | SyntaxKind::RIGHT_BRACE
                            | SyntaxKind::RIGHT_BRACKET
                            | SyntaxKind::NEWLINE
                            | SyntaxKind::COMMENT
                    )
                ) || self.flow_key_separator_at(1))
            {
                break;
            }

            if self.flow_key_separator_at(0) {
                break;
            }

            self.bump();
        }
    }

    /// Whether the current token is a complete quoted scalar, which the
    /// lexer emits as a single STRING.
    fn current_is_quoted_string(&self) -> bool {
        self.current() == Some(SyntaxKind::STRING)
            && self
                .current_text()
                .is_some_and(|text| text.starts_with('"') || text.starts_with('\''))
    }

    /// Consume a scalar the lexer gave no specific type, up to whatever
    /// structure ends it.
    fn parse_untyped_scalar(&mut self) {
        while let Some(kind) = self.current() {
            if matches!(
                kind,
                SyntaxKind::NEWLINE
                    | SyntaxKind::DASH
                    | SyntaxKind::COMMENT
                    | SyntaxKind::DOC_START
                    | SyntaxKind::DOC_END
            ) {
                break;
            }

            // A colon ends the scalar in block context, where it means mapping
            // structure. In flow context it is ordinary content (IPv6, URLs)
            // unless a delimiter follows, which makes it a key separator.
            if kind == SyntaxKind::COLON {
                if !self.in_flow_context {
                    break;
                }
                if self.flow_key_separator_at(0) {
                    break;
                }
            }

            // In flow context, stop at flow collection delimiters
            if self.in_flow_context
                && matches!(
                    kind,
                    SyntaxKind::LEFT_BRACKET
                        | SyntaxKind::RIGHT_BRACKET
                        | SyntaxKind::LEFT_BRACE
                        | SyntaxKind::RIGHT_BRACE
                        | SyntaxKind::COMMA
                )
            {
                break;
            }
            self.bump();
        }
    }

    /// The kind of the token `n` places after the current one, if any.
    ///
    /// `self.tokens` is a stack popped from the end, so the current token
    /// sits on top.
    pub(super) fn peek_kind(&self, n: usize) -> Option<SyntaxKind> {
        self.lookahead().kind(n)
    }

    /// Is the token `n` places after the cursor a COLON separating a flow
    /// mapping key from its value, rather than sitting inside a scalar?
    pub(super) fn flow_key_separator_at(&self, n: usize) -> bool {
        self.peek_kind(n) == Some(SyntaxKind::COLON)
            && matches!(
                self.peek_kind(n + 1),
                None | Some(
                    SyntaxKind::COMMA
                        | SyntaxKind::RIGHT_BRACE
                        | SyntaxKind::RIGHT_BRACKET
                        | SyntaxKind::WHITESPACE
                        | SyntaxKind::NEWLINE
                )
            )
    }

    pub(super) fn parse_tagged_value(&mut self, base_indent: usize) {
        self.parse_tagged_value_inner(base_indent, false)
    }

    /// As `parse_tagged_value`, for a tag in the value position of a block
    /// mapping entry.
    ///
    /// A block mapping cannot be an indentless value, so a tag followed by
    /// entries at the key's own column annotates an implicit null and those
    /// entries stay siblings of the key.
    pub(super) fn parse_tagged_value_as_mapping_value(&mut self, base_indent: usize) {
        self.parse_tagged_value_inner(base_indent, true)
    }

    fn parse_tagged_value_inner(&mut self, base_indent: usize, as_mapping_value: bool) {
        // The content's own shape decides how it parses, whatever the tag
        // names, even for `!!set`, `!!omap` and `!!pairs`: the validator
        // reports content that does not suit the tag.
        self.builder.start_node(SyntaxKind::TAGGED_NODE.into());
        self.bump(); // TAG token

        // An empty node leaves the whitespace after the tag to the
        // enclosing flow collection, like any other flow node.
        let empty_flow_node = self.annotates_empty_flow_node();
        if !empty_flow_node {
            while matches!(self.current(), Some(SyntaxKind::WHITESPACE)) {
                self.bump();
            }
        }

        match self.tagged_block_node_indent(base_indent, as_mapping_value) {
            _ if empty_flow_node => self.emit_implicit_null(),
            Some(indent) => {
                // The tag is alone on its line and annotates a block
                // node starting on a later line. parse_value's NEWLINE
                // arm only nests when an INDENT follows the line break
                // directly, so step over any blank and comment-only
                // lines and parse the node here, inside this
                // TAGGED_NODE.
                // An anchor between the tag and the line break
                // annotates the same node; keep it in the TAGGED_NODE.
                if self.current() == Some(SyntaxKind::ANCHOR) {
                    self.bump();
                }
                self.parse_tagged_block_body(indent, base_indent);
            }
            None if self.annotation_ends_line() => self.emit_annotated_null(),
            // Scalar, flow collection, or nothing that belongs to this
            // tag: the ordinary value path already handles it. Keep
            // our own base indent, and tell a trailing anchor
            // (`k: !!str &a`) which position we are in, so it asks
            // the same attachment question we just answered and
            // leaves a dedented sibling entry alone.
            None => {
                let ctx = BlockCtx {
                    annotation_in_value_position: as_mapping_value,
                    ..self.ctx
                };
                self.scoped(ctx, |p| p.parse_value_with_base_indent(base_indent));
            }
        }

        self.builder.finish_node();
    }

    /// Indent of the block node a lone tag annotates, if it starts on a
    /// later line.
    ///
    /// Returns `None` unless the rest of the tag's line is empty and the next
    /// line with content opens a block collection at a column this tag can
    /// own. Blank and comment-only lines in between are skipped, as they
    /// belong to neither node.
    ///
    /// A sequence may start at `base_indent` rather than further right, since
    /// one nested in a mapping need not be indented past its key. A mapping
    /// in the value position (`as_mapping_value`) has to nest under the key,
    /// or it is a sibling entry rather than our value; with no enclosing key
    /// there is nothing to nest under, so `!!map\na: 1\n` is a tagged mapping
    /// at the same column. Anything left of `base_indent` belongs to an
    /// enclosing collection, so it is not ours to adopt.
    pub(super) fn tagged_block_node_indent(
        &self,
        base_indent: usize,
        as_mapping_value: bool,
    ) -> Option<usize> {
        // Only a body in the value position must clear the key's column.
        let deep_enough = |indent: usize| {
            if as_mapping_value {
                indent > base_indent
            } else {
                indent >= base_indent
            }
        };
        // `tokens` is in reverse order, so walk it backwards from the current
        // token to read the rest of this line and the lines after it.
        let mut rest = self.tokens.iter().rev().map(|(kind, text)| (*kind, text));

        // Only an anchor or a comment may still sit between the tag and the
        // line break; `!!seq &a` annotates the block node just as `!!seq` does.
        let mut token = rest.next()?;
        if token.0 == SyntaxKind::ANCHOR {
            token = rest.next()?;
            while token.0 == SyntaxKind::WHITESPACE {
                token = rest.next()?;
            }
        }
        if token.0 == SyntaxKind::COMMENT {
            token = rest.next()?;
        }
        if token.0 != SyntaxKind::NEWLINE {
            return None;
        }

        // Walk whole lines until one carries content. An INDENT token holds
        // the line's leading whitespace, so a line is blank when a NEWLINE
        // follows it directly.
        loop {
            let mut indent = 0;
            let mut token = rest.next()?;
            if token.0 == SyntaxKind::INDENT {
                indent = token.1.len();
                token = rest.next()?;
            }
            match token.0 {
                SyntaxKind::NEWLINE => continue,
                SyntaxKind::COMMENT => {
                    // Comment lines carry no content, but the COMMENT token
                    // stops before the line break, so step over that too.
                    match rest.next() {
                        Some((SyntaxKind::NEWLINE, _)) => continue,
                        _ => return None,
                    }
                }
                // Inside an explicit key, a `-` left of the `?` is an entry
                // of the sequence that key sits in, not a node for us to
                // annotate: `-\n  ? &d\n-\n  ? e\n` is one sequence, as the
                // YAML test suite's PW8X expects. The key's own sequence may
                // sit at the `?`'s column, as `seq-spaces` allows, and an
                // explicit value's at the `:`'s. Likewise a `-` at or left
                // of the dash of the sequence entry we are in is that
                // sequence's next entry: `- - &a\n  - x\n` has two.
                SyntaxKind::DASH
                    if self
                        .ctx
                        .explicit_key_column
                        .is_some_and(|column| indent < column)
                        || self
                            .ctx
                            .explicit_value_column
                            .is_some_and(|column| indent < column)
                        || (!self.in_flow_context
                            && self
                                .ctx
                                .sequence_entry_column
                                .is_some_and(|column| indent <= column)) =>
                {
                    return None
                }
                SyntaxKind::DASH => return (indent >= base_indent).then_some(indent),
                // A document marker ends the document rather than opening a
                // body: `!!str\n---\n` tags a null.
                SyntaxKind::DOC_START | SyntaxKind::DOC_END => return None,
                // A block scalar header can only be this annotation's node,
                // whatever its column: nothing else starts with `|` or `>`.
                // The validator reports one left of its parent node, as in
                // `a: !!str\n|\n x\n`; the YAML test suite's M5C3 has one
                // less indented than the tag but still valid.
                SyntaxKind::PIPE | SyntaxKind::GREATER => return Some(indent),
                // Any other node in an explicit key or value has to be
                // indented past its `?` or `:`, as `s-l+block-node` asks.
                // A line at or left of it is the entry's own value or the
                // mapping's next entry: `? !!str\nb: 1\n` keys a null and
                // keeps `b` a sibling, as PyYAML reads it.
                _ if self
                    .ctx
                    .explicit_key_column
                    .max(self.ctx.explicit_value_column)
                    .is_some_and(|column| indent <= column) =>
                {
                    return None
                }
                // Anything else is the body if it is deep enough: an
                // explicit key opens a mapping without a colon on the line,
                // a flow collection is a complete node on its own, and
                // anything else opens a plain scalar, which is a valid
                // tagged body whether or not a colon makes it a mapping.
                _ => return deep_enough(indent).then_some(indent),
            }
        }
    }

    /// Parse the block node at `indent` that a tag alone on its line
    /// annotates, stepping over the blank and comment-only lines before it.
    fn parse_tagged_block_body(&mut self, indent: usize, base_indent: usize) {
        self.skip_ws_and_newlines();
        if self.current() == Some(SyntaxKind::DASH) {
            self.parse_sequence_with_base_indent(indent);
        } else {
            // The body is parsed at its own column, but a plain scalar there
            // still belongs to the collection the tag sits in, so its
            // continuation only has to clear the tag's column.
            let ctx = BlockCtx {
                scalar_continuation_floor: Some(base_indent),
                ..self.ctx
            };
            self.scoped(ctx, |p| p.parse_value_with_base_indent(indent));
        }
    }

    pub(super) fn parse_literal_block_scalar(&mut self) {
        // Take the flag: only the document's own node is at the root, and
        // anything this scalar's body contains is not.
        let at_root = std::mem::take(&mut self.node_is_document_root);
        self.builder.start_node(SyntaxKind::SCALAR.into());
        self.bump(); // consume PIPE
        let explicit_indent = self.parse_block_scalar_header();
        self.parse_block_scalar_content(at_root, explicit_indent);
        self.builder.finish_node();
    }

    pub(super) fn parse_folded_block_scalar(&mut self) {
        let at_root = std::mem::take(&mut self.node_is_document_root);
        self.builder.start_node(SyntaxKind::SCALAR.into());
        self.bump(); // consume GREATER
        let explicit_indent = self.parse_block_scalar_header();
        self.parse_block_scalar_content(at_root, explicit_indent);
        self.builder.finish_node();
    }

    /// Parse a block scalar's header, returning its explicit indentation
    /// indicator if it carries one (`|2`, `>2-`).
    fn parse_block_scalar_header(&mut self) -> Option<usize> {
        let (explicit_indent, len) = block_scalar_header(
            self.tokens
                .iter()
                .rev()
                .map(|(kind, text)| (*kind, text.as_str())),
        );
        for _ in 0..len {
            self.bump();
        }

        // Consume optional comment
        if self.current() == Some(SyntaxKind::COMMENT) {
            self.bump();
        }

        // Consume the newline after the header
        if self.current() == Some(SyntaxKind::NEWLINE) {
            self.bump();
        }

        explicit_indent
    }

    /// Parse a block scalar's body.
    ///
    /// `at_root` says the scalar is the document's own node, with nothing
    /// enclosing it, so its body may start at column 0. Nested under a key
    /// the body has to be indented, or `a: |\nb: 1\n` would swallow `b`.
    fn parse_block_scalar_content(&mut self, at_root: bool, explicit_indent: Option<usize>) {
        // Consume all indented content that follows.
        //
        // The header consumed its own line break, so the loop starts at the
        // beginning of the next line. Say so: otherwise the dedent check is
        // suppressed for exactly that line and an empty block scalar eats the
        // entry after it (`empty: |\nnext: 1\n` lost `next`).
        let mut last_was_newline = true;
        // The body has to clear the column of the collection this scalar is
        // a node of, or the line is a dedent out of it rather than the
        // body's first line. Taking such a line's column as the base
        // swallowed it: `- -\n   |\n  - z\n` has an empty scalar, and
        // `- z` is the inner sequence's next entry. A scalar that is the
        // document's own node has no such collection.
        //
        // Several columns can be set at once, as in `- a: |`, and the
        // innermost collection is the one with the largest column. A value
        // on a later line than its key counts too: in `- a:\n   |\n  b: 1\n`
        // the line back at `a`'s column is the mapping's next entry.
        let enclosing_column = self
            .ctx
            .sequence_entry_column
            .max(self.ctx.mapping_value_column)
            .max(self.ctx.mapping_block_value_column);
        // An explicit indentation indicator (`>2`) states the body's column
        // outright, so the first line does not get to set it: in
        // `a: >2\n   more\n  regular\n` the `regular` line is body at the
        // declared column, not a dedent out of the deeper first line.
        //
        // The indicator counts from the innermost enclosing collection's
        // column, which is the largest of the ones recorded, so
        // `- aaa: |2\n    xxx\n` has its body at column 4. With none, as at
        // the document root, it counts from column 0.
        let parent_column = enclosing_column.unwrap_or(0);
        let explicit_indent = explicit_indent.map(|indent| parent_column + indent);
        let mut base_indent: Option<usize> = explicit_indent;
        let mut first_content_indent: Option<usize> = explicit_indent;

        while let Some(kind) = self.current() {
            // Detect the first body line's indentation to use as base.
            //
            // The header's NEWLINE is already consumed, so the loop starts on
            // that first line and `last_was_newline` is false for it. A line
            // at column 0 carries no INDENT token, so record 0 rather than
            // leaving the base unset until some later, indented line sets it:
            // `|-\nx\ny\n` used to keep `x` and strand `y`, and
            // `|-\n?  >\n ems+\n...` took its base from the second line.
            if first_content_indent.is_none() {
                match kind {
                    // A whitespace-only line carries no content either, and
                    // it may be indented further than the body that follows
                    // (`- >\n \t\n detected\n`), so it must not set the base.
                    SyntaxKind::INDENT if self.indent_is_blank_line() => {
                        self.bump();
                        continue;
                    }
                    SyntaxKind::INDENT
                        if enclosing_column.is_some_and(|column| {
                            self.current_text().is_some_and(|text| text.len() <= column)
                        }) =>
                    {
                        break;
                    }
                    SyntaxKind::INDENT => {
                        first_content_indent = self
                            .current_text()
                            .map(|text| body_indent(text, enclosing_column.unwrap_or(0)));
                    }
                    // A blank line carries no content, so it sets no base.
                    SyntaxKind::NEWLINE => {
                        self.bump();
                        last_was_newline = true;
                        continue;
                    }
                    // A body line at column 0 only belongs to this scalar
                    // when nothing encloses it.
                    _ if at_root => first_content_indent = Some(0),
                    _ => {}
                }
            }

            // Set base_indent after seeing first INDENT token
            if base_indent.is_none() && first_content_indent.is_some() {
                base_indent = first_content_indent;
            }

            // Check if we've reached unindented content BEFORE consuming
            if self.is_at_unindented_content_for_block_scalar(last_was_newline, base_indent) {
                break;
            }

            match kind {
                // Stop at document markers
                SyntaxKind::DOC_START | SyntaxKind::DOC_END => break,
                // Track newlines to detect line starts
                SyntaxKind::NEWLINE => {
                    self.bump();
                    last_was_newline = true;
                    continue;
                }
                // Continue consuming content and whitespace
                _ => {
                    self.bump();
                    last_was_newline = false;
                }
            }
        }
    }

    fn is_at_unindented_content_for_block_scalar(
        &self,
        after_newline: bool,
        base_indent: Option<usize>,
    ) -> bool {
        // A body that starts at column 0 (only possible at the document root)
        // reads an unindented line as more body, not as a dedent.
        let body_at_column_zero = base_indent == Some(0);
        // Check if we've reached content at the beginning of a line (unindented)
        // Only check for structural tokens if we're at the start of a line
        if after_newline {
            // After a newline, check if the next token is unindented
            let current = self.current();

            // COLON, QUESTION or DASH at the start of a line means the end of
            // the block scalar -- but only where the body is indented, since a
            // line at column 0 then cannot belong to it. Inside a body that
            // starts at column 0 they are literal text like anything else, and
            // an *indented* `- x` is body content in either case (it carries an
            // INDENT token, so it is not at a line start here).
            if !body_at_column_zero
                && matches!(
                    current,
                    Some(SyntaxKind::COLON | SyntaxKind::QUESTION | SyntaxKind::DASH)
                )
            {
                return true;
            }

            // If we have base_indent, check if current line has less indentation.
            //
            // A blank line's indentation is not significant: a line holding
            // only spaces belongs to the body however short it is, so
            // `k: |\n  a\n \n  b\n` keeps `b`. Without this the whole
            // remainder was stranded in an ERROR node with no parse error.
            if let Some(base) = base_indent {
                if current == Some(SyntaxKind::INDENT) && !self.indent_is_blank_line() {
                    if let Some(text) = self.current_text() {
                        if text.len() < base {
                            // Current line has less indentation than base - end of block scalar
                            return true;
                        }
                    }
                }
            }

            // If we don't see INDENT, we've reached unindented content --
            // unless this scalar's body starts at column 0 itself, where an
            // unindented line is just another body line. Otherwise the first
            // such line is taken as content and every later one at the same
            // column reads as a dedent, which is how `|-\nx\ny\n` kept `x`
            // and stranded `y`. A comment there is a trailing comment after
            // the body, not part of it.
            if !body_at_column_zero
                && current != Some(SyntaxKind::INDENT)
                && current != Some(SyntaxKind::WHITESPACE)
                && current != Some(SyntaxKind::NEWLINE)
            {
                // This is unindented content at the start of a line
                return true;
            }
        }
        false
    }
    /// Check if a plain scalar continues on the next line after a NEWLINE
    /// This looks ahead to see if the next line has content at greater indentation
    ///
    /// The NEWLINE sits `offset` tokens after the current one.
    pub(super) fn is_plain_scalar_continuation(&self, offset: usize, scalar_indent: usize) -> bool {
        let ahead = self.lookahead();
        if offset + 1 >= ahead.len() {
            return false; // No more tokens
        }
        let (line_indent, content) = next_content_line(ahead, offset + 1);
        ahead
            .kind(content)
            .is_some_and(|kind| self.continues_plain_scalar(kind, line_indent, scalar_indent))
            && self.indented_to_continue(line_indent, scalar_indent)
            && !opens_mapping_entry(ahead, content)
    }

    /// Whether a line starting with `kind` at `line_indent` holds content
    /// that continues a plain scalar at `scalar_indent`.
    ///
    /// A TAG, ANCHOR or REFERENCE counts: the lexer reads `!x`, `&a` and
    /// `*a` as node properties because a newline precedes them, but a
    /// property only applies at the start of a node, and a continuation
    /// line is inside one already. `- a\n !\n` is the scalar `a !`, and `k:#foo\n &a !t s`
    /// the scalar `k:#foo &a !t s`, as both saphyr and PyYAML read them.
    ///
    /// PIPE and GREATER count for the same reason. They open a block
    /// scalar only at the start of a node, which in block context means
    /// straight after a `-` or `:`; a line that merely continues a scalar
    /// is inside a node already. `" a\n|\n"` is the scalar `a |`, as both
    /// saphyr and PyYAML read it.
    ///
    /// The flow indicators count outside a flow collection, where they
    /// are ordinary scalar content: `a\n}` is the scalar `a }`, as both
    /// saphyr and PyYAML read it. Inside one they delimit, so they end
    /// the scalar as before.
    ///
    /// A QUESTION counts only where it cannot open the next explicit
    /// key of the mapping or sequence we may sit in: past the scalar's
    /// own column, or in a root scalar that has no such collection. So
    /// `? a\n: 1\n? b\n: 2\n` still has two entries, while `k: v\n  ?\n`
    /// is the single value `v ?`, as saphyr and PyYAML both read it.
    ///
    /// A DASH counts only when the line is indented past the scalar's own,
    /// where it cannot be the next entry of the sequence we sit in:
    /// `a:\n- x\n  - y\n` is the single item `x - y`, as both saphyr and
    /// PyYAML read it. At or left of the scalar's column it opens an entry
    /// and ends the scalar, as before.
    fn continues_plain_scalar(
        &self,
        kind: SyntaxKind,
        line_indent: usize,
        scalar_indent: usize,
    ) -> bool {
        match kind {
            _ if is_plain_scalar_kind(kind) => true,
            SyntaxKind::UNTERMINATED_STRING
            | SyntaxKind::TAG
            | SyntaxKind::ANCHOR
            | SyntaxKind::REFERENCE
            // A bare `&` or `*` with no name is punctuation the lexer could
            // not make a property of, so it is content. A lone `+`
            // likewise: it is an indicator only as a block-scalar chomping
            // suffix, so `a\n+\n` is `a +`.
            | SyntaxKind::AMPERSAND
            | SyntaxKind::ASTERISK
            | SyntaxKind::PLUS
            | SyntaxKind::PIPE
            | SyntaxKind::GREATER
            // A `%` introduces a directive only at the start of a document,
            // so on a continuation line it is content: `v\n%\n[\n` is the
            // scalar `v % [`, as saphyr and PyYAML both read it. Without
            // this the scalar ended at the directive and the `[` opened a
            // flow sequence that never closed.
            | SyntaxKind::DIRECTIVE => true,
            SyntaxKind::LEFT_BRACKET
            | SyntaxKind::RIGHT_BRACKET
            | SyntaxKind::LEFT_BRACE
            | SyntaxKind::RIGHT_BRACE
            | SyntaxKind::COMMA => self.flow_depth == 0,
            // `?` opens an explicit key only at the start of a node. On a
            // line that merely continues a scalar it is content:
            // `- x\n  ?\n- y\n` is the two entries `x ?` and `y`, as saphyr
            // and PyYAML both read it.
            SyntaxKind::QUESTION => {
                match self.ctx.mapping_value_column.or(self.ctx.sequence_entry_column) {
                    // Inside a mapping's value, or a sequence entry: only past
                    // that collection's own column, where the `?` cannot start
                    // its next node.
                    Some(column) => line_indent > column,
                    // No sequence to open an entry of, so the mapping is what a
                    // `?` could start a node of. Past the scalar's own column it
                    // cannot: `k: v\n  ?\n` is the value `v ?`. A root scalar has
                    // no mapping either way.
                    None => {
                        line_indent > scalar_indent
                            || self.ctx.scalar_continuation_floor == Some(0)
                    }
                }
            }
            SyntaxKind::DASH => match self.ctx.sequence_entry_column {
                // Inside a sequence: only past our own dash, where it cannot
                // be the next entry.
                Some(column) => line_indent > column,
                // No sequence to open an entry of, so it is content.
                None => true,
            },
            _ => false,
        }
    }

    /// Whether a line at `line_indent` is indented far enough to continue a
    /// plain scalar at `scalar_indent`.
    fn indented_to_continue(&self, line_indent: usize, scalar_indent: usize) -> bool {
        // A continuation has to be indented past the scalar's own line, so it
        // cannot be mistaken for the next entry of an enclosing mapping.
        // Where there is no such mapping to confuse it with, an equally
        // indented line continues the scalar (`ab\ncd`, `- x\n y`).
        //
        // A lone tag parses its body with the body line's own column as the
        // base, but the scalar belongs to the enclosing collection, so its
        // continuation only has to clear that collection's column.
        //
        // Inside a sequence entry the line has to clear the entry's dash
        // too, which the scalar's line misses for a compact sequence: in
        // `- - a\n  b\n` the inner `-` is at column 2, where `b` cannot
        // continue `a`.
        let floor = self
            .ctx
            .scalar_continuation_floor
            .map_or(scalar_indent, |floor| floor.min(scalar_indent));
        if self
            .ctx
            .sequence_entry_column
            .is_some_and(|column| line_indent <= column)
        {
            false
        } else if self.ctx.equal_indent_continues_scalar {
            line_indent >= floor
        } else {
            line_indent > floor
        }
    }
}

/// The indentation of the next line holding content, and the position of
/// its first token, for a line starting `pos` tokens ahead.
///
/// A blank line does not end a plain scalar; it folds to a line break and
/// the scalar continues on the next line with content. So step over any run
/// of blank lines (an INDENT holds a line's leading whitespace, so a line is
/// blank when a NEWLINE follows it directly).
fn next_content_line(ahead: Lookahead, mut pos: usize) -> (usize, usize) {
    let last = ahead.len() - 1;
    loop {
        let mut after_blank = pos;
        if ahead.kind(after_blank) == Some(SyntaxKind::INDENT) {
            after_blank = (after_blank + 1).min(last);
        }
        if after_blank < last && ahead.kind(after_blank) == Some(SyntaxKind::NEWLINE) {
            pos = after_blank + 1;
            continue;
        }
        break;
    }

    let mut indent = 0;
    if let Some((SyntaxKind::INDENT, text)) = ahead.get(pos) {
        indent = text.len();
        pos = (pos + 1).min(last);
    }
    while pos < last && ahead.kind(pos) == Some(SyntaxKind::WHITESPACE) {
        pos += 1;
    }
    (indent, pos)
}

/// Whether the line whose content starts `pos` tokens ahead is a mapping
/// key, with a COLON after its content, rather than a scalar continuation.
fn opens_mapping_entry(ahead: Lookahead, mut pos: usize) -> bool {
    let last = ahead.len() - 1;
    // Tags and anchors in front annotate that key (`&x c: d`), so look
    // past them to the content they cover.
    while pos < last && matches!(ahead.kind(pos), Some(SyntaxKind::TAG | SyntaxKind::ANCHOR)) {
        pos += 1;
        while pos < last && ahead.kind(pos) == Some(SyntaxKind::WHITESPACE) {
            pos += 1;
        }
    }
    if pos == last {
        return false;
    }
    let mut check = pos + 1;

    // A `:` inside a flow collection separates that collection's own
    // entries, so step over a balanced one before looking: in `a\n[:]\n`
    // the colon is the flow mapping's, and the line continues the scalar
    // `a [:]`. Only the colon after the closing bracket would make the line
    // a key of ours. An unbalanced collection runs out of tokens, which
    // leaves the scan at the last one.
    let after_flow = matches!(
        ahead.kind(pos),
        Some(SyntaxKind::LEFT_BRACKET | SyntaxKind::LEFT_BRACE)
    );
    if after_flow {
        let mut depth = 0usize;
        loop {
            match ahead.kind(check - 1) {
                Some(SyntaxKind::LEFT_BRACKET | SyntaxKind::LEFT_BRACE) => depth += 1,
                Some(SyntaxKind::RIGHT_BRACKET | SyntaxKind::RIGHT_BRACE) => {
                    depth = depth.saturating_sub(1)
                }
                _ => {}
            }
            if depth == 0 || check == last {
                break;
            }
            check += 1;
        }
    }

    // Skip any whitespace after the content. After a flow collection, also
    // skip the plain scalar content that the key keeps before its colon
    // (`[x] y: z`), as is_complex_mapping_key does.
    while check < last
        && ahead.kind(check).is_some_and(|kind| {
            kind == SyntaxKind::WHITESPACE || (after_flow && is_plain_scalar_kind(kind))
        })
    {
        check += 1;
    }

    ahead.kind(check) == Some(SyntaxKind::COLON)
}

/// Kinds emitted by the lexer for plain (unquoted) scalar content.
pub(super) fn is_plain_scalar_kind(kind: SyntaxKind) -> bool {
    matches!(
        kind,
        SyntaxKind::STRING
            | SyntaxKind::INT
            | SyntaxKind::FLOAT
            | SyntaxKind::BOOL
            | SyntaxKind::NULL
            | SyntaxKind::MERGE_KEY
    )
}

/// The indentation a block scalar's first body line sets, where `indent`
/// is the line's leading white space and `enclosing_column` the column of
/// the collection the scalar is a node of.
///
/// Indentation is spaces only (s-indent), so a tab after them is content:
/// `k: |\n  \tx\n` is indented two and holds `\tx`. Spaces that don't
/// clear the enclosing column make the line invalid as body; the parser
/// leniently counts its tabs as indentation then.
fn body_indent(indent: &str, enclosing_column: usize) -> usize {
    let spaces = crate::column::space_indent(indent);
    if spaces > enclosing_column {
        spaces
    } else {
        indent.len()
    }
}