@effected/yaml 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +78 -7
  2. package/Yaml.js +80 -32
  3. package/YamlDocument.js +22 -3
  4. package/YamlFormat.js +170 -19
  5. package/YamlLint.js +239 -0
  6. package/YamlLintRule.js +36 -0
  7. package/YamlNode.js +36 -2
  8. package/YamlToken.js +139 -0
  9. package/YamlVisitor.js +40 -5
  10. package/index.d.ts +439 -12
  11. package/index.js +4 -1
  12. package/internal/composer/anchors.js +2 -27
  13. package/internal/composer/block.js +246 -58
  14. package/internal/composer/comments.js +187 -0
  15. package/internal/composer/document.js +49 -12
  16. package/internal/composer/flow.js +82 -9
  17. package/internal/composer/scalars.js +48 -11
  18. package/internal/composer/state.js +16 -2
  19. package/internal/cst-parser.js +2 -1
  20. package/internal/diagnostics.js +12 -3
  21. package/internal/fold.js +22 -10
  22. package/internal/rules/catalog.js +39 -0
  23. package/internal/rules/colon-spacing.js +77 -0
  24. package/internal/rules/comments-spacing.js +68 -0
  25. package/internal/rules/document-end.js +65 -0
  26. package/internal/rules/document-start.js +64 -0
  27. package/internal/rules/empty-lines.js +70 -0
  28. package/internal/rules/eof-newline.js +33 -0
  29. package/internal/rules/hyphen-spacing.js +52 -0
  30. package/internal/rules/indentation.js +103 -0
  31. package/internal/rules/key-duplicates.js +49 -0
  32. package/internal/rules/line-length.js +36 -0
  33. package/internal/rules/parse-validity.js +33 -0
  34. package/internal/rules/quoted-strings.js +81 -0
  35. package/internal/rules/trailing-spaces.js +39 -0
  36. package/internal/rules/truthy.js +84 -0
  37. package/internal/rules/util.js +92 -0
  38. package/internal/stringifier.js +266 -54
  39. package/package.json +3 -2
@@ -0,0 +1,187 @@
1
+ import { YamlMap, YamlScalar, YamlSeq } from "../../YamlNode.js";
2
+
3
+ //#region src/internal/composer/comments.ts
4
+ /** True when there is no line break between `start` and `end` in `text`. */
5
+ function sameLineSpan(text, start, end) {
6
+ if (start < 0) return false;
7
+ return !text.slice(Math.max(0, start), Math.max(0, end)).includes("\n");
8
+ }
9
+ /**
10
+ * True when only horizontal whitespace precedes `offset` on its line — i.e.
11
+ * the token at `offset` starts its own line. Purely local, so it stays
12
+ * correct even when a preceding node's span over-extends past line ends.
13
+ */
14
+ function isOwnLineAt(text, offset) {
15
+ let i = offset - 1;
16
+ while (i >= 0) {
17
+ const ch = text[i];
18
+ if (ch === " " || ch === " ") {
19
+ i--;
20
+ continue;
21
+ }
22
+ return ch === "\n" || ch === "\r";
23
+ }
24
+ return true;
25
+ }
26
+ /**
27
+ * True when the line immediately above the line containing `offset` is blank
28
+ * (empty or horizontal whitespace only). Purely local — see
29
+ * {@link isOwnLineAt} for why span-based gap checks are not used.
30
+ */
31
+ function hasBlankLineAbove(text, offset) {
32
+ return blankLineAboveStart(text, offset) >= 0;
33
+ }
34
+ /**
35
+ * Start offset of the blank line immediately above the line containing
36
+ * `offset`, or `-1` when that line is not blank. The offset form of
37
+ * {@link hasBlankLineAbove}, for callers that must locate the blank line
38
+ * (e.g. to test whether it falls inside a preceding scalar token's span).
39
+ */
40
+ function blankLineAboveStart(text, offset) {
41
+ const lineBreak = text.lastIndexOf("\n", Math.max(0, offset - 1));
42
+ if (lineBreak < 0) return -1;
43
+ const prevBreak = text.lastIndexOf("\n", lineBreak - 1);
44
+ return text.slice(prevBreak + 1, lineBreak).trim() === "" ? prevBreak + 1 : -1;
45
+ }
46
+ /**
47
+ * The deepest trailing scalar reached by descending last-child edges from
48
+ * `node` (a map's last pair's value, a seq's last item) — the node whose
49
+ * token span a textually-following blank line could fall inside.
50
+ */
51
+ function deepestTrailingScalar(node) {
52
+ let current = node;
53
+ for (;;) {
54
+ if (current instanceof YamlScalar) return current;
55
+ if (current instanceof YamlMap) {
56
+ const last = current.items[current.items.length - 1];
57
+ if (last === void 0) return void 0;
58
+ current = last.value ?? last.key;
59
+ continue;
60
+ }
61
+ if (current instanceof YamlSeq) {
62
+ const last = current.items[current.items.length - 1];
63
+ if (last === void 0) return void 0;
64
+ current = last;
65
+ continue;
66
+ }
67
+ return;
68
+ }
69
+ }
70
+ /**
71
+ * True when the blank line immediately above `offset` is CONTENT of the
72
+ * preceding keep-chomp block scalar, not a stylistic blank. Under `+`
73
+ * chomping the trailing line breaks are part of the scalar's VALUE (the
74
+ * composer already stores them there), so recording the same blank line as
75
+ * `spaceBefore` (or a leading-blank embed on a terminal comment run) would
76
+ * double-count it: emission renders both the kept newline and the stylistic
77
+ * blank, growing the document on every format pass. Clip/strip scalars are
78
+ * deliberately NOT excluded — their emission drops the trailing blank from
79
+ * the value, so the `spaceBefore` capture is exactly what re-creates it.
80
+ *
81
+ * `prev` is the last composed node before `offset` (a pair's value, a seq
82
+ * item); the check descends to its deepest trailing scalar and requires the
83
+ * blank line to start inside that scalar's token span.
84
+ */
85
+ function blankAboveIsKeepChompContent(text, offset, prev) {
86
+ if (prev === void 0) return false;
87
+ const scalar = deepestTrailingScalar(prev);
88
+ if (scalar === void 0 || scalar.chomp !== "keep") return false;
89
+ if (scalar.style !== "block-literal" && scalar.style !== "block-folded") return false;
90
+ const start = blankLineAboveStart(text, offset);
91
+ return start >= scalar.offset && start < scalar.offset + scalar.length;
92
+ }
93
+ /**
94
+ * True when the line immediately below the line containing `offset` is blank
95
+ * (empty or horizontal whitespace only) AND is itself followed by another
96
+ * line. Purely local, the mirror of {@link hasBlankLineAbove} — used to embed
97
+ * a blank line AFTER a comment run as a trailing empty line in the stored
98
+ * comment string.
99
+ */
100
+ function hasBlankLineBelow(text, offset) {
101
+ const lineEnd = text.indexOf("\n", Math.max(0, offset));
102
+ if (lineEnd < 0) return false;
103
+ const nextEnd = text.indexOf("\n", lineEnd + 1);
104
+ if (nextEnd < 0) return false;
105
+ return text.slice(lineEnd + 1, nextEnd).trim() === "";
106
+ }
107
+ /**
108
+ * The stored text of a comment token: the RAW post-`#` slice, reference
109
+ * parity with the `yaml` npm package — `# section` stores `" section"`,
110
+ * `#no-space` stores `"no-space"`, `# aligned` keeps its alignment.
111
+ *
112
+ * The one reserved string is `""`, which encodes an embedded blank line
113
+ * inside a joined comment run — so a spaces-only raw slice stores with ONE
114
+ * extra trailing space and the renderers strip it back off. A bare `#`
115
+ * (raw `""`) stores `" "`, `# ` (raw `" "`) stores `" "`, and so on; the
116
+ * escape is injective, so every comment spelling roundtrips byte-intact.
117
+ * Raw storage is what makes byte-intact roundtrip possible; trimming would
118
+ * canonicalize every comment to `# text`.
119
+ */
120
+ function rawCommentText(source) {
121
+ const raw = source.startsWith("#") ? source.slice(1) : source;
122
+ return /^ *$/.test(raw) ? `${raw} ` : raw;
123
+ }
124
+ /** Join two optional comment blocks with a newline. */
125
+ function joinComments(a, b) {
126
+ return a === void 0 ? b : `${a}\n${b}`;
127
+ }
128
+ /** Zero-based column of `offset` within its line. */
129
+ function columnAt(text, offset) {
130
+ if (offset <= 0) return 0;
131
+ return offset - (text.lastIndexOf("\n", offset - 1) + 1);
132
+ }
133
+ /**
134
+ * Rebuild `node` with the given comment fields merged in (existing fields are
135
+ * kept unless overridden; `commentBefore`/`comment` join with a newline when
136
+ * both sides are present). Aliases are returned unchanged — they carry no
137
+ * comment fields.
138
+ */
139
+ function withCommentFields(node, fields) {
140
+ if (node instanceof YamlScalar) return new YamlScalar({
141
+ value: node.value,
142
+ style: node.style,
143
+ ...node.tag !== void 0 ? { tag: node.tag } : {},
144
+ ...node.anchor !== void 0 ? { anchor: node.anchor } : {},
145
+ ...mergedCommentFields(node, fields),
146
+ ...node.chomp !== void 0 ? { chomp: node.chomp } : {},
147
+ ...node.blockIndent !== void 0 ? { blockIndent: node.blockIndent } : {},
148
+ ...node.raw !== void 0 ? { raw: node.raw } : {},
149
+ ...node.sourceMultiline !== void 0 ? { sourceMultiline: node.sourceMultiline } : {},
150
+ offset: node.offset,
151
+ length: node.length
152
+ });
153
+ if (node instanceof YamlMap) return new YamlMap({
154
+ items: node.items,
155
+ style: node.style,
156
+ ...node.tag !== void 0 ? { tag: node.tag } : {},
157
+ ...node.anchor !== void 0 ? { anchor: node.anchor } : {},
158
+ ...mergedCommentFields(node, fields),
159
+ ...node.sourceMultiline !== void 0 ? { sourceMultiline: node.sourceMultiline } : {},
160
+ offset: node.offset,
161
+ length: node.length
162
+ });
163
+ if (node instanceof YamlSeq) return new YamlSeq({
164
+ items: node.items,
165
+ style: node.style,
166
+ ...node.tag !== void 0 ? { tag: node.tag } : {},
167
+ ...node.anchor !== void 0 ? { anchor: node.anchor } : {},
168
+ ...mergedCommentFields(node, fields),
169
+ ...node.sourceMultiline !== void 0 ? { sourceMultiline: node.sourceMultiline } : {},
170
+ offset: node.offset,
171
+ length: node.length
172
+ });
173
+ return node;
174
+ }
175
+ function mergedCommentFields(existing, incoming) {
176
+ const commentBefore = incoming.commentBefore !== void 0 ? existing.commentBefore !== void 0 ? `${existing.commentBefore}\n${incoming.commentBefore}` : incoming.commentBefore : existing.commentBefore;
177
+ const comment = incoming.comment !== void 0 ? existing.comment !== void 0 ? `${existing.comment}\n${incoming.comment}` : incoming.comment : existing.comment;
178
+ const spaceBefore = incoming.spaceBefore ?? existing.spaceBefore;
179
+ return {
180
+ ...commentBefore !== void 0 ? { commentBefore } : {},
181
+ ...comment !== void 0 ? { comment } : {},
182
+ ...spaceBefore !== void 0 ? { spaceBefore } : {}
183
+ };
184
+ }
185
+
186
+ //#endregion
187
+ export { blankAboveIsKeepChompContent, blankLineAboveStart, columnAt, hasBlankLineAbove, hasBlankLineBelow, isOwnLineAt, joinComments, rawCommentText, sameLineSpan, withCommentFields };
@@ -1,7 +1,8 @@
1
1
  import { YamlAlias, YamlMap, YamlPair, YamlScalar, YamlSeq } from "../../YamlNode.js";
2
- import { checkAnchorOnAlias, getAnchorName, makeAlias, registerAnchor } from "./anchors.js";
3
2
  import { parseCSTAll } from "../cst-parser.js";
4
- import { clearMeta, createState, hasMeta, sameLine } from "./state.js";
3
+ import { checkAnchorOnAlias, getAnchorName, makeAlias, registerAnchor } from "./anchors.js";
4
+ import { hasBlankLineAbove, hasBlankLineBelow, rawCommentText } from "./comments.js";
5
+ import { clearMeta, commentProps, createState, hasMeta, sameLine } from "./state.js";
5
6
  import { parseDirective, validateTagHandlesInDocument } from "./tags.js";
6
7
  import { collectMultilinePlainScalar, findNextContentChild, getScalarStyle, hasBlockMapAfterInList, hasValueSepAfter, indexOfChild, makeScalar, resolveScalar } from "./scalars.js";
7
8
  import { composeBlockMap, composeBlockSeq, composeFlatBlockMap } from "./block.js";
@@ -131,6 +132,9 @@ function composeDocument(cst, state, hasSubsequentDocuments = false, nextDocCst)
131
132
  const directives = [];
132
133
  let contents = null;
133
134
  let documentComment;
135
+ let documentCommentAfter;
136
+ let lastLeadingCommentOffset = -1;
137
+ let sawDocEndMarker = false;
134
138
  const hasDocStart = children.some((c) => c.type === "whitespace" && c.source === "---");
135
139
  let i = 0;
136
140
  const meta = {};
@@ -166,6 +170,7 @@ function composeDocument(cst, state, hasSubsequentDocuments = false, nextDocCst)
166
170
  }
167
171
  if (child.type === "newline" && hasMeta(meta)) sawNewlineSinceMeta = true;
168
172
  if (child.type === "whitespace" || child.type === "newline") {
173
+ if (child.type === "whitespace" && child.source === "...") sawDocEndMarker = true;
169
174
  if (child.type === "whitespace" && (child.source === "]" || child.source === "}")) state.errors.push({
170
175
  code: "MalformedFlowCollection",
171
176
  message: `Unexpected flow indicator '${child.source}' at document level`,
@@ -175,8 +180,18 @@ function composeDocument(cst, state, hasSubsequentDocuments = false, nextDocCst)
175
180
  i++;
176
181
  continue;
177
182
  }
178
- if (child.type === "comment" && contents === null) {
179
- documentComment = child.source.startsWith("#") ? child.source.slice(1).trim() : child.source;
183
+ if (child.type === "comment" && contents === null && !sawDocEndMarker) {
184
+ const text = rawCommentText(child.source);
185
+ if (documentComment !== void 0 && hasBlankLineAbove(state.text, child.offset)) documentComment = `${documentComment}\n`;
186
+ documentComment = documentComment === void 0 ? text : `${documentComment}\n${text}`;
187
+ lastLeadingCommentOffset = child.offset;
188
+ i++;
189
+ continue;
190
+ }
191
+ if (child.type === "comment") {
192
+ const text = rawCommentText(child.source);
193
+ if (documentCommentAfter !== void 0 && hasBlankLineAbove(state.text, child.offset)) documentCommentAfter = `${documentCommentAfter}\n`;
194
+ documentCommentAfter = documentCommentAfter === void 0 ? text : `${documentCommentAfter}\n${text}`;
180
195
  i++;
181
196
  continue;
182
197
  }
@@ -395,6 +410,8 @@ function composeDocument(cst, state, hasSubsequentDocuments = false, nextDocCst)
395
410
  break;
396
411
  }
397
412
  }
413
+ if (documentComment !== void 0 && contents !== null && hasBlankLineBelow(state.text, lastLeadingCommentOffset)) documentComment = `${documentComment}\n`;
414
+ state.escapedComments.length = 0;
398
415
  return {
399
416
  contents,
400
417
  errors: [...state.errors],
@@ -403,7 +420,8 @@ function composeDocument(cst, state, hasSubsequentDocuments = false, nextDocCst)
403
420
  hasDocumentStart: hasDocStart,
404
421
  hasDocumentEnd: hasDocEnd,
405
422
  hasDocumentStartTab: hasDocStartTab,
406
- ...documentComment !== void 0 ? { comment: documentComment } : {}
423
+ ...documentComment !== void 0 ? { comment: documentComment } : {},
424
+ ...documentCommentAfter !== void 0 ? { commentAfter: documentCommentAfter } : {}
407
425
  };
408
426
  }
409
427
  /**
@@ -568,8 +586,9 @@ function decorateSourceMultiline(node, text) {
568
586
  style: node.style,
569
587
  ...node.tag !== void 0 ? { tag: node.tag } : {},
570
588
  ...node.anchor !== void 0 ? { anchor: node.anchor } : {},
571
- ...node.comment !== void 0 ? { comment: node.comment } : {},
589
+ ...commentProps(node),
572
590
  ...node.chomp !== void 0 ? { chomp: node.chomp } : {},
591
+ ...node.blockIndent !== void 0 ? { blockIndent: node.blockIndent } : {},
573
592
  ...node.raw !== void 0 ? { raw: node.raw } : {},
574
593
  sourceMultiline: true,
575
594
  offset: node.offset,
@@ -580,7 +599,7 @@ function decorateSourceMultiline(node, text) {
580
599
  const newItems = node.items.map((pair) => new YamlPair({
581
600
  key: decorateSourceMultiline(pair.key, text) ?? pair.key,
582
601
  value: pair.value === null ? null : decorateSourceMultiline(pair.value, text),
583
- ...pair.comment !== void 0 ? { comment: pair.comment } : {}
602
+ ...commentProps(pair)
584
603
  }));
585
604
  const multiline = isSourceMultiline(text, node.offset, node.length);
586
605
  return new YamlMap({
@@ -588,7 +607,7 @@ function decorateSourceMultiline(node, text) {
588
607
  style: node.style,
589
608
  ...node.tag !== void 0 ? { tag: node.tag } : {},
590
609
  ...node.anchor !== void 0 ? { anchor: node.anchor } : {},
591
- ...node.comment !== void 0 ? { comment: node.comment } : {},
610
+ ...commentProps(node),
592
611
  ...multiline ? { sourceMultiline: true } : {},
593
612
  offset: node.offset,
594
613
  length: node.length
@@ -602,7 +621,7 @@ function decorateSourceMultiline(node, text) {
602
621
  style: node.style,
603
622
  ...node.tag !== void 0 ? { tag: node.tag } : {},
604
623
  ...node.anchor !== void 0 ? { anchor: node.anchor } : {},
605
- ...node.comment !== void 0 ? { comment: node.comment } : {},
624
+ ...commentProps(node),
606
625
  ...multiline ? { sourceMultiline: true } : {},
607
626
  offset: node.offset,
608
627
  length: node.length
@@ -619,6 +638,7 @@ function decorateDocumentSourceMultiline(doc, text) {
619
638
  warnings: doc.warnings,
620
639
  directives: doc.directives,
621
640
  ...doc.comment !== void 0 ? { comment: doc.comment } : {},
641
+ ...doc.commentAfter !== void 0 ? { commentAfter: doc.commentAfter } : {},
622
642
  hasDocumentStart: doc.hasDocumentStart,
623
643
  hasDocumentEnd: doc.hasDocumentEnd,
624
644
  hasDocumentStartTab: doc.hasDocumentStartTab
@@ -641,12 +661,29 @@ const EMPTY_DOCUMENT = {
641
661
  * state and therefore appear in the returned document's `errors`.
642
662
  */
643
663
  function composeFirstDocument(text, options) {
664
+ return composeFirstDocumentCounted(text, options).document;
665
+ }
666
+ /**
667
+ * {@link composeFirstDocument} plus the CST-level document count of the whole
668
+ * stream, from the single CST parse the compose already performs — no second
669
+ * walk, and correct where a regex on `---` is not (a `---` inside a block
670
+ * scalar or quoted string is content, not a document marker). Callers that
671
+ * must refuse multi-document input (the `YamlFormat` single-document
672
+ * contract) read `documentCount` instead of re-parsing.
673
+ */
674
+ function composeFirstDocumentCounted(text, options) {
644
675
  const cstNodes = parseCSTAll(text);
645
676
  const state = createState(text, FLOW, options);
646
677
  validateCrossDocumentDirectives(cstNodes, state);
647
678
  const doc = cstNodes[0];
648
- if (!doc) return EMPTY_DOCUMENT;
649
- return decorateDocumentSourceMultiline(composeDocument(doc, state, cstNodes.length > 1, cstNodes[1]), text);
679
+ if (!doc) return {
680
+ document: EMPTY_DOCUMENT,
681
+ documentCount: 0
682
+ };
683
+ return {
684
+ document: decorateDocumentSourceMultiline(composeDocument(doc, state, cstNodes.length > 1, cstNodes[1]), text),
685
+ documentCount: cstNodes.length
686
+ };
650
687
  }
651
688
  /**
652
689
  * Compose every document of `text` with full error recovery — v3
@@ -674,4 +711,4 @@ function composeAllDocuments(text, options) {
674
711
  }
675
712
 
676
713
  //#endregion
677
- export { composeAllDocuments, composeDocument, composeFirstDocument, validateCrossDocumentDirectives };
714
+ export { EMPTY_DOCUMENT, composeAllDocuments, composeDocument, composeFirstDocument, composeFirstDocumentCounted, validateCrossDocumentDirectives };
@@ -1,5 +1,6 @@
1
1
  import { YamlMap, YamlScalar, YamlSeq } from "../../YamlNode.js";
2
2
  import { checkAnchorOnAlias, getAnchorName, makeAlias, registerAnchor } from "./anchors.js";
3
+ import { blankLineAboveStart, hasBlankLineAbove, isOwnLineAt, joinComments, rawCommentText, withCommentFields } from "./comments.js";
3
4
  import { enterNesting, exitNesting, hasMeta } from "./state.js";
4
5
  import { collectMultilineKey, collectMultilinePlainScalar, getScalarStyle, hasValueSepThroughPlainScalars, makeScalar, resolveScalar } from "./scalars.js";
5
6
  import { buildPairs, checkDuplicateKeys, checkMultilineImplicitKeys } from "./block.js";
@@ -117,8 +118,9 @@ function composeFlowMapInner(cst, state, meta, parentBlockColumn) {
117
118
  validateFlowContentIndent(cst, state, parentBlockColumn);
118
119
  validateFlowSeparators(children, state, "{", "}");
119
120
  const items = flattenFlowChildren(children.filter((c) => !(c.type === "whitespace" && (c.source === "{" || c.source === "}" || c.source.trim() === ""))), state);
120
- buildPairs(items, pairs, state.text);
121
+ const trailingComment = buildPairs(items, pairs, state.text);
121
122
  if (state.options.uniqueKeys) checkDuplicateKeys(pairs, state);
123
+ const mapComment = meta?.comment !== void 0 ? trailingComment !== void 0 ? `${meta.comment}\n${trailingComment}` : meta.comment : trailingComment;
122
124
  const map = new YamlMap({
123
125
  items: pairs,
124
126
  style: "flow",
@@ -126,7 +128,7 @@ function composeFlowMapInner(cst, state, meta, parentBlockColumn) {
126
128
  length: cst.length,
127
129
  ...meta?.tag !== void 0 ? { tag: meta.tag } : {},
128
130
  ...meta?.anchor !== void 0 ? { anchor: meta.anchor } : {},
129
- ...meta?.comment !== void 0 ? { comment: meta.comment } : {}
131
+ ...mapComment !== void 0 ? { comment: mapComment } : {}
130
132
  });
131
133
  if (meta?.anchor) registerAnchor(map, meta.anchor, state, cst.offset);
132
134
  return map;
@@ -188,7 +190,8 @@ function flattenFlowChildren(children, state) {
188
190
  if (child.type === "comment") {
189
191
  items.push({
190
192
  kind: "comment",
191
- comment: child.source.startsWith("#") ? child.source.slice(1).trim() : child.source
193
+ comment: rawCommentText(child.source),
194
+ offset: child.offset
192
195
  });
193
196
  continue;
194
197
  }
@@ -370,25 +373,95 @@ function composeFlowSeqInner(cst, state, meta, parentBlockColumn) {
370
373
  current.push(child);
371
374
  }
372
375
  if (current.length > 0) segments.push(current);
376
+ let seqTrailing;
377
+ let pending = [];
378
+ let pendingSpace = false;
379
+ let lastItemEnd = -1;
380
+ let pushedIdx = -1;
381
+ const joinPending = (parts) => {
382
+ if (parts.length === 0) return void 0;
383
+ let out = parts[0].text;
384
+ for (let k = 1; k < parts.length; k++) {
385
+ const part = parts[k];
386
+ out += part.blankAbove ? `\n\n${part.text}` : `\n${part.text}`;
387
+ }
388
+ return out;
389
+ };
390
+ const blankIsBetweenItems = (anchorOffset) => blankLineAboveStart(state.text, anchorOffset) >= lastItemEnd;
391
+ const acceptOwnLineComment = (cText, cOff) => {
392
+ const blankAbove = cOff >= 0 && hasBlankLineAbove(state.text, cOff);
393
+ if (blankAbove && pending.length === 0 && lastItemEnd >= 0 && blankIsBetweenItems(cOff)) pendingSpace = true;
394
+ pending.push({
395
+ text: cText,
396
+ offset: cOff,
397
+ blankAbove: blankAbove && pending.length > 0
398
+ });
399
+ };
400
+ /** The pending comment fields for the item being pushed, then reset. */
401
+ const takePendingFields = (anchorOffset) => {
402
+ let commentBefore = joinPending(pending);
403
+ if (anchorOffset >= 0 && hasBlankLineAbove(state.text, anchorOffset)) {
404
+ if (commentBefore !== void 0) commentBefore = `${commentBefore}\n`;
405
+ else if (!pendingSpace && lastItemEnd >= 0 && blankIsBetweenItems(anchorOffset)) pendingSpace = true;
406
+ }
407
+ const fields = {
408
+ ...commentBefore !== void 0 ? { commentBefore } : {},
409
+ ...pendingSpace ? { spaceBefore: true } : {}
410
+ };
411
+ pending = [];
412
+ pendingSpace = false;
413
+ return fields;
414
+ };
415
+ const hasPendingFields = (fields) => fields.commentBefore !== void 0 || fields.spaceBefore === true;
373
416
  for (const segment of segments) if (segment.some((c) => c.type === "whitespace" && c.source === ":")) {
374
- const semItems = flattenFlowChildren(segment.filter((c) => !(c.type === "whitespace" && c.source.trim() === "")), state);
417
+ const content = segment.filter((c) => !(c.type === "whitespace" && c.source.trim() === ""));
418
+ const semItems = flattenFlowChildren(content, state);
375
419
  const pairs = [];
376
- buildPairs(semItems, pairs, state.text);
420
+ const segTrailing = buildPairs(semItems, pairs, state.text);
377
421
  if (!segment.some((c) => c.type === "whitespace" && c.source === "?")) checkMultilineImplicitKeys(pairs, state, semItems);
378
422
  const firstPair = pairs[0];
379
423
  if (firstPair) {
424
+ const segFirst = content[0];
425
+ const fields = takePendingFields(segFirst !== void 0 ? segFirst.offset : -1);
380
426
  const map = new YamlMap({
381
427
  items: pairs,
382
428
  style: "flow",
383
429
  offset: firstPair.key.offset,
384
- length: 0
430
+ length: 0,
431
+ ...fields,
432
+ ...segTrailing !== void 0 ? { comment: segTrailing } : {}
385
433
  });
386
434
  items.push(map);
387
- }
435
+ pushedIdx = items.length - 1;
436
+ lastItemEnd = map.offset + map.length;
437
+ } else if (segTrailing !== void 0) seqTrailing = joinComments(seqTrailing, segTrailing);
388
438
  } else {
389
439
  const semItems = flattenFlowChildren(segment.filter((c) => !(c.type === "whitespace" && c.source.trim() === "")), state);
390
- for (const si of semItems) if (si.kind === "node" && si.node) items.push(si.node);
440
+ for (const si of semItems) {
441
+ if (si.kind === "comment") {
442
+ const cText = si.comment ?? "";
443
+ if (!(si.offset === void 0 ? true : isOwnLineAt(state.text, si.offset)) && pushedIdx >= 0) {
444
+ const prev = items[pushedIdx];
445
+ if (prev) items[pushedIdx] = withCommentFields(prev, { comment: cText });
446
+ } else acceptOwnLineComment(cText, si.offset ?? -1);
447
+ continue;
448
+ }
449
+ if (si.kind === "node" && si.node) {
450
+ const fields = takePendingFields(si.node.length > 0 ? si.node.offset : -1);
451
+ const node = hasPendingFields(fields) ? withCommentFields(si.node, fields) : si.node;
452
+ items.push(node);
453
+ pushedIdx = items.length - 1;
454
+ lastItemEnd = si.node.offset + si.node.length;
455
+ }
456
+ }
457
+ }
458
+ if (pending.length > 0) {
459
+ const joined = joinPending(pending);
460
+ const first = pending[0];
461
+ const withLead = joined !== void 0 && first !== void 0 && lastItemEnd >= 0 && first.offset >= 0 && hasBlankLineAbove(state.text, first.offset) ? `\n${joined}` : joined;
462
+ if (withLead !== void 0) seqTrailing = joinComments(seqTrailing, withLead);
391
463
  }
464
+ const seqComment = meta?.comment !== void 0 ? seqTrailing !== void 0 ? `${meta.comment}\n${seqTrailing}` : meta.comment : seqTrailing;
392
465
  const seq = new YamlSeq({
393
466
  items,
394
467
  style: "flow",
@@ -396,7 +469,7 @@ function composeFlowSeqInner(cst, state, meta, parentBlockColumn) {
396
469
  length: cst.length,
397
470
  ...meta?.tag !== void 0 ? { tag: meta.tag } : {},
398
471
  ...meta?.anchor !== void 0 ? { anchor: meta.anchor } : {},
399
- ...meta?.comment !== void 0 ? { comment: meta.comment } : {}
472
+ ...seqComment !== void 0 ? { comment: seqComment } : {}
400
473
  });
401
474
  if (meta?.anchor) registerAnchor(seq, meta.anchor, state, cst.offset);
402
475
  return seq;
@@ -1,5 +1,6 @@
1
1
  import { YamlScalar } from "../../YamlNode.js";
2
2
  import { registerAnchor } from "./anchors.js";
3
+ import { rawCommentText } from "./comments.js";
3
4
  import { lineCol } from "./state.js";
4
5
  import { resolveTagHandle } from "./tags.js";
5
6
 
@@ -98,13 +99,42 @@ function getScalarStyle(node) {
98
99
  */
99
100
  function getBlockChomp(node) {
100
101
  if (node.type !== "block-scalar") return void 0;
101
- const src = node.source.trimStart();
102
- const headerEnd = src.indexOf("\n");
103
- const header = headerEnd === -1 ? src : src.slice(0, headerEnd);
104
- if (header.includes("+")) return "keep";
105
- if (header.includes("-")) return "strip";
102
+ const indicators = node.source.trimStart().match(/^[|>]([0-9+-]*)/)?.[1] ?? "";
103
+ if (indicators.includes("+")) return "keep";
104
+ if (indicators.includes("-")) return "strip";
106
105
  return "clip";
107
106
  }
107
+ /**
108
+ * Extracts the EXPLICIT indentation-indicator digit from a block scalar's
109
+ * header (`|2`, `>1+`), or undefined when the header carries none (the
110
+ * reader auto-detects) or the node is not a block scalar. Inspects only the
111
+ * indicator run — see {@link getBlockChomp} for why the rest of the header
112
+ * line (a comment can contain digits) must not be read.
113
+ */
114
+ function getBlockIndent(node) {
115
+ if (node.type !== "block-scalar") return void 0;
116
+ const digit = (node.source.trimStart().match(/^[|>]([0-9+-]*)/)?.[1] ?? "").match(/[1-9]/)?.[0];
117
+ return digit === void 0 ? void 0 : Number(digit);
118
+ }
119
+ /**
120
+ * The trailing comment on a block scalar's HEADER line (`key: | # c`,
121
+ * `- >-2 # c`) as stored raw-slice text, or `undefined` when the header
122
+ * carries none. The header is the only line of a block scalar that can carry
123
+ * a `#` comment — body lines absorb `#` as content — and the lexer packs the
124
+ * whole scalar (header comment included) into one CST token, so the comment
125
+ * never surfaces as a sibling comment token for the ordinary attribution
126
+ * pass (#341).
127
+ */
128
+ function blockScalarHeaderComment(cst) {
129
+ const src = cst.source.trimStart();
130
+ const nl = src.search(/[\r\n]/);
131
+ const header = nl < 0 ? src : src.slice(0, nl);
132
+ const hash = header.indexOf("#");
133
+ if (hash < 1) return void 0;
134
+ const before = header[hash - 1];
135
+ if (before !== " " && before !== " ") return void 0;
136
+ return rawCommentText(header.slice(hash));
137
+ }
108
138
  function getScalarValue(node, fullText) {
109
139
  if (node.type === "block-scalar") return decodeBlockScalar(node.source, fullText, node.offset);
110
140
  const style = getScalarStyle(node);
@@ -790,18 +820,22 @@ function decodeBlockScalar(raw, fullText, nodeOffset) {
790
820
  }
791
821
  if (hadContent || trailingNewlines.length > 0) {
792
822
  if (chomp === "keep") {
793
- result += "\n";
823
+ if (hadContent) result += "\n";
794
824
  for (const _nl of trailingNewlines) result += "\n";
795
- } else if (chomp !== "strip") result += "\n";
825
+ } else if (chomp !== "strip") {
826
+ if (hadContent) result += "\n";
827
+ }
796
828
  }
797
829
  value = result;
798
830
  } else {
799
831
  value = lines.join("\n");
800
832
  if (lines.length > 0 || trailingNewlines.length > 0) {
801
833
  if (chomp === "keep") {
802
- value += "\n";
834
+ if (lines.length > 0) value += "\n";
803
835
  for (const _nl of trailingNewlines) value += "\n";
804
- } else if (chomp !== "strip") value += "\n";
836
+ } else if (chomp !== "strip") {
837
+ if (lines.length > 0) value += "\n";
838
+ }
805
839
  }
806
840
  }
807
841
  return value;
@@ -860,6 +894,8 @@ function makeScalar(cst, state, meta) {
860
894
  const rawValue = getScalarValue(cst, state.text);
861
895
  const value = resolveScalar(rawValue, style, meta?.tag, state);
862
896
  const chomp = getBlockChomp(cst);
897
+ const blockIndent = getBlockIndent(cst);
898
+ const comment = (style === "block-literal" || style === "block-folded" ? blockScalarHeaderComment(cst) : void 0) ?? meta?.comment;
863
899
  const needsRaw = style === "plain" && typeof value !== "string" && value !== void 0 && shouldPreserveRaw(rawValue, value);
864
900
  const scalar = new YamlScalar({
865
901
  value,
@@ -868,8 +904,9 @@ function makeScalar(cst, state, meta) {
868
904
  length: cst.length,
869
905
  ...meta?.tag !== void 0 ? { tag: meta.tag } : {},
870
906
  ...meta?.anchor !== void 0 ? { anchor: meta.anchor } : {},
871
- ...meta?.comment !== void 0 ? { comment: meta.comment } : {},
907
+ ...comment !== void 0 ? { comment } : {},
872
908
  ...chomp !== void 0 ? { chomp } : {},
909
+ ...blockIndent !== void 0 ? { blockIndent } : {},
873
910
  ...needsRaw ? { raw: rawValue } : {}
874
911
  });
875
912
  if (meta?.anchor) registerAnchor(scalar, meta.anchor, state, cst.offset);
@@ -894,4 +931,4 @@ function shouldPreserveRaw(rawValue, value) {
894
931
  }
895
932
 
896
933
  //#endregion
897
- export { blockMapStartsWithValueSep, classifyPlainNumeric, collectMultilineKey, collectMultilinePlainScalar, findFirstContent, findLastContent, findNextContentChild, findNextContentInList, findNextSignificantChild, findValueSepOffset, foldFlowLines, getBlockChomp, getScalarStyle, getScalarValue, hasBlockMapAfterInList, hasValueSepAfter, hasValueSepAfterInList, hasValueSepBetween, hasValueSepThroughPlainScalars, indexOfChild, makeScalar, resolveScalar, shouldPreserveRaw };
934
+ export { blockMapStartsWithValueSep, classifyPlainNumeric, collectMultilineKey, collectMultilinePlainScalar, findFirstContent, findLastContent, findNextContentChild, findNextContentInList, findNextSignificantChild, findValueSepOffset, foldFlowLines, getBlockChomp, getBlockIndent, getScalarStyle, getScalarValue, hasBlockMapAfterInList, hasValueSepAfter, hasValueSepAfterInList, hasValueSepBetween, hasValueSepThroughPlainScalars, indexOfChild, makeScalar, resolveScalar, shouldPreserveRaw };
@@ -63,6 +63,19 @@ function clearMeta(m) {
63
63
  delete m.tag;
64
64
  delete m.comment;
65
65
  }
66
+ /**
67
+ * The comment-fidelity field triple carried by the four public node classes
68
+ * (and `YamlPair`). Conditional-spread helper so AST rebuild sites copy all
69
+ * three without hand-maintaining the list — and never emit an explicit
70
+ * `undefined` into a v4 `optionalKey` field.
71
+ */
72
+ function commentProps(n) {
73
+ return {
74
+ ...n.commentBefore !== void 0 ? { commentBefore: n.commentBefore } : {},
75
+ ...n.comment !== void 0 ? { comment: n.comment } : {},
76
+ ...n.spaceBefore !== void 0 ? { spaceBefore: n.spaceBefore } : {}
77
+ };
78
+ }
66
79
  function createState(text, flow, options) {
67
80
  return {
68
81
  text,
@@ -77,7 +90,8 @@ function createState(text, flow, options) {
77
90
  },
78
91
  tagMap: /* @__PURE__ */ new Map(),
79
92
  flow,
80
- depth: 0
93
+ depth: 0,
94
+ escapedComments: []
81
95
  };
82
96
  }
83
97
  /**
@@ -114,4 +128,4 @@ function exitNesting(state) {
114
128
  }
115
129
 
116
130
  //#endregion
117
- export { clearMeta, createState, enterNesting, exitNesting, getLineStarts, hasMeta, hasNonWhitespaceBeforeOnLine, lineCol, lineIndentColumn, sameLine };
131
+ export { clearMeta, commentProps, createState, enterNesting, exitNesting, getLineStarts, hasMeta, hasNonWhitespaceBeforeOnLine, lineCol, lineIndentColumn, sameLine };
@@ -365,7 +365,8 @@ function parseBlockValue(state, parentIndent, explicitKey = false) {
365
365
  const token = peek(state);
366
366
  if (!token) break;
367
367
  if (token.kind === "whitespace") {
368
- nodes.push(...consumeTrivia(state));
368
+ advance(state);
369
+ nodes.push(makeLeafNode("whitespace", token, state.text));
369
370
  continue;
370
371
  }
371
372
  if (token.kind === "newline") break;
@@ -43,14 +43,23 @@ const YAML_COMPOSE_ERROR_CODES = [
43
43
  */
44
44
  const YAML_STRINGIFY_ERROR_CODES = ["CircularReference"];
45
45
  /**
46
- * Error codes for the modify stage (`YamlFormat.modify`'s path navigation
47
- * against an already-composed AST). Not raised by the parser/composer.
46
+ * Error codes for the modify stage (`YamlFormat.modify`'s guards and path
47
+ * navigation against an already-composed AST). Not raised by the
48
+ * parser/composer. `MultiDocumentStream` is the single-document-contract
49
+ * refusal: `modify` re-emits exactly one document, so a multi-document
50
+ * stream fails typed rather than silently truncating documents 2..n.
51
+ * `DirectiveCarryingDocument` is the directive refusal: the stringifier does
52
+ * not re-emit `%YAML`/`%TAG` directive lines, and re-emitting a document
53
+ * without its `%TAG` orphans every shorthand tag that depends on it — the
54
+ * output would be unparseable — so modify fails typed rather than corrupting.
48
55
  */
49
56
  const YAML_MODIFY_ERROR_CODES = [
50
57
  "EmptyDocument",
51
58
  "PathNotFound",
52
59
  "InvalidIndex",
53
- "NotNavigable"
60
+ "NotNavigable",
61
+ "MultiDocumentStream",
62
+ "DirectiveCarryingDocument"
54
63
  ];
55
64
  /**
56
65
  * The single source of truth for which diagnostic codes are fatal to a