@effected/yaml 0.25.1 → 0.26.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.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
6
  [![TypeScript 7.0](https://img.shields.io/badge/TypeScript-7.0-3178c6.svg)](https://www.typescriptlang.org/)
7
7
 
8
- Zero-dependency YAML 1.2 parsing, editing, formatting and linting expressed as Effect schemas and pure functions. Parse a single document or a multi-document stream into plain values or an offset-preserving AST, resolve anchors and aliases, read and preserve per-node comments, strip comments, compute byte-minimal edits, format, modify by path, walk a document as a `Stream` of AST events or positioned tokens, lint it against a rule catalog with surgical autofix — inferring the lint config from files you already have — and decode straight into a validated domain schema.
8
+ YAML 1.2 parsing, editing, formatting and linting expressed as Effect schemas and pure functions. Parse a single document or a multi-document stream into plain values or an offset-preserving AST, resolve anchors and aliases, read and preserve per-node comments, strip comments, compute byte-minimal edits, format, modify by path, walk a document as a `Stream` of AST events or positioned tokens, lint it against a rule catalog with surgical autofix — inferring the lint config from files you already have — and decode straight into a validated domain schema.
9
9
 
10
10
  > **Pre-`1.0.0`.** This package is part of the `@effected/*` kit, built on stable
11
11
  > Effect v4 (`effect` `^4.0.0`) and still in `0.x` development. Stable Effect
@@ -25,7 +25,7 @@ YAML is where untrusted text meets production systems: CI pipeline definitions,
25
25
 
26
26
  This package treats that as a first-class requirement rather than a footnote. An alias-expansion budget bounds the number of materialized nodes, a depth cap bounds collection nesting on both the parse and the stringify side, and both fire into the typed error channel. Hostile input produces a `YamlParseError` carrying structured `YamlDiagnostic` values with codes and positions; it never produces a `RangeError`, an unhandled defect or an exhausted heap.
27
27
 
28
- The rest follows from the same discipline. Parsing recovers from errors and aggregates every diagnostic into one failure rather than throwing on the first. Modifications are computed as edits against the original bytes, so comments and layout survive a change. Everything is a pure function or a schema: no IO, no services and no runtime dependency other than `effect` — the lexer, CST parser, composer and stringifier are vendored into the package with attribution rather than taken as a dependency. It is the largest package in the repo, and it earns that by owning its engine.
28
+ The rest follows from the same discipline. Parsing recovers from errors and aggregates every diagnostic into one failure rather than throwing on the first. Modifications are computed as edits against the original bytes, so comments and layout survive a change. Everything is a pure function or a schema: no IO, no services and no runtime dependency other than `effect` and the dependency-free `@effected/source` (the line index every reported position comes from) — the lexer, CST parser, composer and stringifier are vendored into the package with attribution rather than taken as a dependency. It is the largest package in the repo, and it earns that by owning its engine.
29
29
 
30
30
  ## Install
31
31
 
package/Yaml.js CHANGED
@@ -2,7 +2,7 @@ import { AliasExpansionBudgetExceeded, CollectionStyle, QuoteCompat, QuoteStyle,
2
2
  import { composeAllDocuments, composeFirstDocument } from "./internal/composer/document.js";
3
3
  import { isFatalCode } from "./internal/diagnostics.js";
4
4
  import { StringifyDepthExceeded, StringifyFailure, stringifyValue } from "./internal/stringifier.js";
5
- import { YamlDiagnostic } from "./YamlDiagnostic.js";
5
+ import { YamlDiagnostic, lazyLineIndex } from "./YamlDiagnostic.js";
6
6
  import { Effect, Result, Schema, SchemaIssue, SchemaTransformation } from "effect";
7
7
 
8
8
  // src/Yaml.ts
@@ -207,7 +207,10 @@ const toStringifyInput = (options) => options === void 0 ? {} : {
207
207
  finalNewline: options.finalNewline,
208
208
  forceDefaultStyles: options.forceDefaultStyles
209
209
  };
210
- const toDiagnostics = (text, records) => records.map((r) => YamlDiagnostic.fromRaw(r, text));
210
+ const toDiagnostics = (text, records) => {
211
+ const index = lazyLineIndex(text);
212
+ return records.map((r) => YamlDiagnostic.fromRaw(r, index()));
213
+ };
211
214
  /**
212
215
  * Build the fatal {@link YamlParseError} for an alias-expansion "billion
213
216
  * laughs" blow-up. The offending expansion has no source span, so the
package/YamlDiagnostic.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { YAML_COMPOSE_ERROR_CODES, YAML_LEX_ERROR_CODES, YAML_MODIFY_ERROR_CODES, YAML_PARSE_ERROR_CODES, YAML_STRINGIFY_ERROR_CODES, isFatalCode } from "./internal/diagnostics.js";
2
+ import { LineIndex } from "@effected/source";
2
3
  import { Schema } from "effect";
3
4
 
4
5
  // src/YamlDiagnostic.ts
@@ -56,9 +57,16 @@ const YamlErrorCode = Schema.Union([
56
57
  * fatality is a property of the code — see {@link YamlDiagnostic.isFatal}.
57
58
  *
58
59
  * @remarks
59
- * The five-field positional core (`code`/`offset`/`length`/`line`/`character`)
60
- * is structurally identical to `@effected/jsonc`'s parse-error detail shape;
61
- * `message` is this package's additive extra.
60
+ * Satisfies `@effected/source`'s `SourceDiagnostic<YamlErrorCode>`: the
61
+ * positional core (`code`/`message`/`offset`/`length`/`line`/`character`)
62
+ * the kit's format packages share, so a consumer printing or serving
63
+ * diagnostics (an LSP server, a CLI annotation) is written once against that
64
+ * shape. `line`/`character` are zero-based, `character` in UTF-16 code units,
65
+ * with `\n`, `\r\n` and a bare `\r` as line breaks, YAML 1.2's own
66
+ * `b-break` set: exactly `LineIndex.make(text).positionAt(offset)`. U+2028
67
+ * and U+2029 are not line breaks (YAML 1.2 dropped them, the lexer never
68
+ * treated them as one, and neither does an LSP client), and an offset at the
69
+ * `\n` of a `\r\n` reads as the end of its line.
62
70
  *
63
71
  * @public
64
72
  */
@@ -86,12 +94,20 @@ var YamlDiagnostic = class YamlDiagnostic extends Schema.Class("YamlDiagnostic")
86
94
  * deriving `line`/`character` from `offset` against the source `text`.
87
95
  * Advanced — the parse/stringify entry points call this for you.
88
96
  *
97
+ * @remarks
98
+ * Pass a line index already built over the source to materialize many
99
+ * records without rescanning the text; a string builds one. The
100
+ * parameter is the structural `SourceIndex`, so an index from any
101
+ * installed copy of `@effected/source` is accepted. An offset
102
+ * outside the source clamps into it, as `LineIndex.positionAt` does.
103
+ *
89
104
  * @param raw - The engine record: `code`, `message`, `offset` and `length`.
90
- * @param text - The source text the record's `offset` indexes into.
105
+ * @param text - The source text the record's `offset` indexes into, or a
106
+ * `LineIndex` over it.
91
107
  * @returns The diagnostic with `line` and `character` filled in.
92
108
  */
93
109
  static fromRaw(raw, text) {
94
- const { line, character } = lineChar(text, raw.offset);
110
+ const { line, character } = (typeof text === "string" ? LineIndex.make(text) : text).positionAt(raw.offset);
95
111
  return YamlDiagnostic.make({
96
112
  code: raw.code,
97
113
  message: raw.message,
@@ -103,32 +119,18 @@ var YamlDiagnostic = class YamlDiagnostic extends Schema.Class("YamlDiagnostic")
103
119
  }
104
120
  };
105
121
  /**
106
- * Compute the zero-based line/character position of `offset` within `text`.
107
- * Recognizes `\n`, `\r`, `\r\n`, LS and PS as line breaks, matching the
108
- * jsonc counterpart so positions are codec-generic.
122
+ * A line index over `text`, built on first call and shared by every later
123
+ * one: the parse entry points materialize all of a parse's diagnostics
124
+ * against one index, and a parse with none never builds it.
125
+ *
126
+ * @internal
109
127
  */
110
- function lineChar(text, offset) {
111
- let line = 0;
112
- let lineStart = 0;
113
- const limit = Math.min(offset, text.length);
114
- for (let i = 0; i < limit; i++) {
115
- const ch = text.charCodeAt(i);
116
- if (ch === 10) {
117
- line++;
118
- lineStart = i + 1;
119
- } else if (ch === 13) {
120
- if (i + 1 < text.length && text.charCodeAt(i + 1) === 10) i++;
121
- line++;
122
- lineStart = i + 1;
123
- } else if (ch === 8232 || ch === 8233) {
124
- line++;
125
- lineStart = i + 1;
126
- }
127
- }
128
- return {
129
- line,
130
- character: offset - lineStart
128
+ const lazyLineIndex = (text) => {
129
+ let index;
130
+ return () => {
131
+ index ??= LineIndex.make(text);
132
+ return index;
131
133
  };
132
- }
134
+ };
133
135
 
134
- export { YamlComposerErrorCode, YamlDiagnostic, YamlErrorCode, YamlLexErrorCode, YamlModifyErrorCode, YamlParseErrorCode, YamlStringifyErrorCode };
136
+ export { YamlComposerErrorCode, YamlDiagnostic, YamlErrorCode, YamlLexErrorCode, YamlModifyErrorCode, YamlParseErrorCode, YamlStringifyErrorCode, lazyLineIndex };
package/YamlDocument.js CHANGED
@@ -2,7 +2,7 @@ import { YamlNode } from "./YamlNode.js";
2
2
  import { composeAllDocuments, composeFirstDocument } from "./internal/composer/document.js";
3
3
  import { isFatalCode } from "./internal/diagnostics.js";
4
4
  import { stringifyDocument } from "./internal/stringifier.js";
5
- import { YamlDiagnostic } from "./YamlDiagnostic.js";
5
+ import { YamlDiagnostic, lazyLineIndex } from "./YamlDiagnostic.js";
6
6
  import { YamlParseError, stringifyDefectToError } from "./Yaml.js";
7
7
  import { Effect, Result, Schema, SchemaIssue, SchemaTransformation } from "effect";
8
8
 
@@ -85,12 +85,13 @@ var YamlDocument = class YamlDocument extends Schema.Class("YamlDocument")({
85
85
  */
86
86
  static parseResult(text, options) {
87
87
  const raw = composeFirstDocument(text, toParseInput(options));
88
+ const index = lazyLineIndex(text);
88
89
  const fatal = raw.errors.filter((e) => isFatalCode(e.code));
89
90
  if (fatal.length > 0) return Result.fail(new YamlParseError({
90
- diagnostics: fatal.map((e) => YamlDiagnostic.fromRaw(e, text)),
91
+ diagnostics: fatal.map((e) => YamlDiagnostic.fromRaw(e, index())),
91
92
  input: text
92
93
  }));
93
- return Result.succeed(fromRawDocument(raw, text));
94
+ return Result.succeed(fromRawDocument(raw, index));
94
95
  }
95
96
  /**
96
97
  * Parse a single YAML document, keeping the full AST, directives and
@@ -133,12 +134,13 @@ var YamlDocument = class YamlDocument extends Schema.Class("YamlDocument")({
133
134
  */
134
135
  static parseAllResult(text, options) {
135
136
  const { documents, streamErrors } = composeAllDocuments(text, toParseInput(options));
137
+ const index = lazyLineIndex(text);
136
138
  const fatal = [...streamErrors.filter((e) => e.code === "InvalidDirective"), ...documents.flatMap((d) => d.errors.filter((e) => isFatalCode(e.code)))];
137
139
  if (fatal.length > 0) return Result.fail(new YamlParseError({
138
- diagnostics: fatal.map((e) => YamlDiagnostic.fromRaw(e, text)),
140
+ diagnostics: fatal.map((e) => YamlDiagnostic.fromRaw(e, index())),
139
141
  input: text
140
142
  }));
141
- return Result.succeed(documents.map((raw) => fromRawDocument(raw, text)));
143
+ return Result.succeed(documents.map((raw) => fromRawDocument(raw, index)));
142
144
  }
143
145
  /**
144
146
  * Parse a multi-document YAML stream into one {@link YamlDocument} per
@@ -285,15 +287,19 @@ const toStringifyInput = (options) => options === void 0 ? {} : {
285
287
  *
286
288
  * @internal
287
289
  */
288
- function documentFromRaw(raw, text) {
289
- return fromRawDocument(raw, text);
290
+ function documentFromRaw(raw, index) {
291
+ return fromRawDocument(raw, index);
290
292
  }
291
- /** Materialize a raw engine document into the public class. */
292
- function fromRawDocument(raw, text) {
293
+ /**
294
+ * Materialize a raw engine document into the public class. `index` is the
295
+ * parse's one lazily built line index (`lazyLineIndex`), shared by every
296
+ * document of a stream and built only when a diagnostic needs it.
297
+ */
298
+ function fromRawDocument(raw, index) {
293
299
  return new YamlDocument({
294
300
  contents: raw.contents,
295
- errors: raw.errors.map((e) => YamlDiagnostic.fromRaw(e, text)),
296
- warnings: raw.warnings.map((w) => YamlDiagnostic.fromRaw(w, text)),
301
+ errors: raw.errors.map((e) => YamlDiagnostic.fromRaw(e, index())),
302
+ warnings: raw.warnings.map((w) => YamlDiagnostic.fromRaw(w, index())),
297
303
  directives: raw.directives.map((d) => new YamlDirective({
298
304
  name: d.name,
299
305
  parameters: d.parameters
package/YamlLint.js CHANGED
@@ -4,8 +4,10 @@ import { YamlParseError } from "./Yaml.js";
4
4
  import { documentFromRaw } from "./YamlDocument.js";
5
5
  import { YamlEdit } from "./YamlEdit.js";
6
6
  import { YamlLintDiagnostic } from "./YamlLintRule.js";
7
+ import { endsWithLineBreak } from "./internal/rules/util.js";
7
8
  import { builtinOptionsSchemas, builtinRules } from "./internal/rules/catalog.js";
8
- import { YamlTokens } from "./YamlToken.js";
9
+ import { tokensOf } from "./YamlToken.js";
10
+ import { LineIndex } from "@effected/source";
9
11
  import { Result, Schema } from "effect";
10
12
 
11
13
  // src/YamlLint.ts
@@ -92,49 +94,38 @@ var YamlLintConfig = class YamlLintConfig extends Schema.Class("YamlLintConfig")
92
94
  } });
93
95
  };
94
96
  /**
95
- * Split `text` into positioned lines. Line text excludes the terminator
96
- * whole — for CRLF input the `\r` is part of the terminator and is stripped
97
- * too, so every rule sees the same line content regardless of line-ending
98
- * style. Empty input has no lines: rules that count blank lines must not see
99
- * a phantom first line.
97
+ * Split the indexed text into positioned lines: one per `LineIndex` line,
98
+ * under the same `\n` / `\r\n` / bare `\r` rule the tokens and
99
+ * diagnostics use, so a rule may index `ctx.lines` by any token's or
100
+ * diagnostic's `line`. Line text excludes the terminator whole — for CRLF
101
+ * input the `\r` is part of the terminator and is stripped too, so every rule
102
+ * sees the same line content regardless of line-ending style. The final empty
103
+ * segment after a trailing line break is not a line, and empty input has no lines:
104
+ * rules that count blank lines must not see a phantom line.
100
105
  */
101
- const buildLines = (text) => {
102
- if (text === "") return [];
106
+ const buildLines = (index) => {
107
+ if (index.text === "") return [];
108
+ const count = endsWithLineBreak(index.text) ? index.lineCount - 1 : index.lineCount;
103
109
  const lines = [];
104
- let offset = 0;
105
- let number = 0;
106
- while (offset <= text.length) {
107
- const nl = text.indexOf("\n", offset);
108
- const end = nl === -1 ? text.length : nl > offset && text[nl - 1] === "\r" ? nl - 1 : nl;
109
- if (offset === text.length && number > 0) break;
110
- lines.push({
111
- text: text.slice(offset, end),
112
- offset,
113
- number
114
- });
115
- if (nl === -1) break;
116
- offset = nl + 1;
117
- number++;
118
- }
110
+ for (let number = 0; number < count; number++) lines.push({
111
+ text: index.lineText(number),
112
+ offset: index.lineStart(number),
113
+ number
114
+ });
119
115
  return lines;
120
116
  };
121
117
  /**
122
- * Build the eager lint context: tokenize once, compose once (through the
123
- * RECOVERED path, so the context exists for malformed input too — the
124
- * `parse-validity` rule reports what went wrong).
118
+ * Build the eager lint context: index the text once, tokenize once, compose
119
+ * once (through the RECOVERED path, so the context exists for malformed
120
+ * input too — the `parse-validity` rule reports what went wrong).
125
121
  */
126
122
  const buildContext = (text) => {
127
- const tokens = Result.match(YamlTokens.tokenize(text), {
128
- onSuccess: (value) => value,
129
- onFailure: (error) => {
130
- throw error;
131
- }
132
- });
123
+ const index = LineIndex.make(text);
133
124
  return {
134
125
  text,
135
- lines: buildLines(text),
136
- tokens,
137
- document: documentFromRaw(composeFirstDocument(text, { uniqueKeys: false }), text)
126
+ lines: buildLines(index),
127
+ tokens: tokensOf(index),
128
+ document: documentFromRaw(composeFirstDocument(text, { uniqueKeys: false }), () => index)
138
129
  };
139
130
  };
140
131
  /**
package/YamlToken.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { lexAll } from "./internal/lexer.js";
2
+ import { LineIndex } from "@effected/source";
2
3
  import { Result, Schema, Stream } from "effect";
3
4
 
4
5
  // src/YamlToken.ts
@@ -41,6 +42,13 @@ const YamlTokenKind = Schema.Literals([
41
42
  * - `line` / `character` — the zero-based position of the token's start,
42
43
  * matching the `YamlDiagnostic` position vocabulary.
43
44
  *
45
+ * @remarks
46
+ * Satisfies `@effected/source`'s `SourceLocation`. `line` / `character` are
47
+ * `LineIndex.make(source).positionAt(offset)`: zero-based, UTF-16 code
48
+ * units, with `\n`, `\r\n` and a bare `\r` as line breaks (YAML 1.2's
49
+ * `b-break`, the set the lexer emits a `newline` token for), so a token and a
50
+ * `YamlDiagnostic` at the same offset always agree.
51
+ *
44
52
  * @public
45
53
  */
46
54
  var YamlToken = class extends Schema.Class("YamlToken")({
@@ -54,30 +62,32 @@ var YamlToken = class extends Schema.Class("YamlToken")({
54
62
  /**
55
63
  * Promote the internal lexer tokens to the public shape.
56
64
  *
57
- * `line`/`character` are DERIVED from each token's offset against a
58
- * line-start index rather than copied from the internal token: the internal
59
- * `column` is a CST-parser vocabulary that carries the construct's INDENT on
60
- * the synthetic `block-map-start`/`block-seq-start` markers, not the token's
61
- * own position — the public surface promises the position. `text` is the RAW
65
+ * `line`/`character` are DERIVED from each token's offset against the line
66
+ * index rather than copied from the internal token: the internal `column` is
67
+ * a CST-parser vocabulary that carries the construct's INDENT on the
68
+ * synthetic `block-map-start`/`block-seq-start` markers, not the token's own
69
+ * position — the public surface promises the position. `text` is the RAW
62
70
  * source slice for the same reason: the internal `value` is the PROCESSED
63
71
  * form on quoted scalars (content without the quotes), while the public
64
72
  * contract is byte fidelity — the corpus conformance suite pins the tokens
65
73
  * tiling the source exactly.
74
+ *
75
+ * Takes the caller's index so the lint context builds one index for its
76
+ * tokens and its lines.
77
+ *
78
+ * @internal
66
79
  */
67
- const promoteAll = (text, tokens) => {
68
- const lineStarts = [0];
69
- for (let i = 0; i < text.length; i++) if (text[i] === "\n") lineStarts.push(i + 1);
70
- let line = 0;
71
- return tokens.map((token) => {
72
- if (token.offset < lineStarts[line]) line = 0;
73
- while (line + 1 < lineStarts.length && lineStarts[line + 1] <= token.offset) line++;
80
+ const tokensOf = (index) => {
81
+ const text = index.text;
82
+ return lexAll(text).map((token) => {
83
+ const { line, character } = index.positionAt(token.offset);
74
84
  return new YamlToken({
75
85
  kind: token.kind,
76
86
  text: text.slice(token.offset, token.offset + token.length),
77
87
  offset: token.offset,
78
88
  length: token.length,
79
89
  line,
80
- character: token.offset - lineStarts[line]
90
+ character
81
91
  });
82
92
  });
83
93
  };
@@ -118,7 +128,7 @@ var YamlTokens = class YamlTokens {
118
128
  * @returns A `Result` succeeding with every token in source order.
119
129
  */
120
130
  static tokenize(text) {
121
- return Result.succeed(promoteAll(text, lexAll(text)));
131
+ return Result.succeed(tokensOf(LineIndex.make(text)));
122
132
  }
123
133
  /**
124
134
  * Tokenize YAML text as a lazy `Stream` of tokens — the derived form of
@@ -139,4 +149,4 @@ var YamlTokens = class YamlTokens {
139
149
  }
140
150
  };
141
151
 
142
- export { YamlToken, YamlTokenKind, YamlTokens };
152
+ export { YamlToken, YamlTokenKind, YamlTokens, tokensOf };
package/YamlVisitor.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { YamlAlias, YamlMap, YamlScalar, YamlSeq } from "./YamlNode.js";
2
2
  import { composeAllDocuments } from "./internal/composer/document.js";
3
- import { YamlDiagnostic } from "./YamlDiagnostic.js";
3
+ import { YamlDiagnostic, lazyLineIndex } from "./YamlDiagnostic.js";
4
4
  import { Data, Stream } from "effect";
5
5
 
6
6
  // src/YamlVisitor.ts
@@ -58,6 +58,7 @@ var YamlVisitor = class {
58
58
  }
59
59
  };
60
60
  function* visitGen(text, options) {
61
+ const index = lazyLineIndex(text);
61
62
  const { documents, streamErrors } = composeAllDocuments(text, {
62
63
  strict: options?.strict,
63
64
  maxAliasCount: options?.maxAliasCount,
@@ -66,11 +67,11 @@ function* visitGen(text, options) {
66
67
  for (const raw of streamErrors) yield YamlVisitorEvent.Error({
67
68
  path: [],
68
69
  depth: 0,
69
- diagnostic: YamlDiagnostic.fromRaw(raw, text)
70
+ diagnostic: YamlDiagnostic.fromRaw(raw, index())
70
71
  });
71
- for (const doc of documents) yield* walkDocument(doc, text);
72
+ for (const doc of documents) yield* walkDocument(doc, index);
72
73
  }
73
- function* walkDocument(doc, text) {
74
+ function* walkDocument(doc, index) {
74
75
  const path = [];
75
76
  const depth = 0;
76
77
  for (const dir of doc.directives) yield YamlVisitorEvent.Directive({
@@ -90,12 +91,12 @@ function* walkDocument(doc, text) {
90
91
  for (const raw of doc.errors) yield YamlVisitorEvent.Error({
91
92
  path,
92
93
  depth,
93
- diagnostic: YamlDiagnostic.fromRaw(raw, text)
94
+ diagnostic: YamlDiagnostic.fromRaw(raw, index())
94
95
  });
95
96
  for (const raw of doc.warnings) yield YamlVisitorEvent.Error({
96
97
  path,
97
98
  depth,
98
- diagnostic: YamlDiagnostic.fromRaw(raw, text)
99
+ diagnostic: YamlDiagnostic.fromRaw(raw, index())
99
100
  });
100
101
  if (doc.commentBefore !== void 0) yield YamlVisitorEvent.Comment({
101
102
  path,