@amritk/lint 0.3.1 → 0.3.3

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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/dist/core/document.js +12 -10
  3. package/dist/core/formats.js +10 -13
  4. package/dist/core/glob.js +96 -118
  5. package/dist/core/index.js +31 -11
  6. package/dist/core/jsonpath.js +487 -561
  7. package/dist/core/lint.js +78 -85
  8. package/dist/core/plugin.js +21 -29
  9. package/dist/core/pointers.js +107 -151
  10. package/dist/core/ruleset.js +0 -0
  11. package/dist/core/runner.js +214 -272
  12. package/dist/core/types.js +4 -1
  13. package/dist/core/validate-ruleset.js +98 -112
  14. package/dist/fix/apply.js +58 -96
  15. package/dist/fix/index.js +7 -2
  16. package/dist/fix/plugin.js +15 -20
  17. package/dist/functions/alphabetical.js +39 -50
  18. package/dist/functions/casing.js +39 -45
  19. package/dist/functions/defined.js +7 -5
  20. package/dist/functions/enumeration.js +15 -25
  21. package/dist/functions/falsy.js +7 -5
  22. package/dist/functions/index.js +60 -44
  23. package/dist/functions/length.js +22 -32
  24. package/dist/functions/or.js +18 -24
  25. package/dist/functions/pattern.js +39 -49
  26. package/dist/functions/schema.js +93 -119
  27. package/dist/functions/truthy.js +7 -5
  28. package/dist/functions/typed-enum.js +33 -36
  29. package/dist/functions/undefined.js +7 -8
  30. package/dist/functions/unreferenced-reusable-object.js +35 -47
  31. package/dist/functions/xor.js +13 -16
  32. package/dist/index.js +114 -164
  33. package/dist/parsers/edit-model.js +316 -444
  34. package/dist/parsers/index.js +22 -19
  35. package/dist/parsers/json.js +39 -37
  36. package/dist/parsers/lines.js +23 -26
  37. package/dist/parsers/types.js +9 -7
  38. package/dist/parsers/yaml.js +137 -204
  39. package/dist/rules/openapi/fixers.js +166 -221
  40. package/dist/rules/openapi/formats.js +26 -27
  41. package/dist/rules/openapi/functions/example-validation.js +98 -138
  42. package/dist/rules/openapi/functions/helpers.js +8 -10
  43. package/dist/rules/openapi/functions/index.js +98 -72
  44. package/dist/rules/openapi/functions/oas-additional-operations.js +16 -22
  45. package/dist/rules/openapi/functions/oas-discriminator.js +24 -22
  46. package/dist/rules/openapi/functions/oas-example-external-value.js +13 -21
  47. package/dist/rules/openapi/functions/oas-example-value.js +24 -27
  48. package/dist/rules/openapi/functions/oas-mutually-exclusive.js +15 -19
  49. package/dist/rules/openapi/functions/oas-no-nullable.js +13 -21
  50. package/dist/rules/openapi/functions/oas-op-form-data-consume-check.js +21 -19
  51. package/dist/rules/openapi/functions/oas-op-id-unique.js +27 -27
  52. package/dist/rules/openapi/functions/oas-op-params.js +36 -42
  53. package/dist/rules/openapi/functions/oas-op-security-defined.js +40 -39
  54. package/dist/rules/openapi/functions/oas-op-success-response.js +11 -13
  55. package/dist/rules/openapi/functions/oas-path-param.js +75 -96
  56. package/dist/rules/openapi/functions/oas-schema-example-deprecated.js +33 -40
  57. package/dist/rules/openapi/functions/oas-schema.js +9 -14
  58. package/dist/rules/openapi/functions/oas-server-name-unique.js +21 -19
  59. package/dist/rules/openapi/functions/oas-server-variables.js +45 -49
  60. package/dist/rules/openapi/functions/oas-tag-defined.js +20 -20
  61. package/dist/rules/openapi/functions/oas-tag-kind.js +16 -16
  62. package/dist/rules/openapi/functions/oas-tag-parent-defined.js +40 -43
  63. package/dist/rules/openapi/functions/oas-tags-unique.js +18 -16
  64. package/dist/rules/openapi/functions/oas-unused-component.js +48 -58
  65. package/dist/rules/openapi/functions/ref-siblings.js +13 -11
  66. package/dist/rules/openapi/index.js +99 -117
  67. package/dist/rules/openapi/oas.js +524 -536
  68. package/dist/rules/openapi/schemas/index.js +17 -33
  69. package/dist/rules/openapi/schemas/oas20.json +1 -1592
  70. package/dist/rules/openapi/schemas/oas30.json +1 -1651
  71. package/dist/rules/openapi/schemas/oas31.json +1 -1412
  72. package/dist/rules/openapi/schemas/oas32.json +1 -1684
  73. package/package.json +5 -5
@@ -1,495 +1,367 @@
1
- import { isAlias, isMap, isScalar, isSeq, parseDocument } from '@amritk/yaml';
2
- import { applyEdits, findNodeAtLocation, modify, parseTree } from 'jsonc-parser';
1
+ import { isAlias, isMap, isScalar, isSeq, parseDocument } from "@amritk/yaml";
2
+ import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser";
3
3
  const splice = (text, start, end, replacement) => text.slice(0, start) + replacement + text.slice(end);
4
- /** The offset of the start of the line containing `offset`. */
5
4
  const lineStart = (text, offset) => {
6
- let s = offset;
7
- while (s > 0 && text.charCodeAt(s - 1) !== 10)
8
- s--;
9
- return s;
5
+ let s = offset;
6
+ while (s > 0 && text.charCodeAt(s - 1) !== 10)
7
+ s--;
8
+ return s;
10
9
  };
11
- /** Expands `[start, end)` to cover whole lines: back to the line start, forward past the trailing newline. */
12
10
  const expandLine = (text, start, end) => {
13
- const s = lineStart(text, start);
14
- let e = end;
15
- while (e < text.length && text.charCodeAt(e) !== 10)
16
- e++;
17
- if (e < text.length)
18
- e++;
19
- return [s, e];
11
+ const s = lineStart(text, start);
12
+ let e = end;
13
+ while (e < text.length && text.charCodeAt(e) !== 10)
14
+ e++;
15
+ if (e < text.length)
16
+ e++;
17
+ return [s, e];
20
18
  };
21
- /**
22
- * The document's dominant line ending. We look at the first break so inserted
23
- * lines match the file rather than always emitting a bare `\n` (which would leave
24
- * a CRLF file with mixed endings).
25
- */
26
19
  const detectEol = (text) => {
27
- const i = text.indexOf('\n');
28
- return i > 0 && text.charCodeAt(i - 1) === 13 ? '\r\n' : '\n';
20
+ const i = text.indexOf("\n");
21
+ return i > 0 && text.charCodeAt(i - 1) === 13 ? "\r\n" : "\n";
29
22
  };
30
- // --- YAML ------------------------------------------------------------------
31
- /**
32
- * Stringifies a key the way `toJS` does, so the paths we address by match the
33
- * keys the projected data exposes: a null key is `null`, a bool/number key is its
34
- * `String()` form, an alias key is `*name`, and a complex (map/seq) key is empty.
35
- */
36
23
  const keyName = (key) => {
37
- if (isScalar(key)) {
38
- const v = key.value;
39
- return typeof v === 'string' ? v : v === null ? 'null' : String(v);
40
- }
41
- if (isAlias(key))
42
- return `*${key.source}`;
43
- if (isMap(key) || isSeq(key))
44
- return '';
45
- return String(key);
24
+ if (isScalar(key)) {
25
+ const v = key.value;
26
+ return typeof v === "string" ? v : v === null ? "null" : String(v);
27
+ }
28
+ if (isAlias(key))
29
+ return `*${key.source}`;
30
+ if (isMap(key) || isSeq(key))
31
+ return "";
32
+ return String(key);
46
33
  };
47
- /** Navigates the YAML CST to the node at `path`, or `undefined` if absent. */
48
34
  const yamlNodeAt = (root, path) => {
49
- let current = root ?? undefined;
50
- for (const segment of path) {
51
- if (current === undefined)
52
- return undefined;
53
- if (isMap(current)) {
54
- const target = String(segment);
55
- // Last-wins: duplicate keys resolve to the final occurrence, matching how
56
- // `toJS` and the position index treat them, so an edit is not a silent no-op
57
- // that lands on a shadowed earlier copy.
58
- const pair = current.items.findLast((item) => keyName(item.key) === target);
59
- current = pair?.value ?? undefined;
60
- }
61
- else if (isSeq(current)) {
62
- current = current.items[Number(segment)];
63
- }
64
- else {
65
- return undefined;
66
- }
35
+ let current = root ?? void 0;
36
+ for (const segment of path) {
37
+ if (current === void 0)
38
+ return void 0;
39
+ if (isMap(current)) {
40
+ const target = String(segment);
41
+ const pair = current.items.findLast((item) => keyName(item.key) === target);
42
+ current = pair?.value ?? void 0;
43
+ } else if (isSeq(current)) {
44
+ current = current.items[Number(segment)];
45
+ } else {
46
+ return void 0;
67
47
  }
68
- return current;
48
+ }
49
+ return current;
69
50
  };
70
- /** Resolves the key/value CST nodes of the property at `path` (last segment = key). */
71
51
  const yamlPairAt = (root, path) => {
72
- const parent = yamlNodeAt(root, path.slice(0, -1));
73
- if (!parent || !isMap(parent))
74
- return {};
75
- const last = String(path[path.length - 1]);
76
- const pair = parent.items.findLast((item) => keyName(item.key) === last);
77
- if (!pair)
78
- return {};
79
- return { key: pair.key, ...(pair.value ? { value: pair.value } : {}) };
52
+ const parent = yamlNodeAt(root, path.slice(0, -1));
53
+ if (!parent || !isMap(parent))
54
+ return {};
55
+ const last = String(path[path.length - 1]);
56
+ const pair = parent.items.findLast((item) => keyName(item.key) === last);
57
+ if (!pair)
58
+ return {};
59
+ return { key: pair.key, ...pair.value ? { value: pair.value } : {} };
80
60
  };
81
- /**
82
- * Checks that writing `plain` bare into YAML round-trips back to the string
83
- * `value`. A bare scalar can silently change meaning — `true` becomes a boolean,
84
- * `1.0` a number, an empty string a null — and text carrying `: ` or ` #` or a
85
- * newline reshapes the line, so we parse the candidate on its own and require it
86
- * to come back as exactly the same string before trusting it unquoted.
87
- */
88
61
  const plainStringRoundTrips = (plain, value) => {
89
- const doc = parseDocument(plain);
90
- return doc.errors.length === 0 && isScalar(doc.contents) && doc.contents.value === value;
62
+ const doc = parseDocument(plain);
63
+ return doc.errors.length === 0 && isScalar(doc.contents) && doc.contents.value === value;
91
64
  };
92
- // Keys made only of these characters are safe to write bare in YAML; anything
93
- // else (spaces, colons, flow indicators) gets double-quoted to stay valid.
94
65
  const SAFE_YAML_KEY = /^[\w./-]+$/;
95
- /** Serializes a scalar, preserving the quoting style of the value it replaces. */
96
66
  const yamlScalar = (value, original) => {
97
- if (typeof value !== 'string')
98
- return value === null ? 'null' : String(value);
99
- const quote = original.charCodeAt(0);
100
- if (quote === 34 /* " */)
101
- return JSON.stringify(value);
102
- if (quote === 39 /* ' */)
103
- return `'${value.replace(/'/g, "''")}'`;
104
- // Plain (unquoted) original: keep it bare only when the bare text still means
105
- // this exact string; otherwise fall back to a double-quoted literal so we never
106
- // turn a string into a bool/number/null or corrupt the line.
107
- return plainStringRoundTrips(value, value) ? value : JSON.stringify(value);
67
+ if (typeof value !== "string")
68
+ return value === null ? "null" : String(value);
69
+ const quote = original.charCodeAt(0);
70
+ if (quote === 34)
71
+ return JSON.stringify(value);
72
+ if (quote === 39)
73
+ return `'${value.replace(/'/g, "''")}'`;
74
+ return plainStringRoundTrips(value, value) ? value : JSON.stringify(value);
108
75
  };
109
76
  const yamlKey = (key, original) => {
110
- const quote = original.charCodeAt(0);
111
- if (quote === 34)
112
- return JSON.stringify(key);
113
- if (quote === 39)
114
- return `'${key.replace(/'/g, "''")}'`;
115
- // A plain key that would resolve to a non-string (e.g. `true`, `1.0`) or is not
116
- // otherwise safe bare gets quoted, matching the value-side treatment.
117
- return SAFE_YAML_KEY.test(key) && plainStringRoundTrips(key, key) ? key : JSON.stringify(key);
77
+ const quote = original.charCodeAt(0);
78
+ if (quote === 34)
79
+ return JSON.stringify(key);
80
+ if (quote === 39)
81
+ return `'${key.replace(/'/g, "''")}'`;
82
+ return SAFE_YAML_KEY.test(key) && plainStringRoundTrips(key, key) ? key : JSON.stringify(key);
118
83
  };
119
- /** Serializes a key for an inserted property, quoting it only when it needs to be. */
120
- const yamlInsertKey = (key) => (SAFE_YAML_KEY.test(key) ? key : JSON.stringify(key));
121
- /**
122
- * Serializes a value for insertion. YAML is a JSON superset, so JSON output is
123
- * always valid YAML: scalars become bare literals (`42`, `true`, `"text"`) and
124
- * objects/arrays become inline flow (`{"a":1}`). That keeps the inserted node on
125
- * one line without us having to re-implement a block serializer.
126
- */
127
- const yamlInsertValue = (value) => JSON.stringify(value) ?? 'null';
128
- /** Serializes a fresh scalar (no original to mirror), quoting strings only when bare would change meaning. */
84
+ const yamlInsertKey = (key) => SAFE_YAML_KEY.test(key) ? key : JSON.stringify(key);
85
+ const yamlInsertValue = (value) => JSON.stringify(value) ?? "null";
129
86
  const yamlFreshScalar = (value) => {
130
- if (value !== null && typeof value === 'object')
131
- return yamlInsertValue(value);
132
- if (typeof value === 'string')
133
- return plainStringRoundTrips(value, value) ? value : JSON.stringify(value);
134
- return value === null ? 'null' : String(value);
87
+ if (value !== null && typeof value === "object")
88
+ return yamlInsertValue(value);
89
+ if (typeof value === "string")
90
+ return plainStringRoundTrips(value, value) ? value : JSON.stringify(value);
91
+ return value === null ? "null" : String(value);
135
92
  };
136
- /** Returns the leading whitespace of the line containing `offset` (its indentation). */
137
93
  const lineIndent = (text, offset) => {
138
- const start = lineStart(text, offset);
139
- let end = start;
140
- while (end < text.length && (text.charCodeAt(end) === 32 || text.charCodeAt(end) === 9))
141
- end++;
142
- return text.slice(start, end);
94
+ const start = lineStart(text, offset);
95
+ let end = start;
96
+ while (end < text.length && (text.charCodeAt(end) === 32 || text.charCodeAt(end) === 9))
97
+ end++;
98
+ return text.slice(start, end);
143
99
  };
144
- /**
145
- * The indentation a new block-sequence item should carry, measured from the
146
- * column of the first item's own `- ` dash. Using the dash column (not the line's
147
- * leading whitespace) keeps a nested sequence correct: for `- - 1` the inner
148
- * item's dash sits at column 2, so a new inner item indents to match it rather
149
- * than appending to the outer sequence.
150
- */
151
100
  const seqItemIndent = (text, firstItem) => {
152
- const start = lineStart(text, firstItem.start);
153
- let dash = firstItem.start - 1;
154
- while (dash > start && text.charCodeAt(dash) !== 45 /* - */)
155
- dash--;
156
- return text.charCodeAt(dash) === 45 ? ' '.repeat(dash - start) : lineIndent(text, firstItem.start);
101
+ const start = lineStart(text, firstItem.start);
102
+ let dash = firstItem.start - 1;
103
+ while (dash > start && text.charCodeAt(dash) !== 45)
104
+ dash--;
105
+ return text.charCodeAt(dash) === 45 ? " ".repeat(dash - start) : lineIndent(text, firstItem.start);
157
106
  };
158
107
  const applyYamlOp = (text, op) => {
159
- const root = parseDocument(text).contents;
160
- const eol = detectEol(text);
161
- switch (op.op) {
162
- case 'setValue': {
163
- const node = yamlNodeAt(root, op.path);
164
- if (!node) {
165
- // The target may be an explicit-empty key (`key:` with a null value). The
166
- // value node does not exist yet, so splice a scalar in right after the
167
- // colon rather than silently doing nothing.
168
- const { key, value } = yamlPairAt(root, op.path);
169
- if (key && value === undefined) {
170
- const colon = text.indexOf(':', key.end);
171
- if (colon !== -1)
172
- return splice(text, colon + 1, colon + 1, ` ${yamlFreshScalar(op.value)}`);
173
- }
174
- return text;
175
- }
176
- // Scalars keep the node's original quoting; objects/arrays are written as
177
- // inline flow JSON (valid YAML) so a scalar can be widened to a collection.
178
- const replacement = op.value !== null && typeof op.value === 'object'
179
- ? yamlInsertValue(op.value)
180
- : yamlScalar(op.value, text.slice(node.start, node.end));
181
- return splice(text, node.start, node.end, replacement);
182
- }
183
- case 'renameProperty': {
184
- const { key } = yamlPairAt(root, op.path);
185
- if (!key)
186
- return text;
187
- return splice(text, key.start, key.end, yamlKey(op.newKey, text.slice(key.start, key.end)));
108
+ const root = parseDocument(text).contents;
109
+ const eol = detectEol(text);
110
+ switch (op.op) {
111
+ case "setValue": {
112
+ const node = yamlNodeAt(root, op.path);
113
+ if (!node) {
114
+ const { key, value } = yamlPairAt(root, op.path);
115
+ if (key && value === void 0) {
116
+ const colon = text.indexOf(":", key.end);
117
+ if (colon !== -1)
118
+ return splice(text, colon + 1, colon + 1, ` ${yamlFreshScalar(op.value)}`);
188
119
  }
189
- case 'removeProperty': {
190
- const parent = yamlNodeAt(root, op.path.slice(0, -1));
191
- if (!parent || !isMap(parent))
192
- return text;
193
- const last = String(op.path[op.path.length - 1]);
194
- const index = parent.items.findLastIndex((item) => keyName(item.key) === last);
195
- if (index === -1)
196
- return text;
197
- const pair = parent.items[index];
198
- const key = pair?.key;
199
- const value = pair?.value;
200
- // Flow map (`{ a: 1, b: 2 }`): excise just this member plus one adjoining
201
- // comma, keeping the braces and the surviving members on their shared line.
202
- if (text.charCodeAt(parent.start) === 123 /* { */) {
203
- if (parent.items.length === 1)
204
- return splice(text, parent.start + 1, parent.end - 1, '');
205
- // Not the last member: take the comma and gap up to the next key.
206
- if (index < parent.items.length - 1) {
207
- const next = parent.items[index + 1]?.key;
208
- return splice(text, key.start, next.start, '');
209
- }
210
- // Last member: take the trailing comma and gap left by the previous one.
211
- const prev = parent.items[index - 1];
212
- const prevEnd = (prev?.value ?? prev?.key).end;
213
- return splice(text, prevEnd, (value ?? key).end, '');
214
- }
215
- // Compact sequence-entry map (`- a: 1\n b: 2`): the first pair shares its
216
- // line with the parent seq's `- ` dash. Dropping the whole line would swallow
217
- // the dash and merge this item into its sibling, so splice only the pair's own
218
- // span. A lone key falls through to whole-line removal (the item disappears).
219
- const dashPrefix = text.slice(lineStart(text, key.start), key.start);
220
- if (/^\s*-\s+$/.test(dashPrefix) && parent.items.length > 1) {
221
- const next = parent.items[index + 1]?.key;
222
- if (next)
223
- return splice(text, key.start, next.start, '');
224
- return splice(text, key.start, (value ?? key).end, '');
225
- }
226
- // Block map: drop the whole line(s) the property occupies.
227
- const [start, end] = expandLine(text, key.start, value ? value.end : key.end);
228
- return splice(text, start, end, '');
229
- }
230
- case 'removeItems': {
231
- const seq = yamlNodeAt(root, op.path);
232
- if (!seq || !isSeq(seq))
233
- return text;
234
- const removed = new Set(op.indices);
235
- return rewriteYamlSeq(text, seq, seq.items.filter((_, index) => !removed.has(index)));
236
- }
237
- case 'reorderArray': {
238
- const seq = yamlNodeAt(root, op.path);
239
- if (!seq || !isSeq(seq) || seq.items.length === 0)
240
- return text;
241
- const reordered = op.order.map((index) => seq.items[index]).filter((item) => item != null);
242
- return rewriteYamlSeq(text, seq, reordered);
120
+ return text;
121
+ }
122
+ const replacement = op.value !== null && typeof op.value === "object" ? yamlInsertValue(op.value) : yamlScalar(op.value, text.slice(node.start, node.end));
123
+ return splice(text, node.start, node.end, replacement);
124
+ }
125
+ case "renameProperty": {
126
+ const { key } = yamlPairAt(root, op.path);
127
+ if (!key)
128
+ return text;
129
+ return splice(text, key.start, key.end, yamlKey(op.newKey, text.slice(key.start, key.end)));
130
+ }
131
+ case "removeProperty": {
132
+ const parent = yamlNodeAt(root, op.path.slice(0, -1));
133
+ if (!parent || !isMap(parent))
134
+ return text;
135
+ const last = String(op.path[op.path.length - 1]);
136
+ const index = parent.items.findLastIndex((item) => keyName(item.key) === last);
137
+ if (index === -1)
138
+ return text;
139
+ const pair = parent.items[index];
140
+ const key = pair?.key;
141
+ const value = pair?.value;
142
+ if (text.charCodeAt(parent.start) === 123) {
143
+ if (parent.items.length === 1)
144
+ return splice(text, parent.start + 1, parent.end - 1, "");
145
+ if (index < parent.items.length - 1) {
146
+ const next = parent.items[index + 1]?.key;
147
+ return splice(text, key.start, next.start, "");
243
148
  }
244
- case 'insertProperty': {
245
- const parent = yamlNodeAt(root, op.path);
246
- if (!parent) {
247
- // The target may be an explicit-empty key (`parent:` with a null value).
248
- // Turn it into a one-key block map on the next line rather than no-op.
249
- const { key, value } = yamlPairAt(root, op.path);
250
- if (key && value === undefined) {
251
- const colon = text.indexOf(':', key.end);
252
- if (colon !== -1) {
253
- const indent = ' '.repeat(key.start - lineStart(text, key.start) + 2);
254
- const pair = `${yamlInsertKey(op.key)}: ${yamlInsertValue(op.value)}`;
255
- return splice(text, colon + 1, colon + 1, `${eol}${indent}${pair}`);
256
- }
257
- }
258
- return text;
259
- }
260
- if (!isMap(parent))
261
- return text;
262
- // Inserting is additive only; if the key is already there we leave it alone.
263
- if (parent.items.some((item) => keyName(item.key) === op.key))
264
- return text;
265
- const pair = `${yamlInsertKey(op.key)}: ${yamlInsertValue(op.value)}`;
266
- if (text.charCodeAt(parent.start) === 123 /* { */) {
267
- const last = parent.items[parent.items.length - 1];
268
- // Append right after the last entry (keeping the brace's own spacing); an
269
- // empty `{}` gets the pair tucked just inside the opening brace.
270
- if (last) {
271
- const lastEnd = (last.value ?? last.key).end;
272
- return splice(text, lastEnd, lastEnd, `, ${pair}`);
273
- }
274
- return splice(text, parent.start + 1, parent.start + 1, pair);
275
- }
276
- // Block map: mirror the existing keys' indentation and add a line after the last one.
277
- const last = parent.items[parent.items.length - 1];
278
- const lastKey = last?.key;
279
- if (!lastKey)
280
- return text;
281
- // Indent from the key's *column*, not the line's leading whitespace: in a
282
- // compact seq-item map the last key sits after a `- ` dash, so the line
283
- // indent (2) would place the new key outside the item — use the column (4).
284
- const keyLineStart = lineStart(text, lastKey.start);
285
- const prefix = text.slice(keyLineStart, lastKey.start);
286
- const indent = /^\s*$/.test(prefix) ? prefix : ' '.repeat(lastKey.start - keyLineStart);
287
- const [, end] = expandLine(text, lastKey.start, (last?.value ?? lastKey).end);
288
- const lead = end > 0 && text.charCodeAt(end - 1) !== 10 ? eol : '';
289
- return splice(text, end, end, `${lead}${indent}${pair}${eol}`);
149
+ const prev = parent.items[index - 1];
150
+ const prevEnd = (prev?.value ?? prev?.key).end;
151
+ return splice(text, prevEnd, (value ?? key).end, "");
152
+ }
153
+ const dashPrefix = text.slice(lineStart(text, key.start), key.start);
154
+ if (/^\s*-\s+$/.test(dashPrefix) && parent.items.length > 1) {
155
+ const next = parent.items[index + 1]?.key;
156
+ if (next)
157
+ return splice(text, key.start, next.start, "");
158
+ return splice(text, key.start, (value ?? key).end, "");
159
+ }
160
+ const [start, end] = expandLine(text, key.start, value ? value.end : key.end);
161
+ return splice(text, start, end, "");
162
+ }
163
+ case "removeItems": {
164
+ const seq = yamlNodeAt(root, op.path);
165
+ if (!seq || !isSeq(seq))
166
+ return text;
167
+ const removed = new Set(op.indices);
168
+ return rewriteYamlSeq(text, seq, seq.items.filter((_, index) => !removed.has(index)));
169
+ }
170
+ case "reorderArray": {
171
+ const seq = yamlNodeAt(root, op.path);
172
+ if (!seq || !isSeq(seq) || seq.items.length === 0)
173
+ return text;
174
+ const reordered = op.order.map((index) => seq.items[index]).filter((item) => item != null);
175
+ return rewriteYamlSeq(text, seq, reordered);
176
+ }
177
+ case "insertProperty": {
178
+ const parent = yamlNodeAt(root, op.path);
179
+ if (!parent) {
180
+ const { key, value } = yamlPairAt(root, op.path);
181
+ if (key && value === void 0) {
182
+ const colon = text.indexOf(":", key.end);
183
+ if (colon !== -1) {
184
+ const indent2 = " ".repeat(key.start - lineStart(text, key.start) + 2);
185
+ const pair2 = `${yamlInsertKey(op.key)}: ${yamlInsertValue(op.value)}`;
186
+ return splice(text, colon + 1, colon + 1, `${eol}${indent2}${pair2}`);
187
+ }
290
188
  }
291
- case 'insertItem': {
292
- const seq = yamlNodeAt(root, op.path);
293
- if (!seq || !isSeq(seq))
294
- return text;
295
- const value = yamlInsertValue(op.value);
296
- if (text.charCodeAt(seq.start) === 91 /* [ */) {
297
- const items = seq.items.map((item) => text.slice(item.start, item.end));
298
- items.splice(clampIndex(op.index, items.length), 0, value);
299
- return splice(text, seq.start + 1, seq.end - 1, items.join(', '));
300
- }
301
- // Block sequence: we need an existing `- item` line to mirror its indentation and style.
302
- if (seq.items.length === 0)
303
- return text;
304
- const blocks = seqBlocks(text, seq.items);
305
- const regionStart = blocks[0][0];
306
- const regionEnd = blocks[blocks.length - 1][1];
307
- const lines = seq.items.map((_, index) => text.slice(...blocks[index]));
308
- const newLine = `${seqItemIndent(text, seq.items[0])}- ${value}${eol}`;
309
- lines.splice(clampIndex(op.index, lines.length), 0, newLine);
310
- return splice(text, regionStart, regionEnd, lines.join(''));
189
+ return text;
190
+ }
191
+ if (!isMap(parent))
192
+ return text;
193
+ if (parent.items.some((item) => keyName(item.key) === op.key))
194
+ return text;
195
+ const pair = `${yamlInsertKey(op.key)}: ${yamlInsertValue(op.value)}`;
196
+ if (text.charCodeAt(parent.start) === 123) {
197
+ const last2 = parent.items[parent.items.length - 1];
198
+ if (last2) {
199
+ const lastEnd = (last2.value ?? last2.key).end;
200
+ return splice(text, lastEnd, lastEnd, `, ${pair}`);
311
201
  }
202
+ return splice(text, parent.start + 1, parent.start + 1, pair);
203
+ }
204
+ const last = parent.items[parent.items.length - 1];
205
+ const lastKey = last?.key;
206
+ if (!lastKey)
207
+ return text;
208
+ const keyLineStart = lineStart(text, lastKey.start);
209
+ const prefix = text.slice(keyLineStart, lastKey.start);
210
+ const indent = /^\s*$/.test(prefix) ? prefix : " ".repeat(lastKey.start - keyLineStart);
211
+ const [, end] = expandLine(text, lastKey.start, (last?.value ?? lastKey).end);
212
+ const lead = end > 0 && text.charCodeAt(end - 1) !== 10 ? eol : "";
213
+ return splice(text, end, end, `${lead}${indent}${pair}${eol}`);
312
214
  }
215
+ case "insertItem": {
216
+ const seq = yamlNodeAt(root, op.path);
217
+ if (!seq || !isSeq(seq))
218
+ return text;
219
+ const value = yamlInsertValue(op.value);
220
+ if (text.charCodeAt(seq.start) === 91) {
221
+ const items = seq.items.map((item) => text.slice(item.start, item.end));
222
+ items.splice(clampIndex(op.index, items.length), 0, value);
223
+ return splice(text, seq.start + 1, seq.end - 1, items.join(", "));
224
+ }
225
+ if (seq.items.length === 0)
226
+ return text;
227
+ const blocks = seqBlocks(text, seq.items);
228
+ const regionStart = blocks[0][0];
229
+ const regionEnd = blocks[blocks.length - 1][1];
230
+ const lines = seq.items.map((_, index) => text.slice(...blocks[index]));
231
+ const newLine = `${seqItemIndent(text, seq.items[0])}- ${value}${eol}`;
232
+ lines.splice(clampIndex(op.index, lines.length), 0, newLine);
233
+ return splice(text, regionStart, regionEnd, lines.join(""));
234
+ }
235
+ }
313
236
  };
314
- /** Clamps an optional insertion index into `[0, length]`, defaulting to an append. */
315
237
  const clampIndex = (index, length) => Math.max(0, Math.min(index ?? length, length));
316
- /**
317
- * Whole-line spans for each block-sequence item, with any comment-only or blank
318
- * lines that precede an item folded into that item's block. Attaching leading
319
- * comments to the following item means a reorder carries them along and a removal
320
- * takes only the comments that belong to the dropped item — honoring the module's
321
- * promise to preserve comments rather than dropping every line between items.
322
- */
323
238
  const seqBlocks = (text, items) => {
324
- const blocks = [];
325
- items.forEach((item, i) => {
326
- const [start, end] = expandLine(text, item.start, item.end);
327
- // For every item after the first, start the block at the end of the previous
328
- // item's line so the comment/blank lines between the two travel with this item.
329
- blocks.push([i === 0 ? start : blocks[i - 1][1], end]);
330
- });
331
- return blocks;
239
+ const blocks = [];
240
+ items.forEach((item, i) => {
241
+ const [start, end] = expandLine(text, item.start, item.end);
242
+ blocks.push([i === 0 ? start : blocks[i - 1][1], end]);
243
+ });
244
+ return blocks;
332
245
  };
333
- /**
334
- * Rewrites a YAML sequence to contain exactly `newItems` (a subset and/or
335
- * reordering of the original nodes), preserving each kept item's own text. Flow
336
- * sequences (`[a, b]`) are rebuilt inside their brackets; block sequences (one
337
- * `- item` per line) are rebuilt from their whole-line spans, carrying each item's
338
- * preceding comments with it.
339
- */
340
246
  const rewriteYamlSeq = (text, seq, newItems) => {
341
- const isFlow = text.charCodeAt(seq.start) === 91; /* [ */
342
- if (isFlow) {
343
- const inner = newItems.map((item) => text.slice(item.start, item.end)).join(', ');
344
- return splice(text, seq.start + 1, seq.end - 1, inner);
345
- }
346
- if (seq.items.length === 0)
347
- return text;
348
- const blocks = seqBlocks(text, seq.items);
349
- const regionStart = blocks[0][0];
350
- const regionEnd = blocks[blocks.length - 1][1];
351
- const blockText = new Map(seq.items.map((item, index) => [item, text.slice(...blocks[index])]));
352
- const rebuilt = newItems.map((item) => blockText.get(item) ?? '').join('');
353
- return splice(text, regionStart, regionEnd, rebuilt);
247
+ const isFlow = text.charCodeAt(seq.start) === 91;
248
+ if (isFlow) {
249
+ const inner = newItems.map((item) => text.slice(item.start, item.end)).join(", ");
250
+ return splice(text, seq.start + 1, seq.end - 1, inner);
251
+ }
252
+ if (seq.items.length === 0)
253
+ return text;
254
+ const blocks = seqBlocks(text, seq.items);
255
+ const regionStart = blocks[0][0];
256
+ const regionEnd = blocks[blocks.length - 1][1];
257
+ const blockText = new Map(seq.items.map((item, index) => [item, text.slice(...blocks[index])]));
258
+ const rebuilt = newItems.map((item) => blockText.get(item) ?? "").join("");
259
+ return splice(text, regionStart, regionEnd, rebuilt);
354
260
  };
355
- // --- JSON ------------------------------------------------------------------
356
- /** Formatting options for jsonc-parser edits, carrying the file's own line ending. */
357
261
  const jsonFormat = (eol) => ({
358
- insertSpaces: true,
359
- tabSize: 2,
360
- eol,
262
+ insertSpaces: true,
263
+ tabSize: 2,
264
+ eol
361
265
  });
362
- /**
363
- * jsonc-parser keys path segments by JS type — strings index objects, numbers
364
- * index arrays — but a finding's path carries numeric-like object keys (e.g. a
365
- * `"200"` response) as plain numbers. Walk the tree and coerce each segment to
366
- * the type its parent expects, so both keys and indices resolve (and `modify`
367
- * doesn't mistake a `"200"` key for an array index and throw).
368
- */
369
266
  const normalizeJsonPath = (root, path) => {
370
- const result = [];
371
- let node = root;
372
- for (const segment of path) {
373
- const normalized = node?.type === 'array' ? Number(segment) : String(segment);
374
- result.push(normalized);
375
- node = node ? findNodeAtLocation(node, [normalized]) : undefined;
376
- }
377
- return result;
267
+ const result = [];
268
+ let node = root;
269
+ for (const segment of path) {
270
+ const normalized = node?.type === "array" ? Number(segment) : String(segment);
271
+ result.push(normalized);
272
+ node = node ? findNodeAtLocation(node, [normalized]) : void 0;
273
+ }
274
+ return result;
378
275
  };
379
- /**
380
- * Rewrites a JSON array to contain exactly `keptChildren`, slicing each element's
381
- * original source so numeric literals (`1.50`), escapes, and Unicode survive byte
382
- * for byte. Separators, leading, and trailing whitespace are taken from the
383
- * original array, so a one-line array stays on one line and a multi-line array
384
- * keeps its indentation instead of being re-serialized with a hardcoded style.
385
- */
386
276
  const rewriteJsonArray = (text, node, keptChildren) => {
387
- const children = node.children ?? [];
388
- const open = node.offset + 1;
389
- const close = node.offset + node.length - 1;
390
- if (children.length === 0)
391
- return text;
392
- const first = children[0];
393
- const last = children[children.length - 1];
394
- if (keptChildren.length === 0)
395
- return splice(text, open, close, '');
396
- const leading = text.slice(open, first.offset);
397
- const trailing = text.slice(last.offset + last.length, close);
398
- // The separator between two elements, captured from the source so we reuse the
399
- // file's own comma, newline, and indentation rather than inventing them.
400
- const separator = children.length > 1 ? text.slice(first.offset + first.length, children[1].offset) : ', ';
401
- const inner = keptChildren.map((child) => text.slice(child.offset, child.offset + child.length)).join(separator);
402
- return splice(text, open, close, leading + inner + trailing);
277
+ const children = node.children ?? [];
278
+ const open = node.offset + 1;
279
+ const close = node.offset + node.length - 1;
280
+ if (children.length === 0)
281
+ return text;
282
+ const first = children[0];
283
+ const last = children[children.length - 1];
284
+ if (keptChildren.length === 0)
285
+ return splice(text, open, close, "");
286
+ const leading = text.slice(open, first.offset);
287
+ const trailing = text.slice(last.offset + last.length, close);
288
+ const separator = children.length > 1 ? text.slice(first.offset + first.length, children[1].offset) : ", ";
289
+ const inner = keptChildren.map((child) => text.slice(child.offset, child.offset + child.length)).join(separator);
290
+ return splice(text, open, close, leading + inner + trailing);
403
291
  };
404
292
  const applyJsonOp = (text, op) => {
405
- const root = parseTree(text);
406
- if (!root)
293
+ const root = parseTree(text);
294
+ if (!root)
295
+ return text;
296
+ const path = normalizeJsonPath(root, op.path);
297
+ const format = jsonFormat(detectEol(text));
298
+ switch (op.op) {
299
+ case "setValue":
300
+ if (!findNodeAtLocation(root, path))
407
301
  return text;
408
- const path = normalizeJsonPath(root, op.path);
409
- const format = jsonFormat(detectEol(text));
410
- switch (op.op) {
411
- case 'setValue':
412
- // `modify` would happily *create* a missing path (and every ancestor along
413
- // it), which violates the contract that an unresolved path is a no-op — so
414
- // only edit when the node actually exists.
415
- if (!findNodeAtLocation(root, path))
416
- return text;
417
- return applyEdits(text, modify(text, path, op.value, { formattingOptions: format }));
418
- case 'removeProperty':
419
- if (!findNodeAtLocation(root, path))
420
- return text;
421
- return applyEdits(text, modify(text, path, undefined, { formattingOptions: format }));
422
- case 'renameProperty': {
423
- const valueNode = findNodeAtLocation(root, path);
424
- // Only a real object member can be renamed. An array-index path has an
425
- // `array` parent, whose first child is element 0 — renaming there would
426
- // overwrite that element, so bail instead.
427
- if (valueNode?.parent?.type !== 'property')
428
- return text;
429
- const keyNode = valueNode.parent.children?.[0];
430
- if (!keyNode)
431
- return text;
432
- return splice(text, keyNode.offset, keyNode.offset + keyNode.length, JSON.stringify(op.newKey));
433
- }
434
- case 'removeItems': {
435
- const node = findNodeAtLocation(root, path);
436
- if (!node || node.type !== 'array')
437
- return text;
438
- const removed = new Set(op.indices);
439
- const kept = (node.children ?? []).filter((_, index) => !removed.has(index));
440
- return rewriteJsonArray(text, node, kept);
441
- }
442
- case 'reorderArray': {
443
- const node = findNodeAtLocation(root, path);
444
- if (!node || node.type !== 'array')
445
- return text;
446
- const children = node.children ?? [];
447
- const reordered = op.order.map((index) => children[index]).filter((child) => child != null);
448
- return rewriteJsonArray(text, node, reordered);
449
- }
450
- case 'insertProperty': {
451
- const node = findNodeAtLocation(root, path);
452
- if (!node || node.type !== 'object')
453
- return text;
454
- // Additive only: writing to an existing key would replace its value, so bail.
455
- if (node.children?.some((property) => property.children?.[0]?.value === op.key))
456
- return text;
457
- return applyEdits(text, modify(text, [...path, op.key], op.value, { formattingOptions: format }));
458
- }
459
- case 'insertItem': {
460
- const node = findNodeAtLocation(root, path);
461
- if (!node || node.type !== 'array')
462
- return text;
463
- const index = clampIndex(op.index, node.children?.length ?? 0);
464
- return applyEdits(text, modify(text, [...path, index], op.value, { formattingOptions: format, isArrayInsertion: true }));
465
- }
302
+ return applyEdits(text, modify(text, path, op.value, { formattingOptions: format }));
303
+ case "removeProperty":
304
+ if (!findNodeAtLocation(root, path))
305
+ return text;
306
+ return applyEdits(text, modify(text, path, void 0, { formattingOptions: format }));
307
+ case "renameProperty": {
308
+ const valueNode = findNodeAtLocation(root, path);
309
+ if (valueNode?.parent?.type !== "property")
310
+ return text;
311
+ const keyNode = valueNode.parent.children?.[0];
312
+ if (!keyNode)
313
+ return text;
314
+ return splice(text, keyNode.offset, keyNode.offset + keyNode.length, JSON.stringify(op.newKey));
466
315
  }
316
+ case "removeItems": {
317
+ const node = findNodeAtLocation(root, path);
318
+ if (!node || node.type !== "array")
319
+ return text;
320
+ const removed = new Set(op.indices);
321
+ const kept = (node.children ?? []).filter((_, index) => !removed.has(index));
322
+ return rewriteJsonArray(text, node, kept);
323
+ }
324
+ case "reorderArray": {
325
+ const node = findNodeAtLocation(root, path);
326
+ if (!node || node.type !== "array")
327
+ return text;
328
+ const children = node.children ?? [];
329
+ const reordered = op.order.map((index) => children[index]).filter((child) => child != null);
330
+ return rewriteJsonArray(text, node, reordered);
331
+ }
332
+ case "insertProperty": {
333
+ const node = findNodeAtLocation(root, path);
334
+ if (!node || node.type !== "object")
335
+ return text;
336
+ if (node.children?.some((property) => property.children?.[0]?.value === op.key))
337
+ return text;
338
+ return applyEdits(text, modify(text, [...path, op.key], op.value, { formattingOptions: format }));
339
+ }
340
+ case "insertItem": {
341
+ const node = findNodeAtLocation(root, path);
342
+ if (!node || node.type !== "array")
343
+ return text;
344
+ const index = clampIndex(op.index, node.children?.length ?? 0);
345
+ return applyEdits(text, modify(text, [...path, index], op.value, { formattingOptions: format, isArrayInsertion: true }));
346
+ }
347
+ }
467
348
  };
468
- /**
469
- * Applies `ops` to `source`, reporting both the edited text and, per op, whether
470
- * it actually changed anything. Each op is lowered to a minimal text edit and
471
- * applied sequentially (re-parsing between ops so offsets stay valid), preserving
472
- * the formatting of everything it does not touch. An op whose target path no
473
- * longer resolves is a no-op rather than an error — callers use `changed` to tell
474
- * a real fix from one that quietly dropped out (e.g. its node was already gone).
475
- */
476
- export const applyEditOpsWithChanges = (source, format, ops) => {
477
- let text = source;
478
- const changed = [];
479
- for (const op of ops) {
480
- const before = text;
481
- // An op that can't be lowered (an unresolved path, a key the underlying
482
- // editor rejects) must be a no-op, never a thrown error that aborts the
483
- // whole batch — one bad edit should not drop every other fix.
484
- try {
485
- text = format === 'json' ? applyJsonOp(text, op) : applyYamlOp(text, op);
486
- }
487
- catch {
488
- text = before;
489
- }
490
- changed.push(text !== before);
349
+ const applyEditOpsWithChanges = (source, format, ops) => {
350
+ let text = source;
351
+ const changed = [];
352
+ for (const op of ops) {
353
+ const before = text;
354
+ try {
355
+ text = format === "json" ? applyJsonOp(text, op) : applyYamlOp(text, op);
356
+ } catch {
357
+ text = before;
491
358
  }
492
- return { output: text, changed };
359
+ changed.push(text !== before);
360
+ }
361
+ return { output: text, changed };
362
+ };
363
+ const applyEditOps = (source, format, ops) => applyEditOpsWithChanges(source, format, ops).output;
364
+ export {
365
+ applyEditOps,
366
+ applyEditOpsWithChanges
493
367
  };
494
- /** Applies `ops` to `source` and returns just the edited text. See {@link applyEditOpsWithChanges}. */
495
- export const applyEditOps = (source, format, ops) => applyEditOpsWithChanges(source, format, ops).output;