@amritk/lint 0.2.0 → 0.3.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.
- package/README.md +18 -0
- package/dist/core/glob.d.ts +1 -1
- package/dist/core/glob.js +89 -5
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/jsonpath.d.ts +17 -1
- package/dist/core/jsonpath.js +218 -23
- package/dist/core/lint.d.ts +15 -8
- package/dist/core/lint.js +12 -3
- package/dist/core/plugin.d.ts +6 -0
- package/dist/core/plugin.js +6 -0
- package/dist/core/pointers.js +15 -15
- package/dist/core/ruleset.js +0 -0
- package/dist/core/runner.d.ts +6 -1
- package/dist/core/runner.js +127 -43
- package/dist/core/types.d.ts +17 -2
- package/dist/core/validate-ruleset.js +16 -0
- package/dist/fix/apply.d.ts +8 -2
- package/dist/fix/apply.js +68 -18
- package/dist/functions/alphabetical.js +40 -13
- package/dist/functions/casing.js +27 -5
- package/dist/functions/enumeration.d.ts +5 -3
- package/dist/functions/enumeration.js +18 -1
- package/dist/functions/index.d.ts +1 -0
- package/dist/functions/index.js +3 -0
- package/dist/functions/length.d.ts +13 -3
- package/dist/functions/length.js +11 -3
- package/dist/functions/or.d.ts +11 -0
- package/dist/functions/or.js +25 -0
- package/dist/functions/pattern.d.ts +5 -3
- package/dist/functions/pattern.js +42 -9
- package/dist/functions/schema.d.ts +13 -0
- package/dist/functions/schema.js +95 -2
- package/dist/functions/typed-enum.js +7 -1
- package/dist/functions/unreferenced-reusable-object.d.ts +7 -1
- package/dist/functions/unreferenced-reusable-object.js +18 -3
- package/dist/functions/xor.js +8 -1
- package/dist/index.js +9 -1
- package/dist/parsers/edit-model.d.ts +15 -0
- package/dist/parsers/edit-model.js +210 -41
- package/dist/parsers/types.d.ts +14 -2
- package/dist/parsers/yaml.d.ts +10 -0
- package/dist/parsers/yaml.js +174 -26
- package/dist/rules/openapi/fixers.js +63 -4
- package/dist/rules/openapi/formats.js +11 -4
- package/dist/rules/openapi/functions/example-validation.d.ts +16 -3
- package/dist/rules/openapi/functions/example-validation.js +102 -39
- package/dist/rules/openapi/functions/helpers.d.ts +1 -0
- package/dist/rules/openapi/functions/helpers.js +5 -0
- package/dist/rules/openapi/functions/index.d.ts +3 -1
- package/dist/rules/openapi/functions/index.js +7 -1
- package/dist/rules/openapi/functions/oas-additional-operations.js +5 -5
- package/dist/rules/openapi/functions/oas-example-external-value.d.ts +11 -0
- package/dist/rules/openapi/functions/oas-example-external-value.js +23 -0
- package/dist/rules/openapi/functions/oas-no-nullable.d.ts +13 -0
- package/dist/rules/openapi/functions/oas-no-nullable.js +22 -0
- package/dist/rules/openapi/functions/oas-op-id-unique.js +4 -2
- package/dist/rules/openapi/functions/oas-op-params.d.ts +7 -1
- package/dist/rules/openapi/functions/oas-op-params.js +35 -10
- package/dist/rules/openapi/functions/oas-op-security-defined.js +2 -2
- package/dist/rules/openapi/functions/oas-op-success-response.js +6 -1
- package/dist/rules/openapi/functions/oas-path-param.d.ts +10 -1
- package/dist/rules/openapi/functions/oas-path-param.js +87 -26
- package/dist/rules/openapi/functions/oas-server-variables.d.ts +6 -1
- package/dist/rules/openapi/functions/oas-server-variables.js +31 -2
- package/dist/rules/openapi/functions/oas-unused-component.js +14 -1
- package/dist/rules/openapi/oas.js +82 -25
- package/package.json +12 -4
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import { isMap, isScalar, isSeq, parseDocument } from '@amritk/yaml';
|
|
2
|
-
import { applyEdits, findNodeAtLocation,
|
|
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
|
-
/**
|
|
5
|
-
const
|
|
6
|
-
let s =
|
|
4
|
+
/** The offset of the start of the line containing `offset`. */
|
|
5
|
+
const lineStart = (text, offset) => {
|
|
6
|
+
let s = offset;
|
|
7
7
|
while (s > 0 && text.charCodeAt(s - 1) !== 10)
|
|
8
8
|
s--;
|
|
9
|
+
return s;
|
|
10
|
+
};
|
|
11
|
+
/** Expands `[start, end)` to cover whole lines: back to the line start, forward past the trailing newline. */
|
|
12
|
+
const expandLine = (text, start, end) => {
|
|
13
|
+
const s = lineStart(text, start);
|
|
9
14
|
let e = end;
|
|
10
15
|
while (e < text.length && text.charCodeAt(e) !== 10)
|
|
11
16
|
e++;
|
|
@@ -13,8 +18,32 @@ const expandLine = (text, start, end) => {
|
|
|
13
18
|
e++;
|
|
14
19
|
return [s, e];
|
|
15
20
|
};
|
|
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
|
+
const detectEol = (text) => {
|
|
27
|
+
const i = text.indexOf('\n');
|
|
28
|
+
return i > 0 && text.charCodeAt(i - 1) === 13 ? '\r\n' : '\n';
|
|
29
|
+
};
|
|
16
30
|
// --- YAML ------------------------------------------------------------------
|
|
17
|
-
|
|
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
|
+
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);
|
|
46
|
+
};
|
|
18
47
|
/** Navigates the YAML CST to the node at `path`, or `undefined` if absent. */
|
|
19
48
|
const yamlNodeAt = (root, path) => {
|
|
20
49
|
let current = root ?? undefined;
|
|
@@ -23,7 +52,10 @@ const yamlNodeAt = (root, path) => {
|
|
|
23
52
|
return undefined;
|
|
24
53
|
if (isMap(current)) {
|
|
25
54
|
const target = String(segment);
|
|
26
|
-
|
|
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);
|
|
27
59
|
current = pair?.value ?? undefined;
|
|
28
60
|
}
|
|
29
61
|
else if (isSeq(current)) {
|
|
@@ -41,11 +73,25 @@ const yamlPairAt = (root, path) => {
|
|
|
41
73
|
if (!parent || !isMap(parent))
|
|
42
74
|
return {};
|
|
43
75
|
const last = String(path[path.length - 1]);
|
|
44
|
-
const pair = parent.items.
|
|
76
|
+
const pair = parent.items.findLast((item) => keyName(item.key) === last);
|
|
45
77
|
if (!pair)
|
|
46
78
|
return {};
|
|
47
79
|
return { key: pair.key, ...(pair.value ? { value: pair.value } : {}) };
|
|
48
80
|
};
|
|
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
|
+
const plainStringRoundTrips = (plain, value) => {
|
|
89
|
+
const doc = parseDocument(plain);
|
|
90
|
+
return doc.errors.length === 0 && isScalar(doc.contents) && doc.contents.value === value;
|
|
91
|
+
};
|
|
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
|
+
const SAFE_YAML_KEY = /^[\w./-]+$/;
|
|
49
95
|
/** Serializes a scalar, preserving the quoting style of the value it replaces. */
|
|
50
96
|
const yamlScalar = (value, original) => {
|
|
51
97
|
if (typeof value !== 'string')
|
|
@@ -55,7 +101,10 @@ const yamlScalar = (value, original) => {
|
|
|
55
101
|
return JSON.stringify(value);
|
|
56
102
|
if (quote === 39 /* ' */)
|
|
57
103
|
return `'${value.replace(/'/g, "''")}'`;
|
|
58
|
-
|
|
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);
|
|
59
108
|
};
|
|
60
109
|
const yamlKey = (key, original) => {
|
|
61
110
|
const quote = original.charCodeAt(0);
|
|
@@ -63,11 +112,10 @@ const yamlKey = (key, original) => {
|
|
|
63
112
|
return JSON.stringify(key);
|
|
64
113
|
if (quote === 39)
|
|
65
114
|
return `'${key.replace(/'/g, "''")}'`;
|
|
66
|
-
|
|
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);
|
|
67
118
|
};
|
|
68
|
-
// Keys made only of these characters are safe to write bare in YAML; anything
|
|
69
|
-
// else (spaces, colons, flow indicators) gets double-quoted to stay valid.
|
|
70
|
-
const SAFE_YAML_KEY = /^[\w./-]+$/;
|
|
71
119
|
/** Serializes a key for an inserted property, quoting it only when it needs to be. */
|
|
72
120
|
const yamlInsertKey = (key) => (SAFE_YAML_KEY.test(key) ? key : JSON.stringify(key));
|
|
73
121
|
/**
|
|
@@ -77,23 +125,54 @@ const yamlInsertKey = (key) => (SAFE_YAML_KEY.test(key) ? key : JSON.stringify(k
|
|
|
77
125
|
* one line without us having to re-implement a block serializer.
|
|
78
126
|
*/
|
|
79
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. */
|
|
129
|
+
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);
|
|
135
|
+
};
|
|
80
136
|
/** Returns the leading whitespace of the line containing `offset` (its indentation). */
|
|
81
137
|
const lineIndent = (text, offset) => {
|
|
82
|
-
|
|
83
|
-
while (start > 0 && text.charCodeAt(start - 1) !== 10)
|
|
84
|
-
start--;
|
|
138
|
+
const start = lineStart(text, offset);
|
|
85
139
|
let end = start;
|
|
86
140
|
while (end < text.length && (text.charCodeAt(end) === 32 || text.charCodeAt(end) === 9))
|
|
87
141
|
end++;
|
|
88
142
|
return text.slice(start, end);
|
|
89
143
|
};
|
|
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
|
+
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);
|
|
157
|
+
};
|
|
90
158
|
const applyYamlOp = (text, op) => {
|
|
91
159
|
const root = parseDocument(text).contents;
|
|
160
|
+
const eol = detectEol(text);
|
|
92
161
|
switch (op.op) {
|
|
93
162
|
case 'setValue': {
|
|
94
163
|
const node = yamlNodeAt(root, op.path);
|
|
95
|
-
if (!node)
|
|
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
|
+
}
|
|
96
174
|
return text;
|
|
175
|
+
}
|
|
97
176
|
// Scalars keep the node's original quoting; objects/arrays are written as
|
|
98
177
|
// inline flow JSON (valid YAML) so a scalar can be widened to a collection.
|
|
99
178
|
const replacement = op.value !== null && typeof op.value === 'object'
|
|
@@ -112,7 +191,7 @@ const applyYamlOp = (text, op) => {
|
|
|
112
191
|
if (!parent || !isMap(parent))
|
|
113
192
|
return text;
|
|
114
193
|
const last = String(op.path[op.path.length - 1]);
|
|
115
|
-
const index = parent.items.
|
|
194
|
+
const index = parent.items.findLastIndex((item) => keyName(item.key) === last);
|
|
116
195
|
if (index === -1)
|
|
117
196
|
return text;
|
|
118
197
|
const pair = parent.items[index];
|
|
@@ -133,6 +212,17 @@ const applyYamlOp = (text, op) => {
|
|
|
133
212
|
const prevEnd = (prev?.value ?? prev?.key).end;
|
|
134
213
|
return splice(text, prevEnd, (value ?? key).end, '');
|
|
135
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
|
+
}
|
|
136
226
|
// Block map: drop the whole line(s) the property occupies.
|
|
137
227
|
const [start, end] = expandLine(text, key.start, value ? value.end : key.end);
|
|
138
228
|
return splice(text, start, end, '');
|
|
@@ -153,7 +243,21 @@ const applyYamlOp = (text, op) => {
|
|
|
153
243
|
}
|
|
154
244
|
case 'insertProperty': {
|
|
155
245
|
const parent = yamlNodeAt(root, op.path);
|
|
156
|
-
if (!parent
|
|
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))
|
|
157
261
|
return text;
|
|
158
262
|
// Inserting is additive only; if the key is already there we leave it alone.
|
|
159
263
|
if (parent.items.some((item) => keyName(item.key) === op.key))
|
|
@@ -174,10 +278,15 @@ const applyYamlOp = (text, op) => {
|
|
|
174
278
|
const lastKey = last?.key;
|
|
175
279
|
if (!lastKey)
|
|
176
280
|
return text;
|
|
177
|
-
|
|
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);
|
|
178
287
|
const [, end] = expandLine(text, lastKey.start, (last?.value ?? lastKey).end);
|
|
179
|
-
const lead = end > 0 && text.charCodeAt(end - 1) !== 10 ?
|
|
180
|
-
return splice(text, end, end, `${lead}${indent}${pair}
|
|
288
|
+
const lead = end > 0 && text.charCodeAt(end - 1) !== 10 ? eol : '';
|
|
289
|
+
return splice(text, end, end, `${lead}${indent}${pair}${eol}`);
|
|
181
290
|
}
|
|
182
291
|
case 'insertItem': {
|
|
183
292
|
const seq = yamlNodeAt(root, op.path);
|
|
@@ -192,22 +301,41 @@ const applyYamlOp = (text, op) => {
|
|
|
192
301
|
// Block sequence: we need an existing `- item` line to mirror its indentation and style.
|
|
193
302
|
if (seq.items.length === 0)
|
|
194
303
|
return text;
|
|
195
|
-
const blocks =
|
|
304
|
+
const blocks = seqBlocks(text, seq.items);
|
|
196
305
|
const regionStart = blocks[0][0];
|
|
197
306
|
const regionEnd = blocks[blocks.length - 1][1];
|
|
198
307
|
const lines = seq.items.map((_, index) => text.slice(...blocks[index]));
|
|
199
|
-
|
|
308
|
+
const newLine = `${seqItemIndent(text, seq.items[0])}- ${value}${eol}`;
|
|
309
|
+
lines.splice(clampIndex(op.index, lines.length), 0, newLine);
|
|
200
310
|
return splice(text, regionStart, regionEnd, lines.join(''));
|
|
201
311
|
}
|
|
202
312
|
}
|
|
203
313
|
};
|
|
204
314
|
/** Clamps an optional insertion index into `[0, length]`, defaulting to an append. */
|
|
205
315
|
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
|
+
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;
|
|
332
|
+
};
|
|
206
333
|
/**
|
|
207
334
|
* Rewrites a YAML sequence to contain exactly `newItems` (a subset and/or
|
|
208
335
|
* reordering of the original nodes), preserving each kept item's own text. Flow
|
|
209
336
|
* sequences (`[a, b]`) are rebuilt inside their brackets; block sequences (one
|
|
210
|
-
* `- item` per line) are rebuilt from their whole-line spans
|
|
337
|
+
* `- item` per line) are rebuilt from their whole-line spans, carrying each item's
|
|
338
|
+
* preceding comments with it.
|
|
211
339
|
*/
|
|
212
340
|
const rewriteYamlSeq = (text, seq, newItems) => {
|
|
213
341
|
const isFlow = text.charCodeAt(seq.start) === 91; /* [ */
|
|
@@ -217,7 +345,7 @@ const rewriteYamlSeq = (text, seq, newItems) => {
|
|
|
217
345
|
}
|
|
218
346
|
if (seq.items.length === 0)
|
|
219
347
|
return text;
|
|
220
|
-
const blocks =
|
|
348
|
+
const blocks = seqBlocks(text, seq.items);
|
|
221
349
|
const regionStart = blocks[0][0];
|
|
222
350
|
const regionEnd = blocks[blocks.length - 1][1];
|
|
223
351
|
const blockText = new Map(seq.items.map((item, index) => [item, text.slice(...blocks[index])]));
|
|
@@ -225,7 +353,12 @@ const rewriteYamlSeq = (text, seq, newItems) => {
|
|
|
225
353
|
return splice(text, regionStart, regionEnd, rebuilt);
|
|
226
354
|
};
|
|
227
355
|
// --- JSON ------------------------------------------------------------------
|
|
228
|
-
|
|
356
|
+
/** Formatting options for jsonc-parser edits, carrying the file's own line ending. */
|
|
357
|
+
const jsonFormat = (eol) => ({
|
|
358
|
+
insertSpaces: true,
|
|
359
|
+
tabSize: 2,
|
|
360
|
+
eol,
|
|
361
|
+
});
|
|
229
362
|
/**
|
|
230
363
|
* jsonc-parser keys path segments by JS type — strings index objects, numbers
|
|
231
364
|
* index arrays — but a finding's path carries numeric-like object keys (e.g. a
|
|
@@ -243,40 +376,76 @@ const normalizeJsonPath = (root, path) => {
|
|
|
243
376
|
}
|
|
244
377
|
return result;
|
|
245
378
|
};
|
|
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
|
+
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);
|
|
403
|
+
};
|
|
246
404
|
const applyJsonOp = (text, op) => {
|
|
247
405
|
const root = parseTree(text);
|
|
248
406
|
if (!root)
|
|
249
407
|
return text;
|
|
250
408
|
const path = normalizeJsonPath(root, op.path);
|
|
409
|
+
const format = jsonFormat(detectEol(text));
|
|
251
410
|
switch (op.op) {
|
|
252
411
|
case 'setValue':
|
|
253
|
-
|
|
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 }));
|
|
254
418
|
case 'removeProperty':
|
|
255
|
-
|
|
419
|
+
if (!findNodeAtLocation(root, path))
|
|
420
|
+
return text;
|
|
421
|
+
return applyEdits(text, modify(text, path, undefined, { formattingOptions: format }));
|
|
256
422
|
case 'renameProperty': {
|
|
257
423
|
const valueNode = findNodeAtLocation(root, path);
|
|
258
|
-
|
|
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];
|
|
259
430
|
if (!keyNode)
|
|
260
431
|
return text;
|
|
261
432
|
return splice(text, keyNode.offset, keyNode.offset + keyNode.length, JSON.stringify(op.newKey));
|
|
262
433
|
}
|
|
263
434
|
case 'removeItems': {
|
|
264
|
-
// jsonc-parser's per-index array removal is unreliable, so rewrite the
|
|
265
|
-
// whole array with the kept elements instead.
|
|
266
435
|
const node = findNodeAtLocation(root, path);
|
|
267
|
-
if (!node)
|
|
436
|
+
if (!node || node.type !== 'array')
|
|
268
437
|
return text;
|
|
269
438
|
const removed = new Set(op.indices);
|
|
270
|
-
const kept =
|
|
271
|
-
return
|
|
439
|
+
const kept = (node.children ?? []).filter((_, index) => !removed.has(index));
|
|
440
|
+
return rewriteJsonArray(text, node, kept);
|
|
272
441
|
}
|
|
273
442
|
case 'reorderArray': {
|
|
274
443
|
const node = findNodeAtLocation(root, path);
|
|
275
|
-
if (!node)
|
|
444
|
+
if (!node || node.type !== 'array')
|
|
276
445
|
return text;
|
|
277
|
-
const
|
|
278
|
-
const reordered = op.order.map((index) =>
|
|
279
|
-
return
|
|
446
|
+
const children = node.children ?? [];
|
|
447
|
+
const reordered = op.order.map((index) => children[index]).filter((child) => child != null);
|
|
448
|
+
return rewriteJsonArray(text, node, reordered);
|
|
280
449
|
}
|
|
281
450
|
case 'insertProperty': {
|
|
282
451
|
const node = findNodeAtLocation(root, path);
|
|
@@ -285,14 +454,14 @@ const applyJsonOp = (text, op) => {
|
|
|
285
454
|
// Additive only: writing to an existing key would replace its value, so bail.
|
|
286
455
|
if (node.children?.some((property) => property.children?.[0]?.value === op.key))
|
|
287
456
|
return text;
|
|
288
|
-
return applyEdits(text, modify(text, [...path, op.key], op.value, { formattingOptions:
|
|
457
|
+
return applyEdits(text, modify(text, [...path, op.key], op.value, { formattingOptions: format }));
|
|
289
458
|
}
|
|
290
459
|
case 'insertItem': {
|
|
291
460
|
const node = findNodeAtLocation(root, path);
|
|
292
461
|
if (!node || node.type !== 'array')
|
|
293
462
|
return text;
|
|
294
463
|
const index = clampIndex(op.index, node.children?.length ?? 0);
|
|
295
|
-
return applyEdits(text, modify(text, [...path, index], op.value, { formattingOptions:
|
|
464
|
+
return applyEdits(text, modify(text, [...path, index], op.value, { formattingOptions: format, isArrayInsertion: true }));
|
|
296
465
|
}
|
|
297
466
|
}
|
|
298
467
|
};
|
package/dist/parsers/types.d.ts
CHANGED
|
@@ -43,8 +43,20 @@ export type IParseResult<T = unknown> = {
|
|
|
43
43
|
};
|
|
44
44
|
/** Tuning for how strictly the parser treats YAML/JSON edge cases. */
|
|
45
45
|
export type IParserOptions = {
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Severity for duplicate object keys. Default: error. `false`/`"off"` disables
|
|
48
|
+
* detection entirely; any {@link DiagnosticSeverity} reports the duplicate at
|
|
49
|
+
* that level instead of the default error.
|
|
50
|
+
*/
|
|
47
51
|
duplicateKeys?: DiagnosticSeverity | 'off' | false;
|
|
48
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* Severity for YAML values that cannot round-trip through JSON. The core
|
|
54
|
+
* schema projects `.nan`/`.inf`/`-.inf` to the non-finite numbers `NaN`,
|
|
55
|
+
* `Infinity`, and `-Infinity`, which `JSON.stringify` silently rewrites to
|
|
56
|
+
* `null`; each such value is reported at the configured severity.
|
|
57
|
+
*
|
|
58
|
+
* Detection is opt-in. Default (`undefined`), `false`, and `"off"` disable it;
|
|
59
|
+
* any {@link DiagnosticSeverity} enables detection and reports at that level.
|
|
60
|
+
*/
|
|
49
61
|
incompatibleValues?: DiagnosticSeverity | 'off' | false;
|
|
50
62
|
};
|
package/dist/parsers/yaml.d.ts
CHANGED
|
@@ -2,5 +2,15 @@ import { type IParseResult, type IParserOptions } from './types.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Parses YAML (a JSON superset, so this handles both) into data plus a source
|
|
4
4
|
* map, surfacing duplicate-key and incompatible-value diagnostics per `options`.
|
|
5
|
+
*
|
|
6
|
+
* A `---`-separated stream is parsed as multiple documents (via
|
|
7
|
+
* `parseAllDocuments`), each linted independently: `data` becomes an array of
|
|
8
|
+
* per-document values and every position key / finding path is prefixed with the
|
|
9
|
+
* zero-based document index, so a violation in a later document resolves to its
|
|
10
|
+
* own range instead of being silently dropped. A single-document source keeps the
|
|
11
|
+
* flat shape — `data` is the document value and paths are unprefixed — so existing
|
|
12
|
+
* callers and rulesets are unaffected. Node ranges are absolute offsets into the
|
|
13
|
+
* shared source, so diagnostics and positions in later documents are already
|
|
14
|
+
* correct without any per-document offset arithmetic.
|
|
5
15
|
*/
|
|
6
16
|
export declare const parseYaml: <T = unknown>(source: string, options?: IParserOptions) => IParseResult<T>;
|