@effected/yaml 0.6.1 → 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 (40) hide show
  1. package/README.md +78 -7
  2. package/Yaml.js +87 -39
  3. package/YamlDocument.js +25 -6
  4. package/YamlFormat.js +171 -20
  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 +77 -28
  18. package/internal/composer/state.js +16 -2
  19. package/internal/cst-parser.js +11 -2
  20. package/internal/diagnostics.js +12 -3
  21. package/internal/fold.js +22 -10
  22. package/internal/lexer.js +23 -17
  23. package/internal/rules/catalog.js +39 -0
  24. package/internal/rules/colon-spacing.js +77 -0
  25. package/internal/rules/comments-spacing.js +68 -0
  26. package/internal/rules/document-end.js +65 -0
  27. package/internal/rules/document-start.js +64 -0
  28. package/internal/rules/empty-lines.js +70 -0
  29. package/internal/rules/eof-newline.js +33 -0
  30. package/internal/rules/hyphen-spacing.js +52 -0
  31. package/internal/rules/indentation.js +103 -0
  32. package/internal/rules/key-duplicates.js +49 -0
  33. package/internal/rules/line-length.js +36 -0
  34. package/internal/rules/parse-validity.js +33 -0
  35. package/internal/rules/quoted-strings.js +81 -0
  36. package/internal/rules/trailing-spaces.js +39 -0
  37. package/internal/rules/truthy.js +84 -0
  38. package/internal/rules/util.js +92 -0
  39. package/internal/stringifier.js +285 -69
  40. package/package.json +4 -3
package/YamlFormat.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { YamlMap, YamlPair, YamlScalar, YamlSeq } from "./YamlNode.js";
2
- import { composeFirstDocument } from "./internal/composer/document.js";
2
+ import { EMPTY_DOCUMENT, composeAllDocuments, composeFirstDocumentCounted } from "./internal/composer/document.js";
3
3
  import { isFatalCode } from "./internal/diagnostics.js";
4
4
  import { stringifyDocument, stripNodeComments } from "./internal/stringifier.js";
5
5
  import { YamlDiagnostic } from "./YamlDiagnostic.js";
@@ -41,13 +41,20 @@ var YamlFormattingOptions = class extends Schema.Class("YamlFormattingOptions")(
41
41
  }) {};
42
42
  /**
43
43
  * Raised when `YamlFormat.modify` cannot navigate the requested path against
44
- * the composed AST (a structural mismatch) or the source fails to parse.
44
+ * the composed AST (a structural mismatch), the source fails to parse, the
45
+ * source is a multi-document stream (`MultiDocumentStream` — a path names no
46
+ * particular document of a stream, so modify refuses rather than guessing),
47
+ * or the document carries `%YAML`/`%TAG` directives
48
+ * (`DirectiveCarryingDocument` — modify does not re-emit directive lines, and
49
+ * dropping a `%TAG` would orphan the shorthand tags that depend on it).
45
50
  * Carries structured {@link YamlDiagnostic} entries — never a collapsed
46
- * `reason` string (the structure-preserving-errors house rule).
51
+ * `reason` string (the structure-preserving-errors house rule). The error
52
+ * itself has no `code` field: read the code from the diagnostics —
53
+ * `error.diagnostics[0].code` is the primary failure.
47
54
  *
48
55
  * @public
49
56
  */
50
- var YamlModificationError = class extends Schema.TaggedErrorClass()("YamlModificationError", {
57
+ var YamlModificationError = class extends Schema.TaggedError()("YamlModificationError", {
51
58
  path: Schema.Array(Schema.Union([Schema.String, Schema.Number])),
52
59
  diagnostics: Schema.Array(YamlDiagnostic)
53
60
  }) {
@@ -97,26 +104,77 @@ function definedFields(fields) {
97
104
  for (const key of Object.keys(fields)) if (fields[key] !== void 0) out[key] = fields[key];
98
105
  return out;
99
106
  }
100
- /**
101
- * Format a document via the parse → stringify round-trip and diff the
102
- * output against the source, returning `undefined` when the input has a
103
- * fatal parse error (never corrupt malformed input).
104
- */
105
- function formatDocument(text, options) {
106
- const doc = composeFirstDocument(text, {});
107
- if (doc.errors.some((e) => isFatalCode(e.code))) return void 0;
108
- const preserveComments = options?.preserveComments ?? true;
109
- const outputDoc = {
107
+ /** Rebuild a composed document for re-emission, stripping comments when asked. */
108
+ function toOutputDocument(doc, preserveComments) {
109
+ return {
110
110
  contents: !preserveComments && doc.contents !== null ? stripNodeComments(doc.contents) : doc.contents,
111
111
  errors: doc.errors,
112
112
  warnings: doc.warnings,
113
113
  directives: doc.directives,
114
- ...preserveComments ? definedFields({ comment: doc.comment }) : {},
114
+ ...preserveComments ? definedFields({
115
+ comment: doc.comment,
116
+ commentAfter: doc.commentAfter
117
+ }) : {},
115
118
  hasDocumentStart: doc.hasDocumentStart,
116
119
  hasDocumentEnd: doc.hasDocumentEnd,
117
120
  hasDocumentStartTab: doc.hasDocumentStartTab
118
121
  };
119
- return stringifyDocument(outputDoc, toStringifyInput(options));
122
+ }
123
+ /**
124
+ * Format a document via the parse → stringify round-trip and diff the
125
+ * output against the source, returning `undefined` when the input has a
126
+ * fatal parse error (never corrupt malformed input) or carries
127
+ * `%YAML`/`%TAG` directives — `stringifyDocument` does not re-emit
128
+ * directive lines, and dropping a `%TAG` while keeping the shorthand tags
129
+ * that depend on it would turn a valid document into an unparseable one.
130
+ * A multi-document stream routes to {@link formatStream}, which formats
131
+ * every document and shares the same directive refusal.
132
+ */
133
+ function formatDocument(text, options) {
134
+ const { documents, streamErrors } = composeAllDocuments(text, {});
135
+ if (documents.length > 1) return formatStream(documents, streamErrors, options);
136
+ if (streamErrors.some((e) => isFatalCode(e.code))) return void 0;
137
+ const doc = documents[0] ?? EMPTY_DOCUMENT;
138
+ if (doc.errors.some((e) => isFatalCode(e.code))) return void 0;
139
+ if (doc.directives.length > 0) return void 0;
140
+ return stringifyDocument(toOutputDocument(doc, options?.preserveComments ?? true), toStringifyInput(options));
141
+ }
142
+ /**
143
+ * Format a multi-document stream: every document is re-emitted in order with
144
+ * its own framing (`---`, `...`, leading/trailing comment blocks — all of
145
+ * which `stringifyDocument` renders per document), so no document is ever
146
+ * dropped. Receives the documents and stream errors from the ONE
147
+ * composition {@link formatDocument} already performed. Returns `undefined`
148
+ * — no edits — when the stream cannot be re-emitted faithfully:
149
+ *
150
+ * - any document (or the stream itself) carries a fatal diagnostic, the same
151
+ * posture as the single-document path;
152
+ * - any document carries `%YAML`/`%TAG` directives — `stringifyDocument`
153
+ * does not re-emit directive lines, and dropping a `%TAG` would change
154
+ * what the re-emitted document means.
155
+ *
156
+ * The single-document path ({@link formatDocument}) shares the directive
157
+ * refusal: re-emitting a directive-carrying document without its directive
158
+ * line while keeping the dependent shorthand tags produced unparseable
159
+ * output (probe-verified downstream against an independent oracle).
160
+ */
161
+ function formatStream(documents, streamErrors, options) {
162
+ if (streamErrors.some((e) => isFatalCode(e.code))) return void 0;
163
+ if (documents.some((d) => d.errors.some((e) => isFatalCode(e.code)))) return void 0;
164
+ if (documents.some((d) => d.directives.length > 0)) return void 0;
165
+ const preserveComments = options?.preserveComments ?? true;
166
+ const base = toStringifyInput(options);
167
+ const parts = [];
168
+ for (let i = 0; i < documents.length; i++) {
169
+ const doc = documents[i];
170
+ if (i > 0 && !doc.hasDocumentStart && !documents[i - 1].hasDocumentEnd) return void 0;
171
+ const docOptions = i < documents.length - 1 ? {
172
+ ...base,
173
+ finalNewline: true
174
+ } : base;
175
+ parts.push(stringifyDocument(toOutputDocument(doc, preserveComments), docOptions));
176
+ }
177
+ return parts.join("");
120
178
  }
121
179
  /** Convert a plain JS scalar value into a synthetic `YamlScalar` (offset/length are irrelevant — immediately re-stringified). */
122
180
  function jsValueToNode(value) {
@@ -151,7 +209,11 @@ function modifyNode(node, path, depth, value) {
151
209
  newItems[pairIndex] = YamlPair.make({
152
210
  key: oldPair.key,
153
211
  value: newValueNode,
154
- ...definedFields({ comment: oldPair.comment })
212
+ ...definedFields({
213
+ commentBefore: oldPair.commentBefore,
214
+ comment: oldPair.comment,
215
+ spaceBefore: oldPair.spaceBefore
216
+ })
155
217
  });
156
218
  return rebuildMap(node, newItems);
157
219
  }
@@ -175,7 +237,11 @@ function modifyNode(node, path, depth, value) {
175
237
  newItems[pairIndex] = YamlPair.make({
176
238
  key: pair.key,
177
239
  value: newValue,
178
- ...definedFields({ comment: pair.comment })
240
+ ...definedFields({
241
+ commentBefore: pair.commentBefore,
242
+ comment: pair.comment,
243
+ spaceBefore: pair.spaceBefore
244
+ })
179
245
  });
180
246
  return rebuildMap(node, newItems);
181
247
  }
@@ -206,7 +272,9 @@ function rebuildMap(node, items) {
206
272
  ...definedFields({
207
273
  tag: node.tag,
208
274
  anchor: node.anchor,
275
+ commentBefore: node.commentBefore,
209
276
  comment: node.comment,
277
+ spaceBefore: node.spaceBefore,
210
278
  sourceMultiline: node.sourceMultiline
211
279
  }),
212
280
  offset: node.offset,
@@ -220,7 +288,9 @@ function rebuildSeq(node, items) {
220
288
  ...definedFields({
221
289
  tag: node.tag,
222
290
  anchor: node.anchor,
291
+ commentBefore: node.commentBefore,
223
292
  comment: node.comment,
293
+ spaceBefore: node.spaceBefore,
224
294
  sourceMultiline: node.sourceMultiline
225
295
  }),
226
296
  offset: node.offset,
@@ -239,6 +309,24 @@ function rebuildSeq(node, items) {
239
309
  * `diagnostics: ReadonlyArray<YamlDiagnostic>`, never a collapsed `reason`
240
310
  * string.
241
311
  *
312
+ * `format`/`formatToString` handle multi-document streams whole — every
313
+ * document is re-emitted in order with its own framing, never a silently
314
+ * truncated stream. `modify`/`modifyToString` are **single-document**: a
315
+ * {@link YamlPath} carries no document index, so which document of a stream
316
+ * a path names is a rule the format does not define, and a multi-document
317
+ * stream fails typed with a `MultiDocumentStream` diagnostic rather than
318
+ * guessing. Parse a stream with {@link Yaml.parseAll} /
319
+ * `Yaml.parseAllResult`.
320
+ *
321
+ * **Directive-carrying input is refused on every path.** The stringifier does
322
+ * not re-emit `%YAML`/`%TAG` directive lines, and re-emitting a document
323
+ * without its `%TAG` while keeping the shorthand tags that depend on it
324
+ * (`!e!foo`) turns a valid file into one no parser can read. So
325
+ * `format`/`formatToString` leave such input byte-identical (no edits), and
326
+ * `modify`/`modifyToString` fail typed with a `DirectiveCarryingDocument`
327
+ * diagnostic. Directive re-emission is unimplemented, not undesired — the
328
+ * refusal is the floor that stops corruption until it lands.
329
+ *
242
330
  * @public
243
331
  */
244
332
  var YamlFormat = class YamlFormat {
@@ -249,6 +337,24 @@ var YamlFormat = class YamlFormat {
249
337
  * Pure and total: malformed input (a fatal parse error) yields `[]` rather
250
338
  * than corrupting the document.
251
339
  *
340
+ * **Multi-document streams format whole.** Input containing more than one
341
+ * document (a `---`-separated stream — a Kubernetes manifest, a pnpm 11
342
+ * `pnpm-lock.yaml` with a config-dependency preamble) formats every
343
+ * document in order, re-emitting each document's own framing (`---`,
344
+ * `...`, comment blocks); no document is ever dropped. Document detection
345
+ * is CST-level, so a `---` inside a block scalar or quoted string is
346
+ * content, not a document boundary. Two multi-document shapes yield `[]`
347
+ * (untouched) because they cannot be re-emitted faithfully: a stream with
348
+ * a fatal diagnostic in any document (the same posture as single-document
349
+ * input) and a stream carrying `%YAML`/`%TAG` directives.
350
+ *
351
+ * **Directive-carrying documents yield `[]` on the single-document path
352
+ * too.** Directive lines are not re-emitted, and dropping a `%TAG` while
353
+ * keeping the shorthand tags that depend on it would turn a valid document
354
+ * into an unparseable one — so a document carrying `%YAML`/`%TAG`
355
+ * directives is left untouched. Detection is directive-token-level: a
356
+ * literal `%TAG` inside a scalar is content and formats normally.
357
+ *
252
358
  * @remarks
253
359
  * The positional `range` argument takes precedence over
254
360
  * `options?.range` when both are given; either accepts a plain
@@ -278,6 +384,13 @@ var YamlFormat = class YamlFormat {
278
384
  /**
279
385
  * Format `text` and apply the resulting edits in one step
280
386
  * (`YamlEdit.applyAll ∘ format`). Pure and total.
387
+ *
388
+ * Inherits the {@link YamlFormat.format} contract: a multi-document stream
389
+ * is formatted whole — every document re-emitted in order — and input
390
+ * that cannot be formatted faithfully (a fatal parse error, or any
391
+ * document — single or in a stream — carrying `%YAML`/`%TAG` directives)
392
+ * is returned byte-identical — never a truncated first document, never a
393
+ * document re-emitted without the directive its tags depend on.
281
394
  */
282
395
  static formatToString(text, range, options) {
283
396
  return YamlEdit.applyAll(text, YamlFormat.format(text, range, options));
@@ -291,18 +404,53 @@ var YamlFormat = class YamlFormat {
291
404
  * {@link YamlModificationError} on a fatal parse error or a structural
292
405
  * navigation mismatch.
293
406
  *
407
+ * **Single-document contract.** A `path` carries no document index, so on
408
+ * a multi-document stream there is no rule for which document it names —
409
+ * `modify` fails with {@link YamlModificationError} carrying a
410
+ * `MultiDocumentStream` diagnostic rather than guessing document 1 (and
411
+ * unlike {@link YamlFormat.format}, which formats a stream whole because
412
+ * formatting needs no target). Detection is CST-level: a `---` inside a
413
+ * block scalar or quoted string is content, not a document boundary.
414
+ *
415
+ * **Directive-carrying documents are refused.** A document carrying
416
+ * `%YAML`/`%TAG` directives fails with {@link YamlModificationError}
417
+ * carrying a `DirectiveCarryingDocument` diagnostic: modify re-emits the
418
+ * whole document and does not re-emit directive lines, so applying it
419
+ * would drop the `%TAG` while keeping the shorthand tags that depend on
420
+ * it — unparseable output. A typed refusal beats silent corruption;
421
+ * directive re-emission is unimplemented, not undesired. A literal
422
+ * `%TAG` inside a scalar is content and does not trigger the refusal.
423
+ *
294
424
  * @remarks
295
425
  * `options` is a bare {@link YamlStringifyOptions} — it controls only the
296
426
  * internal re-stringify step, not a range (there is no range to restrict
297
427
  * for a path-targeted modification).
298
428
  */
299
429
  static modify = Effect.fn("YamlFormat.modify")(function* (text, path, value, options) {
300
- const doc = composeFirstDocument(text, {});
430
+ const { document: doc, documentCount } = composeFirstDocumentCounted(text, {});
431
+ if (documentCount > 1) return yield* new YamlModificationError({
432
+ path,
433
+ diagnostics: [YamlDiagnostic.fromRaw({
434
+ code: "MultiDocumentStream",
435
+ message: `Cannot modify a multi-document stream (${documentCount} documents): modify re-emits exactly one document`,
436
+ offset: 0,
437
+ length: 0
438
+ }, text)]
439
+ });
301
440
  const fatal = doc.errors.filter((e) => isFatalCode(e.code));
302
441
  if (fatal.length > 0) return yield* new YamlModificationError({
303
442
  path,
304
443
  diagnostics: fatal.map((e) => YamlDiagnostic.fromRaw(e, text))
305
444
  });
445
+ if (doc.directives.length > 0) return yield* new YamlModificationError({
446
+ path,
447
+ diagnostics: [YamlDiagnostic.fromRaw({
448
+ code: "DirectiveCarryingDocument",
449
+ message: "Cannot modify a document carrying %YAML/%TAG directives: modify does not re-emit directive lines, and dropping a %TAG orphans the shorthand tags that depend on it",
450
+ offset: 0,
451
+ length: 0
452
+ }, text)]
453
+ });
306
454
  let newContents;
307
455
  try {
308
456
  newContents = modifyDocument(doc, path, value);
@@ -327,7 +475,10 @@ var YamlFormat = class YamlFormat {
327
475
  });
328
476
  /**
329
477
  * Modify `text` and apply the resulting edits in one step
330
- * (`YamlEdit.applyAll ∘ modify`).
478
+ * (`YamlEdit.applyAll ∘ modify`). Inherits the {@link YamlFormat.modify}
479
+ * error channel, including the single-document contract's
480
+ * `MultiDocumentStream` refusal and the `DirectiveCarryingDocument`
481
+ * refusal of `%YAML`/`%TAG`-carrying documents.
331
482
  */
332
483
  static modifyToString = Effect.fn("YamlFormat.modifyToString")(function* (text, path, value, options) {
333
484
  const edits = yield* YamlFormat.modify(text, path, value, options);
package/YamlLint.js ADDED
@@ -0,0 +1,239 @@
1
+ import { composeFirstDocument } from "./internal/composer/document.js";
2
+ import { isFatalCode } from "./internal/diagnostics.js";
3
+ import { YamlParseError } from "./Yaml.js";
4
+ import { documentFromRaw } from "./YamlDocument.js";
5
+ import { YamlEdit } from "./YamlEdit.js";
6
+ import { YamlLintDiagnostic } from "./YamlLintRule.js";
7
+ import { builtinOptionsSchemas, builtinRules } from "./internal/rules/catalog.js";
8
+ import { YamlTokens } from "./YamlToken.js";
9
+ import { Result, Schema } from "effect";
10
+
11
+ //#region src/YamlLint.ts
12
+ /**
13
+ * One entry of the config `rules` map: a bare severity literal (the common
14
+ * case), `"off"` to disable, or a typed per-rule options object (the tuning
15
+ * case; may carry its own `severity`).
16
+ *
17
+ * @public
18
+ */
19
+ const YamlLintRuleSetting = Schema.Union([Schema.Literals([
20
+ "error",
21
+ "warning",
22
+ "off"
23
+ ]), Schema.Record(Schema.String, Schema.Unknown)]);
24
+ /** Rule-aware validation of the config `rules` map (undefined = valid). */
25
+ const validateRulesMap = (rules) => {
26
+ for (const [id, entry] of Object.entries(rules)) {
27
+ if (id === "parse-validity") {
28
+ if (entry === "off" || entry === "warning") return `Rule "parse-validity" is always-on and cannot be set to "${entry}"`;
29
+ if (typeof entry === "object") return `Rule "parse-validity" accepts no options`;
30
+ continue;
31
+ }
32
+ if (typeof entry === "object") {
33
+ const optionsSchema = builtinOptionsSchemas.get(id);
34
+ if (optionsSchema !== void 0) {
35
+ const decoded = Schema.decodeUnknownResult(optionsSchema, { onExcessProperty: "error" })(entry);
36
+ if (Result.isFailure(decoded)) return `Invalid options for rule "${id}": ${String(decoded.failure)}`;
37
+ }
38
+ }
39
+ }
40
+ };
41
+ /**
42
+ * The lint configuration: a `rules` map keying rule ids (built-in or custom)
43
+ * to a severity literal or a typed per-rule options object.
44
+ *
45
+ * Validation is rule-aware for the built-in catalog — a mistyped option on a
46
+ * built-in rule fails schema validation with a typed error naming the rule,
47
+ * and any attempt to demote or disable the always-on `parse-validity` rule
48
+ * is rejected. Custom rule ids are accepted with a bare severity or an
49
+ * opaque options object the custom rule validates itself.
50
+ *
51
+ * Presets ship as the statics {@link YamlLintConfig.default} and
52
+ * {@link YamlLintConfig.relaxed} — composing one is object spread over a
53
+ * static, not an `extends` string resolution step (this package owns no
54
+ * config-file loader).
55
+ *
56
+ * @public
57
+ */
58
+ var YamlLintConfig = class YamlLintConfig extends Schema.Class("YamlLintConfig")({ rules: Schema.Record(Schema.String, YamlLintRuleSetting).pipe(Schema.check(Schema.makeFilter(validateRulesMap))) }) {
59
+ /**
60
+ * The default preset. Rule entries accrete as built-ins land; the
61
+ * `quoted-strings` rule defaults to DOUBLE quotes here (the one taste
62
+ * call the design pins).
63
+ */
64
+ static default = YamlLintConfig.make({ rules: {
65
+ "line-length": "error",
66
+ "trailing-spaces": "error",
67
+ "empty-lines": "error",
68
+ "eof-newline": "error",
69
+ "key-duplicates": "error",
70
+ "quoted-strings": { quoteType: "double" },
71
+ truthy: "error",
72
+ "comments-spacing": "error",
73
+ "colon-spacing": "error",
74
+ "hyphen-spacing": "error",
75
+ indentation: "error"
76
+ } });
77
+ /** The relaxed preset: style rules demoted to warnings. */
78
+ static relaxed = YamlLintConfig.make({ rules: {
79
+ "line-length": "warning",
80
+ "trailing-spaces": "warning",
81
+ "empty-lines": "warning",
82
+ "eof-newline": "warning",
83
+ "key-duplicates": "error",
84
+ "quoted-strings": {
85
+ quoteType: "double",
86
+ severity: "warning"
87
+ },
88
+ truthy: "warning",
89
+ "comments-spacing": "warning",
90
+ "colon-spacing": "warning",
91
+ "hyphen-spacing": "warning",
92
+ indentation: "warning"
93
+ } });
94
+ };
95
+ /**
96
+ * Split `text` into positioned lines. Line text excludes the terminator
97
+ * whole — for CRLF input the `\r` is part of the terminator and is stripped
98
+ * too, so every rule sees the same line content regardless of line-ending
99
+ * style. Empty input has no lines: rules that count blank lines must not see
100
+ * a phantom first line.
101
+ */
102
+ const buildLines = (text) => {
103
+ if (text === "") return [];
104
+ const lines = [];
105
+ let offset = 0;
106
+ let number = 0;
107
+ while (offset <= text.length) {
108
+ const nl = text.indexOf("\n", offset);
109
+ const end = nl === -1 ? text.length : nl > offset && text[nl - 1] === "\r" ? nl - 1 : nl;
110
+ if (offset === text.length && number > 0) break;
111
+ lines.push({
112
+ text: text.slice(offset, end),
113
+ offset,
114
+ number
115
+ });
116
+ if (nl === -1) break;
117
+ offset = nl + 1;
118
+ number++;
119
+ }
120
+ return lines;
121
+ };
122
+ /**
123
+ * Build the eager lint context: tokenize once, compose once (through the
124
+ * RECOVERED path, so the context exists for malformed input too — the
125
+ * `parse-validity` rule reports what went wrong).
126
+ */
127
+ const buildContext = (text) => {
128
+ const tokens = Result.match(YamlTokens.tokenize(text), {
129
+ onSuccess: (value) => value,
130
+ onFailure: (error) => {
131
+ throw error;
132
+ }
133
+ });
134
+ return {
135
+ text,
136
+ lines: buildLines(text),
137
+ tokens,
138
+ document: documentFromRaw(composeFirstDocument(text, { uniqueKeys: false }), text)
139
+ };
140
+ };
141
+ /** Resolve the effective severity of a configured entry. */
142
+ const resolveSeverity = (entry) => {
143
+ if (entry === "error" || entry === "warning") return entry;
144
+ if (typeof entry === "object" && (entry.severity === "error" || entry.severity === "warning")) return entry.severity;
145
+ return "error";
146
+ };
147
+ const byPosition = (a, b) => a.offset - b.offset || a.length - b.length || (a.rule < b.rule ? -1 : a.rule > b.rule ? 1 : 0);
148
+ /**
149
+ * The rule loop shared by `run` and `fix` over one already-built context —
150
+ * `fix` must not tokenize and compose the same input twice.
151
+ */
152
+ const runRules = (ctx, rules, config) => {
153
+ const out = [];
154
+ for (const rule of rules) {
155
+ const alwaysOn = rule.id === "parse-validity";
156
+ const entry = alwaysOn ? "error" : config.rules[rule.id];
157
+ if (entry === void 0 || entry === "off") continue;
158
+ const severity = resolveSeverity(entry);
159
+ const options = typeof entry === "object" ? entry : void 0;
160
+ for (const diagnostic of rule.check(ctx, options)) out.push(alwaysOn || diagnostic.severity === severity ? diagnostic : new YamlLintDiagnostic({
161
+ ...diagnostic,
162
+ severity
163
+ }));
164
+ }
165
+ return out.sort(byPosition);
166
+ };
167
+ /**
168
+ * Linting statics. Not instantiable.
169
+ *
170
+ * @remarks
171
+ * Pure and synchronous throughout — the lint engine is the pure half only:
172
+ * strings in, diagnostics or a fixed string out. File discovery, config-file
173
+ * loading and autofix-to-disk belong to a consumer's tier, not here.
174
+ *
175
+ * @public
176
+ */
177
+ var YamlLint = class {
178
+ constructor() {}
179
+ /**
180
+ * The built-in rule catalog. Custom usage is array concatenation:
181
+ * `YamlLint.run(text, [...YamlLint.builtins, myRule], config)`.
182
+ */
183
+ static builtins = builtinRules;
184
+ /**
185
+ * Run `rules` over `text` under `config`, returning every finding sorted
186
+ * by position.
187
+ *
188
+ * Document-driven rules see the FIRST document of the stream (matching
189
+ * `Yaml.parse`; split the stream `Yaml.parseAll`-style to lint every
190
+ * document), while token- and line-driven rules cover the full source —
191
+ * see {@link LintContext}.
192
+ *
193
+ * A rule runs when its config entry is a severity or an options object;
194
+ * `"off"` and absent entries skip it — except `parse-validity`, which is
195
+ * always-on. The resolved severity (explicit-in-options, else the bare
196
+ * literal, else `"error"`) overrides what the rule emitted, again except
197
+ * for `parse-validity`, whose bridged engine diagnostics keep the
198
+ * engine's own grading.
199
+ */
200
+ static run(text, rules, config) {
201
+ return runRules(buildContext(text), rules, config);
202
+ }
203
+ /**
204
+ * Run `rules` and apply every non-overlapping surgical fix, returning the
205
+ * fixed text. Fails with {@link YamlParseError} when the FIRST document
206
+ * of the stream has a fatal parse error — a document the engine cannot
207
+ * compose is not safely fixable. The gate matches `Yaml.parse`'s scope:
208
+ * later documents of a multi-document stream are not composed (split the
209
+ * stream `Yaml.parseAll`-style to lint every document), though token- and
210
+ * line-driven fixes still cover the full source. Fixes route exclusively through `YamlEdit.applyAll` (never a
211
+ * reformat), so applying them is comment-safe by construction; when two
212
+ * fixes overlap — or start at the same offset — the earlier one in
213
+ * {@link YamlLint.run} order (position, then rule id) wins and the later
214
+ * is dropped (its diagnostic remains reported by {@link YamlLint.run}).
215
+ */
216
+ static fix(text, rules, config) {
217
+ const ctx = buildContext(text);
218
+ const fatal = ctx.document.errors.filter((e) => isFatalCode(e.code));
219
+ if (fatal.length > 0) return Result.fail(new YamlParseError({
220
+ diagnostics: fatal,
221
+ input: text
222
+ }));
223
+ const fixes = [];
224
+ let lastEnd = -1;
225
+ let lastOffset = -1;
226
+ for (const diagnostic of runRules(ctx, rules, config)) {
227
+ const fix = diagnostic.fix;
228
+ if (fix === void 0) continue;
229
+ if (fix.offset < lastEnd || fix.offset === lastOffset) continue;
230
+ fixes.push(fix);
231
+ lastEnd = fix.offset + fix.length;
232
+ lastOffset = fix.offset;
233
+ }
234
+ return Result.succeed(YamlEdit.applyAll(text, fixes));
235
+ }
236
+ };
237
+
238
+ //#endregion
239
+ export { YamlLint, YamlLintConfig, YamlLintRuleSetting };
@@ -0,0 +1,36 @@
1
+ import { YamlEdit } from "./YamlEdit.js";
2
+ import { Schema } from "effect";
3
+
4
+ //#region src/YamlLintRule.ts
5
+ /**
6
+ * Lint diagnostic severities. `"off"` is a config-level disable only and
7
+ * never reaches a diagnostic — a rule set to `"off"` is not run.
8
+ *
9
+ * @public
10
+ */
11
+ const YamlLintSeverity = Schema.Literals(["error", "warning"]);
12
+ /**
13
+ * A single lint finding: the reporting rule, its severity, a positioned span
14
+ * and optionally a surgical fix.
15
+ *
16
+ * Deliberately separate from the engine's `YamlDiagnostic`: that type is the
17
+ * lexer/parser/composer/stringifier error-code union and the single source
18
+ * of truth for engine fatality — it carries no severity and no fix, and
19
+ * lint-layer concerns must not pollute it. The `parse-validity` rule bridges
20
+ * the two by mapping engine diagnostics into this shape.
21
+ *
22
+ * @public
23
+ */
24
+ var YamlLintDiagnostic = class extends Schema.Class("YamlLintDiagnostic")({
25
+ rule: Schema.String,
26
+ severity: YamlLintSeverity,
27
+ message: Schema.String,
28
+ offset: Schema.Number,
29
+ length: Schema.Number,
30
+ line: Schema.Number,
31
+ character: Schema.Number,
32
+ fix: Schema.optionalKey(YamlEdit)
33
+ }) {};
34
+
35
+ //#endregion
36
+ export { YamlLintDiagnostic, YamlLintSeverity };
package/YamlNode.js CHANGED
@@ -49,9 +49,17 @@ const ScalarChomp = Schema.Literals([
49
49
  * - `style` — the scalar presentation style in the source document.
50
50
  * - `tag` — optional explicit YAML tag (e.g. `!!str`, `!!int`).
51
51
  * - `anchor` — optional anchor name for aliasing.
52
- * - `comment` — optional trailing or leading comment text.
52
+ * - `commentBefore` — own-line comment text directly above the node
53
+ * (multiple consecutive comment lines join with `\n`).
54
+ * - `comment` — trailing comment text on the node's line (strictly trailing;
55
+ * own-line comments live on `commentBefore`).
56
+ * - `spaceBefore` — `true` when a blank line precedes the node (and its
57
+ * `commentBefore` block, when present) in the source.
53
58
  * - `chomp` — block-scalar chomping indicator, when the scalar is a block
54
59
  * scalar.
60
+ * - `blockIndent` — the EXPLICIT indentation-indicator digit from a block
61
+ * scalar's header (`|2`, `>1+`), when the source spelled one; absent when
62
+ * the header let the reader auto-detect the indent.
55
63
  * - `raw` — the raw source text, preserved when it differs from the resolved
56
64
  * value in a way stringification needs to know about.
57
65
  * - `sourceMultiline` — `true` when the source span covers two or more lines;
@@ -65,8 +73,11 @@ var YamlScalar = class extends Schema.TaggedClass()("YamlScalar", {
65
73
  tag: Schema.optionalKey(Schema.String),
66
74
  style: ScalarStyle,
67
75
  anchor: Schema.optionalKey(Schema.String),
76
+ commentBefore: Schema.optionalKey(Schema.String),
68
77
  comment: Schema.optionalKey(Schema.String),
78
+ spaceBefore: Schema.optionalKey(Schema.Boolean),
69
79
  chomp: Schema.optionalKey(ScalarChomp),
80
+ blockIndent: Schema.optionalKey(Schema.Number),
70
81
  raw: Schema.optionalKey(Schema.String),
71
82
  sourceMultiline: Schema.optionalKey(Schema.Boolean),
72
83
  offset: Schema.Number,
@@ -157,18 +168,31 @@ const YamlNode = Schema.suspend(() => Schema.Union([
157
168
  * A YAML key-value pair AST node, representing one entry within a mapping.
158
169
  * `value` is `null` when absent (e.g. `key:` with no value).
159
170
  *
171
+ * - `commentBefore` — own-line comment text directly above the pair's key
172
+ * (multiple consecutive comment lines join with `\n`).
173
+ * - `comment` — trailing comment text on the pair's line (strictly trailing).
174
+ * - `spaceBefore` — `true` when a blank line precedes the pair (and its
175
+ * `commentBefore` block, when present) in the source.
176
+ *
160
177
  * @public
161
178
  */
162
179
  var YamlPair = class extends Schema.TaggedClass()("YamlPair", {
163
180
  key: Schema.suspend(() => YamlNode),
164
181
  value: Schema.NullOr(Schema.suspend(() => YamlNode)),
165
- comment: Schema.optionalKey(Schema.String)
182
+ commentBefore: Schema.optionalKey(Schema.String),
183
+ comment: Schema.optionalKey(Schema.String),
184
+ spaceBefore: Schema.optionalKey(Schema.Boolean)
166
185
  }) {};
167
186
  /**
168
187
  * A YAML mapping AST node, representing a collection of {@link YamlPair}
169
188
  * entries.
170
189
  *
171
190
  * - `style` — the presentation style: `"block"` or `"flow"`.
191
+ * - `commentBefore` — own-line comment text directly above the mapping.
192
+ * - `comment` — trailing comment text: own-line comment lines after the
193
+ * mapping's last entry (still at the mapping's item indent), or a same-line
194
+ * trailing comment for a flow mapping.
195
+ * - `spaceBefore` — `true` when a blank line precedes the mapping.
172
196
  * - `sourceMultiline` — `true` when the source span covers two or more lines;
173
197
  * used by the canonical stringifier. Absent on synthetic nodes.
174
198
  *
@@ -179,7 +203,9 @@ var YamlMap = class extends Schema.TaggedClass()("YamlMap", {
179
203
  tag: Schema.optionalKey(Schema.String),
180
204
  anchor: Schema.optionalKey(Schema.String),
181
205
  style: CollectionStyle,
206
+ commentBefore: Schema.optionalKey(Schema.String),
182
207
  comment: Schema.optionalKey(Schema.String),
208
+ spaceBefore: Schema.optionalKey(Schema.Boolean),
183
209
  sourceMultiline: Schema.optionalKey(Schema.Boolean),
184
210
  offset: Schema.Number,
185
211
  length: Schema.Number
@@ -205,6 +231,12 @@ var YamlMap = class extends Schema.TaggedClass()("YamlMap", {
205
231
  * A YAML sequence AST node, representing an ordered list of
206
232
  * {@link (YamlNode:type)} values.
207
233
  *
234
+ * - `commentBefore` — own-line comment text directly above the sequence.
235
+ * - `comment` — trailing comment text: own-line comment lines after the
236
+ * sequence's last item (still at the sequence's item indent), or a
237
+ * same-line trailing comment for a flow sequence.
238
+ * - `spaceBefore` — `true` when a blank line precedes the sequence.
239
+ *
208
240
  * @public
209
241
  */
210
242
  var YamlSeq = class extends Schema.TaggedClass()("YamlSeq", {
@@ -212,7 +244,9 @@ var YamlSeq = class extends Schema.TaggedClass()("YamlSeq", {
212
244
  tag: Schema.optionalKey(Schema.String),
213
245
  anchor: Schema.optionalKey(Schema.String),
214
246
  style: CollectionStyle,
247
+ commentBefore: Schema.optionalKey(Schema.String),
215
248
  comment: Schema.optionalKey(Schema.String),
249
+ spaceBefore: Schema.optionalKey(Schema.Boolean),
216
250
  sourceMultiline: Schema.optionalKey(Schema.Boolean),
217
251
  offset: Schema.Number,
218
252
  length: Schema.Number