@safeaccess/inline 0.1.1

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 (129) hide show
  1. package/.gitattributes +16 -0
  2. package/.gitkeep +0 -0
  3. package/CHANGELOG.md +38 -0
  4. package/LICENSE +21 -0
  5. package/README.md +454 -0
  6. package/benchmarks/get.bench.ts +26 -0
  7. package/benchmarks/parse.bench.ts +41 -0
  8. package/dist/accessors/abstract-accessor.d.ts +213 -0
  9. package/dist/accessors/abstract-accessor.js +294 -0
  10. package/dist/accessors/formats/any-accessor.d.ts +35 -0
  11. package/dist/accessors/formats/any-accessor.js +44 -0
  12. package/dist/accessors/formats/array-accessor.d.ts +26 -0
  13. package/dist/accessors/formats/array-accessor.js +39 -0
  14. package/dist/accessors/formats/env-accessor.d.ts +27 -0
  15. package/dist/accessors/formats/env-accessor.js +64 -0
  16. package/dist/accessors/formats/ini-accessor.d.ts +41 -0
  17. package/dist/accessors/formats/ini-accessor.js +109 -0
  18. package/dist/accessors/formats/json-accessor.d.ts +26 -0
  19. package/dist/accessors/formats/json-accessor.js +56 -0
  20. package/dist/accessors/formats/ndjson-accessor.d.ts +28 -0
  21. package/dist/accessors/formats/ndjson-accessor.js +71 -0
  22. package/dist/accessors/formats/object-accessor.d.ts +48 -0
  23. package/dist/accessors/formats/object-accessor.js +90 -0
  24. package/dist/accessors/formats/xml-accessor.d.ts +27 -0
  25. package/dist/accessors/formats/xml-accessor.js +52 -0
  26. package/dist/accessors/formats/yaml-accessor.d.ts +29 -0
  27. package/dist/accessors/formats/yaml-accessor.js +46 -0
  28. package/dist/contracts/accessors-interface.d.ts +11 -0
  29. package/dist/contracts/accessors-interface.js +1 -0
  30. package/dist/contracts/factory-accessors-interface.d.ts +16 -0
  31. package/dist/contracts/factory-accessors-interface.js +1 -0
  32. package/dist/contracts/parse-integration-interface.d.ts +31 -0
  33. package/dist/contracts/parse-integration-interface.js +1 -0
  34. package/dist/contracts/path-cache-interface.d.ts +40 -0
  35. package/dist/contracts/path-cache-interface.js +1 -0
  36. package/dist/contracts/readable-accessors-interface.d.ts +79 -0
  37. package/dist/contracts/readable-accessors-interface.js +1 -0
  38. package/dist/contracts/security-guard-interface.d.ts +40 -0
  39. package/dist/contracts/security-guard-interface.js +1 -0
  40. package/dist/contracts/security-parser-interface.d.ts +67 -0
  41. package/dist/contracts/security-parser-interface.js +1 -0
  42. package/dist/contracts/writable-accessors-interface.d.ts +65 -0
  43. package/dist/contracts/writable-accessors-interface.js +1 -0
  44. package/dist/core/dot-notation-parser.d.ts +204 -0
  45. package/dist/core/dot-notation-parser.js +343 -0
  46. package/dist/exceptions/accessor-exception.d.ts +13 -0
  47. package/dist/exceptions/accessor-exception.js +16 -0
  48. package/dist/exceptions/invalid-format-exception.d.ts +14 -0
  49. package/dist/exceptions/invalid-format-exception.js +17 -0
  50. package/dist/exceptions/parser-exception.d.ts +14 -0
  51. package/dist/exceptions/parser-exception.js +17 -0
  52. package/dist/exceptions/path-not-found-exception.d.ts +14 -0
  53. package/dist/exceptions/path-not-found-exception.js +17 -0
  54. package/dist/exceptions/readonly-violation-exception.d.ts +15 -0
  55. package/dist/exceptions/readonly-violation-exception.js +18 -0
  56. package/dist/exceptions/security-exception.d.ts +18 -0
  57. package/dist/exceptions/security-exception.js +21 -0
  58. package/dist/exceptions/unsupported-type-exception.d.ts +14 -0
  59. package/dist/exceptions/unsupported-type-exception.js +17 -0
  60. package/dist/exceptions/yaml-parse-exception.d.ts +17 -0
  61. package/dist/exceptions/yaml-parse-exception.js +20 -0
  62. package/dist/index.d.ts +30 -0
  63. package/dist/index.js +30 -0
  64. package/dist/inline.d.ts +402 -0
  65. package/dist/inline.js +512 -0
  66. package/dist/parser/xml-parser.d.ts +46 -0
  67. package/dist/parser/xml-parser.js +288 -0
  68. package/dist/parser/yaml-parser.d.ts +94 -0
  69. package/dist/parser/yaml-parser.js +286 -0
  70. package/dist/security/forbidden-keys.d.ts +34 -0
  71. package/dist/security/forbidden-keys.js +80 -0
  72. package/dist/security/security-guard.d.ts +94 -0
  73. package/dist/security/security-guard.js +172 -0
  74. package/dist/security/security-parser.d.ts +130 -0
  75. package/dist/security/security-parser.js +192 -0
  76. package/dist/type-format.d.ts +28 -0
  77. package/dist/type-format.js +29 -0
  78. package/eslint.config.js +1 -0
  79. package/package.json +39 -0
  80. package/src/accessors/abstract-accessor.ts +353 -0
  81. package/src/accessors/formats/any-accessor.ts +51 -0
  82. package/src/accessors/formats/array-accessor.ts +45 -0
  83. package/src/accessors/formats/env-accessor.ts +79 -0
  84. package/src/accessors/formats/ini-accessor.ts +124 -0
  85. package/src/accessors/formats/json-accessor.ts +66 -0
  86. package/src/accessors/formats/ndjson-accessor.ts +82 -0
  87. package/src/accessors/formats/object-accessor.ts +100 -0
  88. package/src/accessors/formats/xml-accessor.ts +58 -0
  89. package/src/accessors/formats/yaml-accessor.ts +52 -0
  90. package/src/contracts/accessors-interface.ts +12 -0
  91. package/src/contracts/factory-accessors-interface.ts +16 -0
  92. package/src/contracts/parse-integration-interface.ts +32 -0
  93. package/src/contracts/path-cache-interface.ts +43 -0
  94. package/src/contracts/readable-accessors-interface.ts +88 -0
  95. package/src/contracts/security-guard-interface.ts +43 -0
  96. package/src/contracts/security-parser-interface.ts +74 -0
  97. package/src/contracts/writable-accessors-interface.ts +70 -0
  98. package/src/core/dot-notation-parser.ts +419 -0
  99. package/src/exceptions/accessor-exception.ts +16 -0
  100. package/src/exceptions/invalid-format-exception.ts +18 -0
  101. package/src/exceptions/parser-exception.ts +18 -0
  102. package/src/exceptions/path-not-found-exception.ts +18 -0
  103. package/src/exceptions/readonly-violation-exception.ts +19 -0
  104. package/src/exceptions/security-exception.ts +22 -0
  105. package/src/exceptions/unsupported-type-exception.ts +18 -0
  106. package/src/exceptions/yaml-parse-exception.ts +21 -0
  107. package/src/index.ts +46 -0
  108. package/src/inline.ts +570 -0
  109. package/src/parser/xml-parser.ts +334 -0
  110. package/src/parser/yaml-parser.ts +368 -0
  111. package/src/security/forbidden-keys.ts +81 -0
  112. package/src/security/security-guard.ts +195 -0
  113. package/src/security/security-parser.ts +233 -0
  114. package/src/type-format.ts +28 -0
  115. package/stryker.config.json +24 -0
  116. package/tests/accessors/accessors.test.ts +1017 -0
  117. package/tests/accessors/json-accessor.test.ts +171 -0
  118. package/tests/core/dot-notation-parser.test.ts +587 -0
  119. package/tests/exceptions/parser-exception.test.ts +31 -0
  120. package/tests/inline.test.ts +445 -0
  121. package/tests/mocks/fake-parse-integration.ts +24 -0
  122. package/tests/mocks/fake-path-cache.ts +31 -0
  123. package/tests/parity.test.ts +164 -0
  124. package/tests/parser/xml-parser.test.ts +618 -0
  125. package/tests/parser/yaml-parser.test.ts +463 -0
  126. package/tests/security/security-guard.test.ts +646 -0
  127. package/tests/security/security-parser.test.ts +391 -0
  128. package/tsconfig.json +16 -0
  129. package/vitest.config.ts +19 -0
@@ -0,0 +1,334 @@
1
+ import { InvalidFormatException } from '../exceptions/invalid-format-exception.js';
2
+ import { SecurityException } from '../exceptions/security-exception.js';
3
+
4
+ /**
5
+ * Internal XML-to-object parser for XmlAccessor.
6
+ *
7
+ * Provides both a browser path (DOMParser) and a minimal manual parser
8
+ * for Node.js environments. Does not depend on external XML libraries.
9
+ *
10
+ * @internal
11
+ */
12
+ export class XmlParser {
13
+ private readonly maxDepth: number;
14
+ private readonly maxElements: number;
15
+
16
+ /**
17
+ * @param maxDepth - Maximum structural depth allowed.
18
+ * @param maxElements - Maximum number of opening-tag occurrences allowed in the
19
+ * Node.js manual-parser path before parsing is aborted. Acts as a complexity
20
+ * bound and defence-in-depth against document-bombing. Defaults to 10 000
21
+ * (matches `SecurityParser.maxKeys`). Non-positive, non-finite, or `NaN` values
22
+ * are clamped to 10 000 to prevent accidental guard disablement.
23
+ */
24
+ constructor(maxDepth: number, maxElements: number = 10_000) {
25
+ this.maxDepth = maxDepth;
26
+ this.maxElements = Number.isFinite(maxElements) && maxElements >= 1
27
+ ? maxElements
28
+ : 10_000;
29
+ }
30
+
31
+ /**
32
+ * Parse an XML body into a plain object using the best available parser.
33
+ *
34
+ * @param xml - Raw XML content (must not contain DOCTYPE).
35
+ * @returns Parsed data structure.
36
+ * @throws {InvalidFormatException} When XML is malformed.
37
+ * @throws {SecurityException} When structural depth exceeds limit.
38
+ *
39
+ * @example
40
+ * new XmlParser(10).parse('<root><key>value</key></root>'); // { key: 'value' }
41
+ */
42
+ parse(xml: string): Record<string, unknown> {
43
+ if (typeof DOMParser !== 'undefined') {
44
+ return this.parseBrowserXml(xml);
45
+ }
46
+
47
+ return this.parseXmlManual(xml);
48
+ }
49
+
50
+ private parseBrowserXml(xml: string): Record<string, unknown> {
51
+ const parser = new DOMParser();
52
+ const doc = parser.parseFromString(xml, 'application/xml');
53
+
54
+ const parserError = doc.querySelector('parsererror');
55
+ if (parserError !== null) {
56
+ throw new InvalidFormatException(
57
+ `XmlAccessor failed to parse XML: ${parserError.textContent ?? 'Unknown error'}`,
58
+ );
59
+ }
60
+
61
+ const root = doc.documentElement;
62
+ if (root === null) {
63
+ return {};
64
+ }
65
+
66
+ return this.elementToRecord(root, 0);
67
+ }
68
+
69
+ private elementToRecord(element: Element, depth: number): Record<string, unknown> {
70
+ if (depth > this.maxDepth) {
71
+ throw new SecurityException(
72
+ `XML structural depth ${depth} exceeds maximum of ${this.maxDepth}.`,
73
+ );
74
+ }
75
+
76
+ const result: Record<string, unknown> = {};
77
+
78
+ for (const attr of Array.from(element.attributes)) {
79
+ if (attr !== undefined) {
80
+ result[`@${attr.name}`] = attr.value;
81
+ }
82
+ }
83
+
84
+ for (const child of Array.from(element.childNodes)) {
85
+ if (child === undefined) continue;
86
+
87
+ if (child.nodeType === 3) {
88
+ const text = child.textContent?.trim() ?? '';
89
+ if (text !== '') {
90
+ result['#text'] = text;
91
+ }
92
+ } else if (child.nodeType === 1) {
93
+ const childEl = child as Element;
94
+ const name = childEl.nodeName;
95
+ const childData = this.elementToRecord(childEl, depth + 1);
96
+
97
+ if (Object.prototype.hasOwnProperty.call(result, name)) {
98
+ const existing = result[name];
99
+ if (Array.isArray(existing)) {
100
+ existing.push(childData);
101
+ } else {
102
+ result[name] = [existing, childData];
103
+ }
104
+ } else {
105
+ result[name] = childData;
106
+ }
107
+ }
108
+ }
109
+
110
+ return result;
111
+ }
112
+
113
+ private parseXmlManual(xml: string): Record<string, unknown> {
114
+ const stripped = xml.replace(/<\?xml[^?]*\?>/i, '').trim();
115
+
116
+ // Bound parser complexity before the linear inner-content scan: count opening
117
+ // tags as a document-complexity proxy. This is a defence-in-depth limit —
118
+ // the linear scanner below is already O(n), but bounding element count also
119
+ // caps the total number of recursive parseXmlChildren calls.
120
+ // Browser environments (DOMParser) are unaffected.
121
+ const elementCount = (stripped.match(/<[a-zA-Z_]/g) ?? []).length;
122
+ if (elementCount > this.maxElements) {
123
+ throw new SecurityException(
124
+ `XML element count ${elementCount} exceeds maximum of ${this.maxElements}.`,
125
+ );
126
+ }
127
+
128
+ return this.extractRootContent(stripped);
129
+ }
130
+
131
+ /**
132
+ * Extract root element inner content using an indexOf-based O(n) scan.
133
+ *
134
+ * Replaces the previous backreference regex to guarantee O(n) time regardless
135
+ * of document structure. The closing-tag match is verified by scanning
136
+ * backwards from the final `>` character of the trimmed document.
137
+ */
138
+ private extractRootContent(doc: string): Record<string, unknown> {
139
+ if (doc.length < 2 || doc[0] !== '<' || !/[a-zA-Z_]/.test(doc[1] as string)) {
140
+ throw new InvalidFormatException('XmlAccessor failed to parse XML string.');
141
+ }
142
+
143
+ // Scan root tag name: [a-zA-Z_][\w.-]*
144
+ let nameEnd = 2;
145
+ while (nameEnd < doc.length && /[\w.-]/.test(doc[nameEnd] as string)) {
146
+ nameEnd++;
147
+ }
148
+ const tagName = doc.slice(1, nameEnd);
149
+
150
+ // Locate the '>' that closes the root opening tag (attributes may not contain '>').
151
+ const openGt = doc.indexOf('>', nameEnd);
152
+ if (openGt === -1) {
153
+ throw new InvalidFormatException('XmlAccessor failed to parse XML string.');
154
+ }
155
+
156
+ // Self-closing element: opening tag body ends with '/'.
157
+ if (doc.slice(nameEnd, openGt).trimEnd().endsWith('/')) {
158
+ // Self-closing tag must occupy the whole document.
159
+ if (openGt !== doc.length - 1) {
160
+ throw new InvalidFormatException('XmlAccessor failed to parse XML string.');
161
+ }
162
+ return {};
163
+ }
164
+
165
+ // The trimmed document must end with '>'.
166
+ if (doc[doc.length - 1] !== '>') {
167
+ throw new InvalidFormatException('XmlAccessor failed to parse XML string.');
168
+ }
169
+
170
+ // Walk backward from the final '>' to locate the closing tag for this root element.
171
+ // This is O(tagNameLen) — the tag name is typically short and always bounded.
172
+ let pos = doc.length - 2;
173
+ while (pos >= 0 && (doc[pos] === ' ' || doc[pos] === '\t' || doc[pos] === '\n' || doc[pos] === '\r')) {
174
+ pos--;
175
+ }
176
+
177
+ // pos must point to the last char of the root tag name.
178
+ const nameStart = pos - tagName.length + 1;
179
+ if (nameStart < 2 || doc.slice(nameStart, pos + 1) !== tagName) {
180
+ throw new InvalidFormatException('XmlAccessor failed to parse XML string.');
181
+ }
182
+
183
+ // The two chars before the tag name must be '</'.
184
+ if (doc[nameStart - 1] !== '/' || doc[nameStart - 2] !== '<') {
185
+ throw new InvalidFormatException('XmlAccessor failed to parse XML string.');
186
+ }
187
+
188
+ const closeTagStart = nameStart - 2;
189
+ if (closeTagStart <= openGt) {
190
+ throw new InvalidFormatException('XmlAccessor failed to parse XML string.');
191
+ }
192
+
193
+ const innerContent = doc.slice(openGt + 1, closeTagStart);
194
+ return this.parseXmlChildren(innerContent, 0);
195
+ }
196
+
197
+ private parseXmlChildren(content: string, depth: number): Record<string, unknown> {
198
+ if (depth > this.maxDepth) {
199
+ throw new SecurityException(
200
+ `XML structural depth ${depth} exceeds maximum of ${this.maxDepth}.`,
201
+ );
202
+ }
203
+
204
+ const result: Record<string, unknown> = {};
205
+ let i = 0;
206
+ let hasElements = false;
207
+
208
+ while (i < content.length) {
209
+ const lt = content.indexOf('<', i);
210
+ if (lt === -1) break;
211
+
212
+ const nextChar = content[lt + 1];
213
+ if (nextChar === undefined) break;
214
+
215
+ // Skip closing tags, comments, and processing instructions
216
+ if (nextChar === '/' || nextChar === '!' || nextChar === '?') {
217
+ const gt = content.indexOf('>', lt);
218
+ i = gt === -1 ? content.length : gt + 1;
219
+ continue;
220
+ }
221
+
222
+ // Tag name must start with a valid XML name-start character
223
+ if (!/[a-zA-Z_]/.test(nextChar)) {
224
+ i = lt + 1;
225
+ continue;
226
+ }
227
+
228
+ // Extract tag name: [a-zA-Z_][\w.-]*
229
+ let nameEnd = lt + 2;
230
+ while (nameEnd < content.length && /[\w.-]/.test(content[nameEnd] as string)) {
231
+ nameEnd++;
232
+ }
233
+ const tagName = content.slice(lt + 1, nameEnd);
234
+
235
+ const gt = content.indexOf('>', nameEnd);
236
+ if (gt === -1) break;
237
+
238
+ // Self-closing tag e.g. <tag/> or <tag attr="v"/>
239
+ if (content.slice(nameEnd, gt).trimEnd().endsWith('/')) {
240
+ hasElements = true;
241
+ this.addChild(result, tagName, '');
242
+ i = gt + 1;
243
+ continue;
244
+ }
245
+
246
+ // Locate the matching closing </tagName> using a nesting counter.
247
+ // This avoids regex backreferences and runs in O(n) time.
248
+ const closeTag = `</${tagName}`;
249
+ const openPrefix = `<${tagName}`;
250
+ let nestDepth = 1;
251
+ let pos = gt + 1;
252
+ let innerEnd = -1;
253
+ let afterClose = content.length;
254
+
255
+ while (pos < content.length && nestDepth > 0) {
256
+ const nextLt = content.indexOf('<', pos);
257
+ if (nextLt === -1) break;
258
+
259
+ if (content.startsWith(closeTag, nextLt)) {
260
+ const c = content[nextLt + closeTag.length];
261
+ if (c === '>' || c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === undefined) {
262
+ nestDepth--;
263
+ if (nestDepth === 0) {
264
+ innerEnd = nextLt;
265
+ const cgt = content.indexOf('>', nextLt + closeTag.length);
266
+ afterClose = cgt === -1 ? content.length : cgt + 1;
267
+ break;
268
+ }
269
+ pos = nextLt + closeTag.length;
270
+ continue;
271
+ }
272
+ }
273
+
274
+ if (content.startsWith(openPrefix, nextLt)) {
275
+ const c = content[nextLt + openPrefix.length];
276
+ if (c === '>' || c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '/') {
277
+ const ogt = content.indexOf('>', nextLt + openPrefix.length);
278
+ if (ogt !== -1 && !content.slice(nextLt + openPrefix.length, ogt).trimEnd().endsWith('/')) {
279
+ nestDepth++;
280
+ }
281
+ }
282
+ }
283
+
284
+ pos = nextLt + 1;
285
+ }
286
+
287
+ if (innerEnd === -1) {
288
+ // Unclosed or malformed tag — skip past the opening tag
289
+ i = gt + 1;
290
+ continue;
291
+ }
292
+
293
+ const inner = content.slice(gt + 1, innerEnd);
294
+ const trimmedInner = inner.trim();
295
+ let value: unknown;
296
+
297
+ if (trimmedInner !== '' && /<[a-zA-Z]/.test(trimmedInner)) {
298
+ const childResult = this.parseXmlChildren(trimmedInner, depth + 1);
299
+ value =
300
+ Object.keys(childResult).length === 1 && '#text' in childResult
301
+ ? childResult['#text']
302
+ : childResult;
303
+ } else {
304
+ value = trimmedInner;
305
+ }
306
+
307
+ hasElements = true;
308
+ this.addChild(result, tagName, value);
309
+ i = afterClose;
310
+ }
311
+
312
+ if (!hasElements) {
313
+ const text = content.trim();
314
+ if (text !== '') {
315
+ result['#text'] = text;
316
+ }
317
+ }
318
+
319
+ return result;
320
+ }
321
+
322
+ private addChild(result: Record<string, unknown>, tagName: string, value: unknown): void {
323
+ if (Object.prototype.hasOwnProperty.call(result, tagName)) {
324
+ const existing = result[tagName];
325
+ if (Array.isArray(existing)) {
326
+ existing.push(value);
327
+ } else {
328
+ result[tagName] = [existing, value];
329
+ }
330
+ } else {
331
+ result[tagName] = value;
332
+ }
333
+ }
334
+ }
@@ -0,0 +1,368 @@
1
+ import { YamlParseException } from '../exceptions/yaml-parse-exception.js';
2
+
3
+ /**
4
+ * Minimal YAML parser supporting a safe subset of YAML 1.2.
5
+ *
6
+ * Parses scalars, maps, sequences, and inline values. Blocks unsafe constructs:
7
+ * tags (!! and !), anchors (&), aliases (*), and merge keys (<<).
8
+ *
9
+ * Does not depend on external YAML libraries, making the package portable.
10
+ */
11
+ export class YamlParser {
12
+ /**
13
+ * Parse a YAML string into a plain object.
14
+ *
15
+ * @param yaml - Raw YAML content.
16
+ * @returns Parsed data structure.
17
+ * @throws {YamlParseException} When unsafe constructs or syntax errors are found.
18
+ *
19
+ * @example
20
+ * new YamlParser().parse('key: value'); // { key: 'value' }
21
+ */
22
+ parse(yaml: string): Record<string, unknown> {
23
+ const lines = yaml.replace(/\r\n/g, '\n').split('\n');
24
+ this.assertNoUnsafeConstructs(lines);
25
+ const result = this.parseLines(lines, 0, 0, lines.length);
26
+ if (Array.isArray(result)) {
27
+ return {};
28
+ }
29
+ return result as Record<string, unknown>;
30
+ }
31
+
32
+ /**
33
+ * Reject YAML constructs that are unsafe or unsupported.
34
+ *
35
+ * @param lines - Raw YAML lines to scan.
36
+ * @throws {YamlParseException} When tags, anchors, aliases, or merge keys are found.
37
+ */
38
+ private assertNoUnsafeConstructs(lines: string[]): void {
39
+ for (const [i, rawLine] of lines.entries()) {
40
+ const trimmed = rawLine.trimStart();
41
+
42
+ if (trimmed === '' || trimmed.startsWith('#')) {
43
+ continue;
44
+ }
45
+
46
+ // Block !! and ! tags (but not inside quotes)
47
+ if (/(?<!['"!])!{1,2}[\w</]/.test(trimmed)) {
48
+ throw new YamlParseException(
49
+ `Unsupported YAML tag at line ${i + 1}: tags (! and !! syntax) are not supported.`,
50
+ );
51
+ }
52
+
53
+ if (/(?:^|\s)&\w+/.test(trimmed)) {
54
+ throw new YamlParseException(`YAML anchors are not supported (line ${i + 1}).`);
55
+ }
56
+
57
+ if (/(?:^|\s)\*\w+/.test(trimmed)) {
58
+ throw new YamlParseException(`YAML aliases are not supported (line ${i + 1}).`);
59
+ }
60
+
61
+ if (/^(\s*)<<\s*:/.test(rawLine)) {
62
+ throw new YamlParseException(
63
+ `YAML merge keys (<<) are not supported (line ${i + 1}).`,
64
+ );
65
+ }
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Parse a range of lines into a YAML value (map, sequence, or scalar).
71
+ *
72
+ * @param lines - All lines of the YAML document.
73
+ * @param baseIndent - Indentation level for this block.
74
+ * @param start - First line index (inclusive).
75
+ * @param end - Last line index (exclusive).
76
+ * @returns Parsed value.
77
+ */
78
+ private parseLines(lines: string[], baseIndent: number, start: number, end: number): unknown {
79
+ const mapResult: Record<string, unknown> = {};
80
+ const arrResult: unknown[] = [];
81
+ let isSequence = false;
82
+ let i = start;
83
+
84
+ while (i < end) {
85
+ const line = lines[i] as string;
86
+ const trimmed = line.trimStart();
87
+
88
+ if (trimmed === '' || trimmed.startsWith('#')) {
89
+ i++;
90
+ continue;
91
+ }
92
+
93
+ const currentIndent = line.length - trimmed.length;
94
+
95
+ /* c8 ignore start */
96
+ if (currentIndent < baseIndent) {
97
+ break;
98
+ }
99
+ /* c8 ignore stop */
100
+
101
+ if (currentIndent > baseIndent) {
102
+ i++;
103
+ continue;
104
+ }
105
+
106
+ // Sequence item
107
+ if (trimmed.startsWith('- ') || trimmed === '-') {
108
+ isSequence = true;
109
+ const itemContent = trimmed === '-' ? '' : trimmed.slice(2).trim();
110
+
111
+ const match =
112
+ itemContent !== '' ? /^([^\s:][^:]*?)\s*:\s*(.*)$/.exec(itemContent) : null;
113
+ if (match !== null) {
114
+ const childIndent = currentIndent + 2;
115
+ const childEnd = this.findBlockEnd(lines, childIndent, i + 1, end);
116
+ const subMap: Record<string, unknown> = {};
117
+ subMap[match[1] as string] = this.resolveValue(
118
+ match[2] as string,
119
+ lines,
120
+ i,
121
+ childIndent,
122
+ childEnd,
123
+ );
124
+ this.mergeChildLines(lines, i + 1, childEnd, childIndent, subMap);
125
+ arrResult.push(subMap);
126
+ i = childEnd;
127
+ } else if (itemContent === '') {
128
+ const childIndent = currentIndent + 2;
129
+ const childEnd = this.findBlockEnd(lines, childIndent, i + 1, end);
130
+ if (childEnd > i + 1) {
131
+ arrResult.push(this.parseLines(lines, childIndent, i + 1, childEnd));
132
+ i = childEnd;
133
+ } else {
134
+ arrResult.push(null);
135
+ i++;
136
+ }
137
+ } else {
138
+ arrResult.push(this.castScalar(itemContent));
139
+ i++;
140
+ }
141
+ continue;
142
+ }
143
+
144
+ // Map key: value
145
+ const mapMatch = /^([^\s:][^:]*?)\s*:\s*(.*)$/.exec(trimmed);
146
+ if (mapMatch !== null) {
147
+ const key = mapMatch[1] as string;
148
+ const rawValue = mapMatch[2] as string;
149
+ const childIndent = currentIndent + 2;
150
+ const childEnd = this.findBlockEnd(lines, childIndent, i + 1, end);
151
+ mapResult[key] = this.resolveValue(rawValue, lines, i, childIndent, childEnd);
152
+ i = childEnd;
153
+ continue;
154
+ }
155
+
156
+ i++;
157
+ }
158
+
159
+ if (isSequence) {
160
+ return arrResult;
161
+ }
162
+
163
+ return mapResult;
164
+ }
165
+
166
+ /**
167
+ * Merge child key-value lines into an existing map.
168
+ *
169
+ * @param lines - All lines of the YAML document.
170
+ * @param start - First child line index (inclusive).
171
+ * @param end - Last child line index (exclusive).
172
+ * @param childIndent - Expected indentation for child lines.
173
+ * @param map - Map to merge values into.
174
+ */
175
+ private mergeChildLines(
176
+ lines: string[],
177
+ start: number,
178
+ end: number,
179
+ childIndent: number,
180
+ map: Record<string, unknown>,
181
+ ): void {
182
+ let ci = start;
183
+ while (ci < end) {
184
+ const childLine = lines[ci] as string;
185
+ const childTrimmed = childLine.trimStart();
186
+
187
+ if (childTrimmed === '' || childTrimmed.startsWith('#')) {
188
+ ci++;
189
+ continue;
190
+ }
191
+
192
+ const childCurrentIndent = childLine.length - childTrimmed.length;
193
+
194
+ if (childCurrentIndent === childIndent) {
195
+ const cm = /^([^\s:][^:]*?)\s*:\s*(.*)$/.exec(childTrimmed);
196
+ if (cm !== null) {
197
+ const nextChildEnd = this.findBlockEnd(
198
+ lines,
199
+ childCurrentIndent + 2,
200
+ ci + 1,
201
+ end,
202
+ );
203
+ map[cm[1] as string] = this.resolveValue(
204
+ cm[2] as string,
205
+ lines,
206
+ ci,
207
+ childCurrentIndent + 2,
208
+ nextChildEnd,
209
+ );
210
+ ci = nextChildEnd;
211
+ continue;
212
+ }
213
+ }
214
+
215
+ ci++;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Resolve a raw value string into a typed value.
221
+ *
222
+ * @param rawValue - The value portion after the colon.
223
+ * @param lines - All lines of the YAML document.
224
+ * @param lineIndex - Line where the value was found.
225
+ * @param childIndent - Expected indentation for child block.
226
+ * @param childEnd - End index of the child block.
227
+ * @returns Resolved typed value.
228
+ */
229
+ private resolveValue(
230
+ rawValue: string,
231
+ lines: string[],
232
+ lineIndex: number,
233
+ childIndent: number,
234
+ childEnd: number,
235
+ ): unknown {
236
+ const trimmed = rawValue.trim();
237
+
238
+ // Block scalar literal (|) or folded (>)
239
+ if (trimmed === '|' || trimmed === '>') {
240
+ return this.parseBlockScalar(
241
+ lines,
242
+ lineIndex + 1,
243
+ childEnd,
244
+ childIndent,
245
+ trimmed === '>',
246
+ );
247
+ }
248
+
249
+ // Inline flow (array or map) starting with [ or {
250
+ if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
251
+ return this.parseInlineFlow(trimmed);
252
+ }
253
+
254
+ // Nested block
255
+ if (trimmed === '' && childEnd > lineIndex + 1) {
256
+ return this.parseLines(lines, childIndent, lineIndex + 1, childEnd);
257
+ }
258
+
259
+ return this.castScalar(trimmed);
260
+ }
261
+
262
+ /**
263
+ * Parse a YAML block scalar (literal | or folded >).
264
+ *
265
+ * @param lines - All lines of the YAML document.
266
+ * @param start - First line of the scalar block (inclusive).
267
+ * @param end - Last line of the scalar block (exclusive).
268
+ * @param indent - Minimum indentation for scalar lines.
269
+ * @param folded - Whether to fold lines (> style) or keep literal (| style).
270
+ * @returns The assembled string.
271
+ */
272
+ private parseBlockScalar(
273
+ lines: string[],
274
+ start: number,
275
+ end: number,
276
+ indent: number,
277
+ folded: boolean,
278
+ ): string {
279
+ const parts: string[] = [];
280
+ for (let i = start; i < end; i++) {
281
+ const line = lines[i] as string;
282
+ const trimmed = line.trimStart();
283
+ const lineIndent = line.length - trimmed.length;
284
+ if (lineIndent >= indent || trimmed === '') {
285
+ parts.push(trimmed);
286
+ }
287
+ }
288
+
289
+ if (folded) {
290
+ return parts.join(' ').trim();
291
+ }
292
+
293
+ return parts.join('\n').trimEnd();
294
+ }
295
+
296
+ /**
297
+ * Parse an inline flow collection (JSON-like array or object).
298
+ *
299
+ * @param raw - Raw inline flow string.
300
+ * @returns Parsed value or the raw string if parsing fails.
301
+ */
302
+ private parseInlineFlow(raw: string): unknown {
303
+ try {
304
+ // Convert YAML flow to JSON by single-quoting to double-quoting
305
+ const jsonLike = raw.replace(/'/g, '"').replace(/(\w+)\s*:/g, '"$1":');
306
+ return JSON.parse(jsonLike);
307
+ } catch {
308
+ // Fallback: return raw string
309
+ return raw;
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Cast a scalar string to its native type.
315
+ *
316
+ * @param value - Raw scalar string.
317
+ * @returns Typed value (null, boolean, number, or string).
318
+ */
319
+ private castScalar(value: string): unknown {
320
+ if (value === '' || value === 'null' || value === '~') return null;
321
+ if (value === 'true') return true;
322
+ if (value === 'false') return false;
323
+
324
+ // Quoted string
325
+ if (
326
+ (value.startsWith('"') && value.endsWith('"')) ||
327
+ (value.startsWith("'") && value.endsWith("'"))
328
+ ) {
329
+ return value.slice(1, -1);
330
+ }
331
+
332
+ // Integer
333
+ if (/^-?\d+$/.test(value)) return parseInt(value, 10);
334
+
335
+ // Float
336
+ if (/^-?\d+\.\d+$/.test(value)) return parseFloat(value);
337
+
338
+ return value;
339
+ }
340
+
341
+ /**
342
+ * Find where a child block ends based on indentation.
343
+ *
344
+ * @param lines - All lines of the YAML document.
345
+ * @param minIndent - Minimum indentation to remain in the block.
346
+ * @param start - First line to check (inclusive).
347
+ * @param end - Hard end boundary (exclusive).
348
+ * @returns Index of the first line outside the block.
349
+ */
350
+ private findBlockEnd(lines: string[], minIndent: number, start: number, end: number): number {
351
+ for (let i = start; i < end; i++) {
352
+ const line = lines[i] as string;
353
+ const trimmed = line.trimStart();
354
+
355
+ if (trimmed === '' || trimmed.startsWith('#')) {
356
+ continue;
357
+ }
358
+
359
+ const lineIndent = line.length - trimmed.length;
360
+
361
+ if (lineIndent < minIndent) {
362
+ return i;
363
+ }
364
+ }
365
+
366
+ return end;
367
+ }
368
+ }