@effected/yaml 0.1.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/LICENSE +21 -0
- package/README.md +111 -0
- package/Yaml.js +414 -0
- package/YamlDiagnostic.js +126 -0
- package/YamlDocument.js +181 -0
- package/YamlEdit.js +45 -0
- package/YamlFormat.js +309 -0
- package/YamlNode.js +396 -0
- package/YamlVisitor.js +172 -0
- package/index.d.ts +1331 -0
- package/index.js +9 -0
- package/internal/composer/anchors.js +97 -0
- package/internal/composer/block.js +1155 -0
- package/internal/composer/document.js +677 -0
- package/internal/composer/flow.js +400 -0
- package/internal/composer/scalars.js +885 -0
- package/internal/composer/state.js +117 -0
- package/internal/composer/tags.js +88 -0
- package/internal/cst-parser.js +716 -0
- package/internal/diagnostics.js +80 -0
- package/internal/diff.js +56 -0
- package/internal/fold.js +153 -0
- package/internal/lexer.js +959 -0
- package/internal/stringifier.js +797 -0
- package/package.json +47 -0
- package/tsdoc-metadata.json +11 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 C. Spencer Beggs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# @effected/yaml
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@effected/yaml)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
|
|
8
|
+
Zero-dependency YAML 1.2 parsing, editing and formatting 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, strip comments, compute byte-minimal edits, format, modify by path, walk a document as a `Stream` and decode straight into a validated domain schema.
|
|
9
|
+
|
|
10
|
+
> **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
|
|
11
|
+
> development against a single pinned Effect v4 beta. Packages graduate to
|
|
12
|
+
> `1.0.0` once Effect `4.0.0` ships. To hold your own `effect` versions at
|
|
13
|
+
> exactly the ones the kit is built and tested against, install
|
|
14
|
+
> [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
|
|
15
|
+
>
|
|
16
|
+
> **Stability: unstable.** This package's API surface is not yet considered
|
|
17
|
+
> complete and may change across `0.x` releases. Pin an exact version — even a
|
|
18
|
+
> package marked *stable* before `1.0.0` can introduce a breaking change by
|
|
19
|
+
> accident, and an exact pin turns that into a type-check error rather than a
|
|
20
|
+
> runtime surprise. Full policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).
|
|
21
|
+
|
|
22
|
+
## Why @effected/yaml
|
|
23
|
+
|
|
24
|
+
YAML is where untrusted text meets production systems: CI pipeline definitions, Kubernetes manifests and config files that arrive from a pull request, an API payload or a user's home directory. The format is also large enough that a parser has real attack surface. An anchor that references an anchor that references an anchor — the "billion laughs" bomb — is a few hundred bytes of YAML that expands into gigabytes of nodes, and a deeply nested flow collection is a few kilobytes that overflows a recursive-descent parser's stack.
|
|
25
|
+
|
|
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
|
+
|
|
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.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install @effected/yaml effect
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pnpm add @effected/yaml effect
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Requires Node.js >=24.11.0. `effect` v4 is a peer dependency; the package itself adds no other runtime dependencies.
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
Compose your schema with `Yaml.schema` to decode YAML straight into a validated domain value:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { Yaml } from "@effected/yaml";
|
|
48
|
+
import { Effect, Schema } from "effect";
|
|
49
|
+
|
|
50
|
+
const Config = Schema.Struct({ port: Schema.Number });
|
|
51
|
+
const ConfigFromYaml = Yaml.schema(Config);
|
|
52
|
+
|
|
53
|
+
const program = Effect.gen(function* () {
|
|
54
|
+
return yield* Schema.decodeUnknownEffect(ConfigFromYaml)("port: 3000 # dev server");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
Effect.runPromise(program).then(console.log);
|
|
58
|
+
// { port: 3000 }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`Yaml.stringify` goes the other way, failing typed on circular references rather than throwing:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { Yaml } from "@effected/yaml";
|
|
65
|
+
import { Effect } from "effect";
|
|
66
|
+
|
|
67
|
+
Effect.runPromise(Yaml.stringify({ port: 3000, hosts: ["a", "b"] })).then(console.log);
|
|
68
|
+
// port: 3000
|
|
69
|
+
// hosts:
|
|
70
|
+
// - a
|
|
71
|
+
// - b
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Hostile input fails typed
|
|
75
|
+
|
|
76
|
+
An alias bomb — nested anchors whose expansion multiplies at every level — is bounded by an expansion budget and surfaces as a `YamlParseError`, not as an out-of-memory kill:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { Yaml } from "@effected/yaml";
|
|
80
|
+
import { Effect } from "effect";
|
|
81
|
+
|
|
82
|
+
const bomb = [
|
|
83
|
+
"a: &a [x,x,x,x,x,x,x,x,x]",
|
|
84
|
+
"b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]",
|
|
85
|
+
// ...further levels, each multiplying the one before
|
|
86
|
+
"z: [*g,*g,*g,*g,*g,*g,*g,*g,*g]",
|
|
87
|
+
].join("\n");
|
|
88
|
+
|
|
89
|
+
Effect.runPromise(Effect.result(Yaml.parse(bomb))).then(console.log);
|
|
90
|
+
// Failure with YamlParseError, whose `diagnostics` carry:
|
|
91
|
+
// { code: "AliasCountExceeded", message: "Alias expansion exceeded budget of ... nodes" }
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Collection nesting past the depth cap behaves the same way, yielding a `NestingDepthExceeded` diagnostic instead of a stack overflow, and `Yaml.stringify` caps the mirror-image recursion when encoding a value back to text. The guarantee is the same everywhere: every fallible entry point carries a real error channel, and nothing reaches your process as a defect.
|
|
95
|
+
|
|
96
|
+
## Features
|
|
97
|
+
|
|
98
|
+
- `Yaml.parse` / `Yaml.parseAll` — error-recovery parsing of a single document or a `---`-separated stream into plain values, resolving anchors and aliases and aggregating every diagnostic into one `YamlParseError`.
|
|
99
|
+
- `Yaml.stringify` — serialize a plain value back to YAML, failing typed with `YamlStringifyError` on circular references or on excessively deep nesting.
|
|
100
|
+
- `Yaml.stripComments` — quote-aware comment removal that keeps line numbers stable, or every byte offset stable when given a replacement character.
|
|
101
|
+
- `Yaml.equals` / `Yaml.equalsValue` — semantic equality that ignores comments, whitespace, formatting and mapping key order while keeping sequence order significant.
|
|
102
|
+
- `Yaml.schema` / `Yaml.fromString` / `Yaml.YamlFromString` / `Yaml.allFromString` — string→domain schema factories that decode a single document or a whole stream into a validated Effect `Schema` value.
|
|
103
|
+
- `YamlNode` — an offset-preserving AST (`YamlScalar`, `YamlMap`, `YamlSeq`, `YamlPair`, `YamlAlias`) for locating and reading nodes by position or path.
|
|
104
|
+
- `YamlDocument` — the full parsed AST plus the recovered `errors` and `warnings` arrays, so a partially valid document is still inspectable.
|
|
105
|
+
- `YamlEdit` / `YamlFormat` — compute byte-minimal edit arrays for formatting and path-based modification, so a change preserves every comment and byte you did not touch.
|
|
106
|
+
- `YamlVisitor` — walk a parsed document as a `Stream` of a tagged-enum event union, with `Stream.take` early termination on large inputs.
|
|
107
|
+
- `YamlParseError` / `YamlStringifyError` / `YamlModificationError` — tagged errors carrying structured, positional `YamlDiagnostic` arrays rather than opaque messages.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
[MIT](LICENSE)
|
package/Yaml.js
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { AliasExpansionBudgetExceeded, CollectionStyle, ScalarStyle, nodeToJsValue } from "./YamlNode.js";
|
|
2
|
+
import { buildAnchorMap } from "./internal/composer/anchors.js";
|
|
3
|
+
import { composeAllDocuments, composeFirstDocument } from "./internal/composer/document.js";
|
|
4
|
+
import { isFatalCode } from "./internal/diagnostics.js";
|
|
5
|
+
import { StringifyDepthExceeded, StringifyFailure, stringifyValue } from "./internal/stringifier.js";
|
|
6
|
+
import { YamlDiagnostic } from "./YamlDiagnostic.js";
|
|
7
|
+
import { Effect, Option, Schema, SchemaIssue, SchemaTransformation } from "effect";
|
|
8
|
+
|
|
9
|
+
//#region src/Yaml.ts
|
|
10
|
+
/**
|
|
11
|
+
* Options controlling parse behavior. All fields are omissible; absent fields
|
|
12
|
+
* resolve to `strict` `true`, `maxAliasCount` `100` (the alias-based
|
|
13
|
+
* denial-of-service guard) and `uniqueKeys` `true` (duplicate mapping keys
|
|
14
|
+
* are errors).
|
|
15
|
+
*
|
|
16
|
+
* @public
|
|
17
|
+
*/
|
|
18
|
+
var YamlParseOptions = class extends Schema.Class("YamlParseOptions")({
|
|
19
|
+
strict: Schema.optionalKey(Schema.Boolean),
|
|
20
|
+
maxAliasCount: Schema.optionalKey(Schema.Number),
|
|
21
|
+
uniqueKeys: Schema.optionalKey(Schema.Boolean)
|
|
22
|
+
}) {};
|
|
23
|
+
/**
|
|
24
|
+
* Options controlling stringify behavior. All fields are omissible; absent
|
|
25
|
+
* fields resolve to `indent` `2`, `lineWidth` `80`, `defaultScalarStyle`
|
|
26
|
+
* `"plain"`, `defaultCollectionStyle` `"block"`, `sortKeys` `false`,
|
|
27
|
+
* `finalNewline` `true` and `forceDefaultStyles` `false`.
|
|
28
|
+
*
|
|
29
|
+
* @public
|
|
30
|
+
*/
|
|
31
|
+
var YamlStringifyOptions = class extends Schema.Class("YamlStringifyOptions")({
|
|
32
|
+
indent: Schema.optionalKey(Schema.Number),
|
|
33
|
+
lineWidth: Schema.optionalKey(Schema.Number),
|
|
34
|
+
defaultScalarStyle: Schema.optionalKey(ScalarStyle),
|
|
35
|
+
defaultCollectionStyle: Schema.optionalKey(CollectionStyle),
|
|
36
|
+
sortKeys: Schema.optionalKey(Schema.Boolean),
|
|
37
|
+
finalNewline: Schema.optionalKey(Schema.Boolean),
|
|
38
|
+
forceDefaultStyles: Schema.optionalKey(Schema.Boolean)
|
|
39
|
+
}) {};
|
|
40
|
+
/**
|
|
41
|
+
* Error-recovery parse failure: aggregates every fatal {@link YamlDiagnostic}
|
|
42
|
+
* encountered, so a single failure reports the whole batch. Raised by
|
|
43
|
+
* {@link Yaml.parse}, {@link Yaml.parseAll}, `YamlDocument.parse`/`parseAll`
|
|
44
|
+
* and the decode direction of the schema factories.
|
|
45
|
+
*
|
|
46
|
+
* @public
|
|
47
|
+
*/
|
|
48
|
+
var YamlParseError = class extends Schema.TaggedErrorClass()("YamlParseError", {
|
|
49
|
+
diagnostics: Schema.Array(YamlDiagnostic),
|
|
50
|
+
input: Schema.String
|
|
51
|
+
}) {
|
|
52
|
+
get message() {
|
|
53
|
+
const count = this.diagnostics.length;
|
|
54
|
+
const summary = this.diagnostics.map((d) => `${d.code} at ${d.line}:${d.character}`).join("; ");
|
|
55
|
+
return `YAML parse failed with ${count} error${count === 1 ? "" : "s"}: ${summary}`;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Stringification failure (the circular-reference guard), carrying structured
|
|
60
|
+
* {@link YamlDiagnostic} entries and the offending value. Raised by
|
|
61
|
+
* {@link Yaml.stringify}, `YamlDocument#stringify` and the encode direction of
|
|
62
|
+
* the schema factories.
|
|
63
|
+
*
|
|
64
|
+
* @public
|
|
65
|
+
*/
|
|
66
|
+
var YamlStringifyError = class extends Schema.TaggedErrorClass()("YamlStringifyError", {
|
|
67
|
+
diagnostics: Schema.Array(YamlDiagnostic),
|
|
68
|
+
value: Schema.Unknown
|
|
69
|
+
}) {
|
|
70
|
+
get message() {
|
|
71
|
+
return `YAML stringify failed: ${this.diagnostics.map((d) => d.message).join("; ")}`;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const toParseInput = (options) => options === void 0 ? {} : {
|
|
75
|
+
strict: options.strict,
|
|
76
|
+
maxAliasCount: options.maxAliasCount,
|
|
77
|
+
uniqueKeys: options.uniqueKeys
|
|
78
|
+
};
|
|
79
|
+
const toStringifyInput = (options) => options === void 0 ? {} : {
|
|
80
|
+
indent: options.indent,
|
|
81
|
+
lineWidth: options.lineWidth,
|
|
82
|
+
defaultScalarStyle: options.defaultScalarStyle,
|
|
83
|
+
defaultCollectionStyle: options.defaultCollectionStyle,
|
|
84
|
+
sortKeys: options.sortKeys,
|
|
85
|
+
finalNewline: options.finalNewline,
|
|
86
|
+
forceDefaultStyles: options.forceDefaultStyles
|
|
87
|
+
};
|
|
88
|
+
const toDiagnostics = (text, records) => records.map((r) => YamlDiagnostic.fromRaw(r, text));
|
|
89
|
+
/**
|
|
90
|
+
* Collect the raw diagnostics that make a composed document a parse failure:
|
|
91
|
+
* every fatal-code error, plus DuplicateKey warnings promoted to errors when
|
|
92
|
+
* `uniqueKeys` is in force (the v3 `parse` contract). Order preserved:
|
|
93
|
+
* fatals first, then promotions.
|
|
94
|
+
*/
|
|
95
|
+
const failureRecords = (doc, uniqueKeys) => {
|
|
96
|
+
const fatal = doc.errors.filter((e) => isFatalCode(e.code));
|
|
97
|
+
if (fatal.length > 0) return fatal;
|
|
98
|
+
return uniqueKeys ? doc.warnings.filter((w) => w.code === "DuplicateKey") : [];
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Extract a document's plain-JS value with an alias-expansion budget derived
|
|
102
|
+
* from `maxAliasCount`, materializing a "billion laughs" blow-up as a typed
|
|
103
|
+
* fatal {@link YamlParseError} (`AliasCountExceeded`) instead of letting the
|
|
104
|
+
* heap-exhausting expansion escape as an unhandled defect.
|
|
105
|
+
*/
|
|
106
|
+
const extractDocumentValue = (contents, anchors, maxAliasCount, text) => Effect.try({
|
|
107
|
+
try: () => nodeToJsValue(contents, anchors, maxAliasCount),
|
|
108
|
+
catch: (defect) => {
|
|
109
|
+
if (defect instanceof AliasExpansionBudgetExceeded) return new YamlParseError({
|
|
110
|
+
diagnostics: [YamlDiagnostic.make({
|
|
111
|
+
code: "AliasCountExceeded",
|
|
112
|
+
message: defect.message,
|
|
113
|
+
offset: 0,
|
|
114
|
+
length: 0,
|
|
115
|
+
line: 0,
|
|
116
|
+
character: 0
|
|
117
|
+
})],
|
|
118
|
+
input: text
|
|
119
|
+
});
|
|
120
|
+
throw defect;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
const stringifyOrFail = (value, options) => Effect.try({
|
|
124
|
+
try: () => stringifyValue(value, toStringifyInput(options)),
|
|
125
|
+
catch: (defect) => {
|
|
126
|
+
if (defect instanceof StringifyFailure) return new YamlStringifyError({
|
|
127
|
+
diagnostics: [YamlDiagnostic.make({
|
|
128
|
+
code: "CircularReference",
|
|
129
|
+
message: defect.reason,
|
|
130
|
+
offset: 0,
|
|
131
|
+
length: 0,
|
|
132
|
+
line: 0,
|
|
133
|
+
character: 0
|
|
134
|
+
})],
|
|
135
|
+
value
|
|
136
|
+
});
|
|
137
|
+
if (defect instanceof StringifyDepthExceeded) return new YamlStringifyError({
|
|
138
|
+
diagnostics: [YamlDiagnostic.make({
|
|
139
|
+
code: "NestingDepthExceeded",
|
|
140
|
+
message: defect.message,
|
|
141
|
+
offset: 0,
|
|
142
|
+
length: 0,
|
|
143
|
+
line: 0,
|
|
144
|
+
character: 0
|
|
145
|
+
})],
|
|
146
|
+
value
|
|
147
|
+
});
|
|
148
|
+
throw defect;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
/**
|
|
152
|
+
* Static entry points for YAML parsing, stringification, comment stripping,
|
|
153
|
+
* semantic equality and the schema factories. Not instantiable.
|
|
154
|
+
*
|
|
155
|
+
* @remarks
|
|
156
|
+
* `parse`/`parseAll`/`stringify` and the schema factories carry real typed
|
|
157
|
+
* error channels — including the hardening guards (an alias-expansion budget
|
|
158
|
+
* on decode, a nesting-depth cap on encode) that keep malformed or
|
|
159
|
+
* adversarial input on the typed channel instead of surfacing as an unhandled
|
|
160
|
+
* defect. `stripComments`/`equals`/`equalsValue` are pure total functions.
|
|
161
|
+
*
|
|
162
|
+
* @example
|
|
163
|
+
* ```ts
|
|
164
|
+
* import { Yaml } from "@effected/yaml";
|
|
165
|
+
* import { Effect } from "effect";
|
|
166
|
+
*
|
|
167
|
+
* const program = Effect.gen(function* () {
|
|
168
|
+
* const value = yield* Yaml.parse("name: Alice\nage: 30");
|
|
169
|
+
* return value; // { name: "Alice", age: 30 }
|
|
170
|
+
* });
|
|
171
|
+
* ```
|
|
172
|
+
*
|
|
173
|
+
* @public
|
|
174
|
+
*/
|
|
175
|
+
var Yaml = class Yaml {
|
|
176
|
+
constructor() {}
|
|
177
|
+
/**
|
|
178
|
+
* Parse a single YAML document into a plain JavaScript value, resolving
|
|
179
|
+
* anchors and aliases. Error-recovery parsing: collects every fatal
|
|
180
|
+
* diagnostic and fails once with the aggregate {@link YamlParseError}.
|
|
181
|
+
* Returns `unknown`, never `any`.
|
|
182
|
+
*
|
|
183
|
+
* A "billion laughs" alias-expansion blow-up (an alias chain whose
|
|
184
|
+
* resolved size grows exponentially relative to `maxAliasCount`) also
|
|
185
|
+
* fails through {@link YamlParseError} with an `AliasCountExceeded`
|
|
186
|
+
* diagnostic, never as an unhandled defect.
|
|
187
|
+
*/
|
|
188
|
+
static parse = Effect.fn("Yaml.parse")(function* (text, options) {
|
|
189
|
+
const doc = composeFirstDocument(text, toParseInput(options));
|
|
190
|
+
const failures = failureRecords(doc, options?.uniqueKeys ?? true);
|
|
191
|
+
if (failures.length > 0) return yield* new YamlParseError({
|
|
192
|
+
diagnostics: toDiagnostics(text, failures),
|
|
193
|
+
input: text
|
|
194
|
+
});
|
|
195
|
+
const anchors = /* @__PURE__ */ new Map();
|
|
196
|
+
return yield* extractDocumentValue(doc.contents, anchors, options?.maxAliasCount ?? 100, text);
|
|
197
|
+
});
|
|
198
|
+
/**
|
|
199
|
+
* Parse a multi-document YAML stream into an array of plain JavaScript
|
|
200
|
+
* values (one per document, in order). Any fatal diagnostic in any
|
|
201
|
+
* document — or a stream-level directive-placement error — fails the
|
|
202
|
+
* whole Effect with the aggregate {@link YamlParseError}.
|
|
203
|
+
*
|
|
204
|
+
* A "billion laughs" alias-expansion blow-up in any document also fails
|
|
205
|
+
* through {@link YamlParseError} with an `AliasCountExceeded` diagnostic,
|
|
206
|
+
* never as an unhandled defect.
|
|
207
|
+
*/
|
|
208
|
+
static parseAll = Effect.fn("Yaml.parseAll")(function* (text, options) {
|
|
209
|
+
const { documents, streamErrors } = composeAllDocuments(text, toParseInput(options));
|
|
210
|
+
const uniqueKeys = options?.uniqueKeys ?? true;
|
|
211
|
+
const failures = [...streamErrors.filter((e) => e.code === "InvalidDirective"), ...documents.flatMap((d) => failureRecords(d, uniqueKeys))];
|
|
212
|
+
if (failures.length > 0) return yield* new YamlParseError({
|
|
213
|
+
diagnostics: toDiagnostics(text, failures),
|
|
214
|
+
input: text
|
|
215
|
+
});
|
|
216
|
+
const maxAliasCount = options?.maxAliasCount ?? 100;
|
|
217
|
+
const values = [];
|
|
218
|
+
for (const d of documents) {
|
|
219
|
+
const anchors = buildAnchorMap(d.contents);
|
|
220
|
+
values.push(yield* extractDocumentValue(d.contents, anchors, maxAliasCount, text));
|
|
221
|
+
}
|
|
222
|
+
return values;
|
|
223
|
+
});
|
|
224
|
+
/**
|
|
225
|
+
* Stringify a plain JavaScript value as YAML. Fails with
|
|
226
|
+
* {@link YamlStringifyError} on circular references (`CircularReference`)
|
|
227
|
+
* or on a value nested deeper than the stringifier's recursion budget
|
|
228
|
+
* (`NestingDepthExceeded`) — both surface through the typed error channel
|
|
229
|
+
* rather than as an unhandled stack-overflow defect.
|
|
230
|
+
*/
|
|
231
|
+
static stringify = Effect.fn("Yaml.stringify")(function* (value, options) {
|
|
232
|
+
return yield* stringifyOrFail(value, options);
|
|
233
|
+
});
|
|
234
|
+
/**
|
|
235
|
+
* Strip comments from YAML text. Without `replaceCh`, comment characters
|
|
236
|
+
* are removed (line breaks are kept, so line numbers stay stable); with a
|
|
237
|
+
* `replaceCh` (e.g. `" "`), each comment character is replaced instead,
|
|
238
|
+
* keeping all offsets stable. Quote-aware: `#` inside quoted scalars is
|
|
239
|
+
* content, not a comment. Pure and total.
|
|
240
|
+
*/
|
|
241
|
+
static stripComments(text, replaceCh) {
|
|
242
|
+
let result = "";
|
|
243
|
+
let i = 0;
|
|
244
|
+
let inComment = false;
|
|
245
|
+
let inSingleQuote = false;
|
|
246
|
+
let inDoubleQuote = false;
|
|
247
|
+
while (i < text.length) {
|
|
248
|
+
const ch = text[i];
|
|
249
|
+
if (inComment) {
|
|
250
|
+
if (ch === "\n") {
|
|
251
|
+
inComment = false;
|
|
252
|
+
result += ch;
|
|
253
|
+
} else if (replaceCh !== void 0) result += replaceCh;
|
|
254
|
+
} else if (inDoubleQuote) {
|
|
255
|
+
result += ch;
|
|
256
|
+
if (ch === "\\" && i + 1 < text.length) {
|
|
257
|
+
i++;
|
|
258
|
+
result += text[i];
|
|
259
|
+
} else if (ch === "\"") inDoubleQuote = false;
|
|
260
|
+
} else if (inSingleQuote) {
|
|
261
|
+
result += ch;
|
|
262
|
+
if (ch === "'" && i + 1 < text.length && text[i + 1] === "'") {
|
|
263
|
+
i++;
|
|
264
|
+
result += text[i];
|
|
265
|
+
} else if (ch === "'") inSingleQuote = false;
|
|
266
|
+
} else if (ch === "\"") {
|
|
267
|
+
inDoubleQuote = true;
|
|
268
|
+
result += ch;
|
|
269
|
+
} else if (ch === "'") {
|
|
270
|
+
inSingleQuote = true;
|
|
271
|
+
result += ch;
|
|
272
|
+
} else if (ch === "#") {
|
|
273
|
+
const prev = i > 0 ? text[i - 1] : "\n";
|
|
274
|
+
if (prev === " " || prev === " " || prev === "\n" || i === 0) {
|
|
275
|
+
inComment = true;
|
|
276
|
+
if (replaceCh !== void 0) result += replaceCh;
|
|
277
|
+
} else result += ch;
|
|
278
|
+
} else result += ch;
|
|
279
|
+
i++;
|
|
280
|
+
}
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Compare two YAML strings for semantic equality: comments, whitespace,
|
|
285
|
+
* formatting and mapping key order are ignored; sequence order is
|
|
286
|
+
* significant. Malformed input is never equal to anything — parse errors
|
|
287
|
+
* (or duplicate keys) on either side yield `false` rather than comparing
|
|
288
|
+
* recovery-parser artifacts. Pure and total.
|
|
289
|
+
*/
|
|
290
|
+
static equals(a, b) {
|
|
291
|
+
const va = parseForEquality(a);
|
|
292
|
+
const vb = parseForEquality(b);
|
|
293
|
+
if (va.malformed || vb.malformed) return false;
|
|
294
|
+
return deepEqualValues(va.value, vb.value);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Compare a YAML string against an existing JavaScript value with the
|
|
298
|
+
* same semantics as {@link Yaml.equals}: malformed `text` yields `false`.
|
|
299
|
+
* Pure and total.
|
|
300
|
+
*/
|
|
301
|
+
static equalsValue(text, value) {
|
|
302
|
+
const v = parseForEquality(text);
|
|
303
|
+
if (v.malformed) return false;
|
|
304
|
+
return deepEqualValues(v.value, value);
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* A `Schema<unknown, string>` decoding a single YAML document with the
|
|
308
|
+
* given `options` (defaults when omitted) and encoding values back to
|
|
309
|
+
* YAML text with default stringify options.
|
|
310
|
+
*
|
|
311
|
+
* Schema-producing: each call returns a fresh schema whose derivation
|
|
312
|
+
* caches are not shared across calls. Bind the result to a `const` on hot
|
|
313
|
+
* paths; for the default-options case use {@link Yaml.YamlFromString}.
|
|
314
|
+
*/
|
|
315
|
+
static fromString(options) {
|
|
316
|
+
return Schema.String.pipe(Schema.decodeTo(Schema.Unknown, SchemaTransformation.transformOrFail({
|
|
317
|
+
decode: (input) => Yaml.parse(input, options).pipe(Effect.mapError((error) => new SchemaIssue.InvalidValue(Option.some(input), { message: error.message }))),
|
|
318
|
+
encode: (value) => stringifyOrFail(value).pipe(Effect.mapError((error) => new SchemaIssue.InvalidValue(Option.some(value), { message: error.message })))
|
|
319
|
+
})));
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* The zero-config `Schema<unknown, string>` — `Yaml.fromString()` with
|
|
323
|
+
* default options, pre-bound so the common case needs no memoization
|
|
324
|
+
* discipline.
|
|
325
|
+
*/
|
|
326
|
+
static YamlFromString = Yaml.fromString();
|
|
327
|
+
/**
|
|
328
|
+
* A `Schema<ReadonlyArray<unknown>, string>` decoding a multi-document
|
|
329
|
+
* YAML stream into one value per document, and encoding an array of
|
|
330
|
+
* values back into a `---`-separated stream.
|
|
331
|
+
*
|
|
332
|
+
* Schema-producing: bind the result to a `const` on hot paths (see
|
|
333
|
+
* {@link Yaml.fromString}).
|
|
334
|
+
*/
|
|
335
|
+
static allFromString(options) {
|
|
336
|
+
return Schema.String.pipe(Schema.decodeTo(Schema.Array(Schema.Unknown), SchemaTransformation.transformOrFail({
|
|
337
|
+
decode: (input) => Yaml.parseAll(input, options).pipe(Effect.mapError((error) => new SchemaIssue.InvalidValue(Option.some(input), { message: error.message }))),
|
|
338
|
+
encode: (values) => Effect.gen(function* () {
|
|
339
|
+
if (values.length === 0) return "";
|
|
340
|
+
const parts = [];
|
|
341
|
+
for (let index = 0; index < values.length; index++) {
|
|
342
|
+
const yaml = yield* stringifyOrFail(values[index]).pipe(Effect.mapError((error) => new SchemaIssue.InvalidValue(Option.some(values), { message: error.message })));
|
|
343
|
+
parts.push(index > 0 ? `---\n${yaml}` : yaml);
|
|
344
|
+
}
|
|
345
|
+
return parts.join("");
|
|
346
|
+
})
|
|
347
|
+
})));
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Compose {@link Yaml.fromString} with a target schema, yielding a
|
|
351
|
+
* `Schema<A, string>` that decodes YAML straight into a validated domain
|
|
352
|
+
* value — the single best consumer-facing feature of the library. The
|
|
353
|
+
* target's decoding/encoding service requirements flow through.
|
|
354
|
+
*
|
|
355
|
+
* Schema-producing: bind the result to a `const` on hot paths (see
|
|
356
|
+
* {@link Yaml.fromString}).
|
|
357
|
+
*/
|
|
358
|
+
static schema(target, options) {
|
|
359
|
+
return Yaml.fromString(options).pipe(Schema.decodeTo(target));
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
/**
|
|
363
|
+
* Parse for `equals`/`equalsValue`: any recorded error — fatal or not — or a
|
|
364
|
+
* DuplicateKey warning marks the input malformed (never equal to anything).
|
|
365
|
+
*/
|
|
366
|
+
function parseForEquality(text) {
|
|
367
|
+
const doc = composeFirstDocument(text, {});
|
|
368
|
+
if (doc.errors.length > 0 || doc.warnings.some((w) => w.code === "DuplicateKey")) return {
|
|
369
|
+
malformed: true,
|
|
370
|
+
value: void 0
|
|
371
|
+
};
|
|
372
|
+
const anchors = /* @__PURE__ */ new Map();
|
|
373
|
+
try {
|
|
374
|
+
return {
|
|
375
|
+
malformed: false,
|
|
376
|
+
value: nodeToJsValue(doc.contents, anchors, 100)
|
|
377
|
+
};
|
|
378
|
+
} catch (err) {
|
|
379
|
+
if (err instanceof AliasExpansionBudgetExceeded) return {
|
|
380
|
+
malformed: true,
|
|
381
|
+
value: void 0
|
|
382
|
+
};
|
|
383
|
+
throw err;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Deep structural equality over plain values: NaN equals NaN, mapping key
|
|
388
|
+
* order ignored, sequence order significant.
|
|
389
|
+
*/
|
|
390
|
+
function deepEqualValues(a, b) {
|
|
391
|
+
if (a === b) return true;
|
|
392
|
+
if (typeof a === "number" && typeof b === "number" && Number.isNaN(a) && Number.isNaN(b)) return true;
|
|
393
|
+
if (a === null || b === null) return false;
|
|
394
|
+
if (typeof a !== typeof b) return false;
|
|
395
|
+
if (Array.isArray(a)) {
|
|
396
|
+
if (!Array.isArray(b) || a.length !== b.length) return false;
|
|
397
|
+
for (let i = 0; i < a.length; i++) if (!deepEqualValues(a[i], b[i])) return false;
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
if (Array.isArray(b)) return false;
|
|
401
|
+
if (typeof a === "object" && typeof b === "object") {
|
|
402
|
+
const aObj = a;
|
|
403
|
+
const bObj = b;
|
|
404
|
+
const aKeys = Object.keys(aObj);
|
|
405
|
+
const bKeys = Object.keys(bObj);
|
|
406
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
407
|
+
for (const key of aKeys) if (!Object.hasOwn(bObj, key) || !deepEqualValues(aObj[key], bObj[key])) return false;
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
//#endregion
|
|
414
|
+
export { Yaml, YamlParseError, YamlParseOptions, YamlStringifyError, YamlStringifyOptions };
|
|
@@ -0,0 +1,126 @@
|
|
|
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 { Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/YamlDiagnostic.ts
|
|
5
|
+
/**
|
|
6
|
+
* Error codes emitted by the lexer stage.
|
|
7
|
+
*
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
const YamlLexErrorCode = Schema.Literals(YAML_LEX_ERROR_CODES);
|
|
11
|
+
/**
|
|
12
|
+
* Error codes emitted by the CST-parser stage.
|
|
13
|
+
*
|
|
14
|
+
* @public
|
|
15
|
+
*/
|
|
16
|
+
const YamlParseErrorCode = Schema.Literals(YAML_PARSE_ERROR_CODES);
|
|
17
|
+
/**
|
|
18
|
+
* Error codes emitted by the composer stage.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
const YamlComposerErrorCode = Schema.Literals(YAML_COMPOSE_ERROR_CODES);
|
|
23
|
+
/**
|
|
24
|
+
* Error codes emitted by the stringifier (the circular-reference guard).
|
|
25
|
+
*
|
|
26
|
+
* @public
|
|
27
|
+
*/
|
|
28
|
+
const YamlStringifyErrorCode = Schema.Literals(YAML_STRINGIFY_ERROR_CODES);
|
|
29
|
+
/**
|
|
30
|
+
* Error codes emitted by `YamlFormat.modify`'s path navigation against an
|
|
31
|
+
* already-composed AST — not raised by the parser/composer/stringifier.
|
|
32
|
+
*
|
|
33
|
+
* @public
|
|
34
|
+
*/
|
|
35
|
+
const YamlModifyErrorCode = Schema.Literals(YAML_MODIFY_ERROR_CODES);
|
|
36
|
+
/**
|
|
37
|
+
* Union of all YAML error codes across all pipeline stages. Stage
|
|
38
|
+
* discrimination lives here (in the code), not in separate error classes.
|
|
39
|
+
*
|
|
40
|
+
* @public
|
|
41
|
+
*/
|
|
42
|
+
const YamlErrorCode = Schema.Union([
|
|
43
|
+
YamlLexErrorCode,
|
|
44
|
+
YamlParseErrorCode,
|
|
45
|
+
YamlComposerErrorCode,
|
|
46
|
+
YamlStringifyErrorCode,
|
|
47
|
+
YamlModifyErrorCode
|
|
48
|
+
]);
|
|
49
|
+
/**
|
|
50
|
+
* One structured diagnostic: its {@link (YamlErrorCode:type)}, a
|
|
51
|
+
* human-readable `message`, and its exact position (`offset`/`length`, plus
|
|
52
|
+
* zero-based `line`/`character`). Used for both errors and warnings-as-data;
|
|
53
|
+
* fatality is a property of the code — see {@link YamlDiagnostic.isFatal}.
|
|
54
|
+
*
|
|
55
|
+
* @remarks
|
|
56
|
+
* The five-field positional core (`code`/`offset`/`length`/`line`/`character`)
|
|
57
|
+
* is structurally identical to `@effected/jsonc`'s parse-error detail shape;
|
|
58
|
+
* `message` is this package's additive extra.
|
|
59
|
+
*
|
|
60
|
+
* @public
|
|
61
|
+
*/
|
|
62
|
+
var YamlDiagnostic = class YamlDiagnostic extends Schema.Class("YamlDiagnostic")({
|
|
63
|
+
code: YamlErrorCode,
|
|
64
|
+
message: Schema.String,
|
|
65
|
+
offset: Schema.Number,
|
|
66
|
+
length: Schema.Number,
|
|
67
|
+
line: Schema.Number,
|
|
68
|
+
character: Schema.Number
|
|
69
|
+
}) {
|
|
70
|
+
/**
|
|
71
|
+
* The single fatal-code predicate: whether diagnostics with this code
|
|
72
|
+
* abort a parse (vs. being recoverable warnings-as-data). Declared once,
|
|
73
|
+
* as a property of the code — replacing the v3 source's three
|
|
74
|
+
* subtly-differing inline fatal lists.
|
|
75
|
+
*/
|
|
76
|
+
static isFatal(code) {
|
|
77
|
+
return isFatalCode(code);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Materialize a raw engine diagnostic record into a `YamlDiagnostic`,
|
|
81
|
+
* deriving `line`/`character` from `offset` against the source `text`.
|
|
82
|
+
* Advanced — the parse/stringify entry points call this for you.
|
|
83
|
+
*/
|
|
84
|
+
static fromRaw(raw, text) {
|
|
85
|
+
const { line, character } = lineChar(text, raw.offset);
|
|
86
|
+
return YamlDiagnostic.make({
|
|
87
|
+
code: raw.code,
|
|
88
|
+
message: raw.message,
|
|
89
|
+
offset: raw.offset,
|
|
90
|
+
length: raw.length,
|
|
91
|
+
line,
|
|
92
|
+
character
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Compute the zero-based line/character position of `offset` within `text`.
|
|
98
|
+
* Recognizes `\n`, `\r`, `\r\n`, LS and PS as line breaks, matching the
|
|
99
|
+
* jsonc counterpart so positions are codec-generic.
|
|
100
|
+
*/
|
|
101
|
+
function lineChar(text, offset) {
|
|
102
|
+
let line = 0;
|
|
103
|
+
let lineStart = 0;
|
|
104
|
+
const limit = Math.min(offset, text.length);
|
|
105
|
+
for (let i = 0; i < limit; i++) {
|
|
106
|
+
const ch = text.charCodeAt(i);
|
|
107
|
+
if (ch === 10) {
|
|
108
|
+
line++;
|
|
109
|
+
lineStart = i + 1;
|
|
110
|
+
} else if (ch === 13) {
|
|
111
|
+
if (i + 1 < text.length && text.charCodeAt(i + 1) === 10) i++;
|
|
112
|
+
line++;
|
|
113
|
+
lineStart = i + 1;
|
|
114
|
+
} else if (ch === 8232 || ch === 8233) {
|
|
115
|
+
line++;
|
|
116
|
+
lineStart = i + 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
line,
|
|
121
|
+
character: offset - lineStart
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
//#endregion
|
|
126
|
+
export { YamlComposerErrorCode, YamlDiagnostic, YamlErrorCode, YamlLexErrorCode, YamlModifyErrorCode, YamlParseErrorCode, YamlStringifyErrorCode };
|