confdiff 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ export { diff, formatPath, typeOf } from "./diff.js";
2
+ export { parseContent, parseEnv, parseCsv, parseXml, keyRowsByColumn, detectFormat, sniff, } from "./parse.js";
3
+ export { renderText, renderJson } from "./render.js";
4
+ import { diff as _diff } from "./diff.js";
5
+ import { parseContent, keyRowsByColumn, detectFormat } from "./parse.js";
6
+ /** High-level helper: compare two raw strings of (possibly different) formats. */
7
+ export function compare(a, b, opts = {}) {
8
+ const fa = opts.formatA ?? detectFormat(opts.filenameA, a);
9
+ const fb = opts.formatB ?? detectFormat(opts.filenameB, b);
10
+ let va = parseContent(a, fa);
11
+ let vb = parseContent(b, fb);
12
+ if (opts.csvKey) {
13
+ if (fa === "csv")
14
+ va = keyRowsByColumn(va, opts.csvKey);
15
+ if (fb === "csv")
16
+ vb = keyRowsByColumn(vb, opts.csvKey);
17
+ }
18
+ return _diff(va, vb, opts);
19
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Strip `//` line and block comments and trailing commas from a JSON document
3
+ * so JSON-with-comments config files parse cleanly: `tsconfig.json`, VS Code
4
+ * `settings.json`, `devcontainer.json`, `.eslintrc.json`, `.babelrc`, and any
5
+ * `.jsonc`/`.json5` file that only uses comments + trailing commas. Comments and
6
+ * trailing commas are never valid in strict JSON, so this is a NO-OP on any
7
+ * conforming JSON document — behaviour is byte-for-byte unchanged for ordinary
8
+ * input. String contents (including `//`, `/*` and commas inside double-quoted
9
+ * strings) are preserved. Comments are replaced with equal-length whitespace and
10
+ * a trailing comma with a single space, so parse-error line/column numbers still
11
+ * line up with the source.
12
+ */
13
+ export declare function stripJsonc(text: string): string;
14
+ export type Format = "json" | "yaml" | "toml" | "ini" | "env" | "properties" | "csv" | "xml";
15
+ export type Value = unknown;
16
+ /** Extensions confdiff recognizes as structured-config files (for directory diffs). */
17
+ export declare const KNOWN_EXTENSIONS: readonly string[];
18
+ /** True if a bare filename looks like a config file confdiff can parse by extension. */
19
+ export declare function isConfigFilename(name: string): boolean;
20
+ /** Detect the format from a filename, falling back to content sniffing. */
21
+ export declare function detectFormat(filename: string | undefined, content: string): Format;
22
+ /** Best-effort content sniffing when the extension is unknown. */
23
+ export declare function sniff(content: string): Format;
24
+ export declare function parseEnv(content: string): Record<string, string>;
25
+ /**
26
+ * Parse a Java `.properties` file (as consumed by `java.util.Properties.load`).
27
+ *
28
+ * Unlike `.env`, a properties file accepts THREE key/value separators — `=`,
29
+ * `:`, or whitespace — plus `#`/`!` comments, backslash line continuations, and
30
+ * `\uXXXX`/`\t`/`\:` style escapes. Treating `.properties` as `.env` (only `=`)
31
+ * silently dropped every `key: value` or `key value` line, so comparing two real
32
+ * Spring/log4j property files produced wrong, incomplete diffs. This parser
33
+ * handles the full format so those lines are compared instead of vanishing.
34
+ */
35
+ export declare function parseProperties(content: string): Record<string, string>;
36
+ /**
37
+ * Parse CSV/TSV into an array of row objects keyed by the header row.
38
+ *
39
+ * Zero-config: the delimiter (`,` `\t` `;` `|`) is auto-detected from the header
40
+ * line unless `delimiter` is given. Handles RFC 4180 quoting — quoted fields may
41
+ * contain the delimiter, newlines, and `""`-escaped quotes. All cell values are
42
+ * strings, so pair with `--loose` to compare `"80"` against `80`, or with
43
+ * `--csv-key <col>` (in the CLI) to match rows by a key column instead of by
44
+ * position.
45
+ */
46
+ export declare function parseCsv(content: string, delimiter?: string): Record<string, string>[];
47
+ /**
48
+ * Re-key an array of row objects into an object keyed by `column`, so rows are
49
+ * matched by that key rather than by position. Throws on missing column or
50
+ * duplicate keys (which would silently drop rows).
51
+ */
52
+ export declare function keyRowsByColumn(rows: Record<string, string>[], column: string): Record<string, Record<string, string>>;
53
+ /**
54
+ * Parse XML into a plain nested object so it can be diffed semantically —
55
+ * element/attribute order and insignificant whitespace are ignored, and only
56
+ * structural or value changes are reported.
57
+ *
58
+ * Attributes are keyed with an `@_` prefix (`@_id`), an element's own text
59
+ * becomes `#text`, and repeated child elements become arrays. Scalar text and
60
+ * attribute values are type-coerced (so `<port>80</port>` compares equal to a
61
+ * JSON `"port": 80`); use `--loose` if you'd rather not coerce.
62
+ */
63
+ export declare function parseXml(content: string): Value;
64
+ export declare function parseContent(content: string, format: Format): Value;
package/dist/parse.js ADDED
@@ -0,0 +1,566 @@
1
+ import { extname } from "node:path";
2
+ import { parse as parseYaml, parseAllDocuments } from "yaml";
3
+ import { parse as parseToml } from "smol-toml";
4
+ /**
5
+ * Integers outside JS's safe range (|n| > 2^53-1) lose precision when parsed
6
+ * into a plain `number` — e.g. Discord/Twitter "snowflake" IDs or 64-bit
7
+ * counters. That silently makes two *different* IDs compare EQUAL, the worst
8
+ * failure mode for a diff tool. We preserve such integers losslessly as
9
+ * `BigInt` (JSON via a reviver, YAML/TOML via their bigint options) and
10
+ * normalise safe-range bigints back to plain numbers so ordinary values keep
11
+ * their usual type and cross-format compares still line up (a `1` is a `1`).
12
+ */
13
+ function normalizeBigInts(value) {
14
+ if (typeof value === "bigint") {
15
+ return isSafeBig(value) ? Number(value) : value;
16
+ }
17
+ if (Array.isArray(value)) {
18
+ for (let i = 0; i < value.length; i++)
19
+ value[i] = normalizeBigInts(value[i]);
20
+ return value;
21
+ }
22
+ if (value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
23
+ const obj = value;
24
+ for (const k of Object.keys(obj))
25
+ obj[k] = normalizeBigInts(obj[k]);
26
+ return obj;
27
+ }
28
+ return value;
29
+ }
30
+ function isSafeBig(n) {
31
+ return n >= BigInt(Number.MIN_SAFE_INTEGER) && n <= BigInt(Number.MAX_SAFE_INTEGER);
32
+ }
33
+ /**
34
+ * Strip `//` line and block comments and trailing commas from a JSON document
35
+ * so JSON-with-comments config files parse cleanly: `tsconfig.json`, VS Code
36
+ * `settings.json`, `devcontainer.json`, `.eslintrc.json`, `.babelrc`, and any
37
+ * `.jsonc`/`.json5` file that only uses comments + trailing commas. Comments and
38
+ * trailing commas are never valid in strict JSON, so this is a NO-OP on any
39
+ * conforming JSON document — behaviour is byte-for-byte unchanged for ordinary
40
+ * input. String contents (including `//`, `/*` and commas inside double-quoted
41
+ * strings) are preserved. Comments are replaced with equal-length whitespace and
42
+ * a trailing comma with a single space, so parse-error line/column numbers still
43
+ * line up with the source.
44
+ */
45
+ export function stripJsonc(text) {
46
+ const out = [];
47
+ const n = text.length;
48
+ let inString = false;
49
+ for (let i = 0; i < n; i++) {
50
+ const c = text[i];
51
+ if (inString) {
52
+ out.push(c);
53
+ if (c === "\\" && i + 1 < n) {
54
+ out.push(text[i + 1]);
55
+ i++;
56
+ }
57
+ else if (c === '"') {
58
+ inString = false;
59
+ }
60
+ continue;
61
+ }
62
+ if (c === '"') {
63
+ inString = true;
64
+ out.push(c);
65
+ continue;
66
+ }
67
+ if (c === "/" && text[i + 1] === "/") {
68
+ i += 2;
69
+ out.push(" ");
70
+ while (i < n && text[i] !== "\n") {
71
+ out.push(text[i] === "\t" ? "\t" : " ");
72
+ i++;
73
+ }
74
+ if (i < n)
75
+ out.push("\n"); // keep the newline
76
+ continue;
77
+ }
78
+ if (c === "/" && text[i + 1] === "*") {
79
+ i += 2;
80
+ out.push(" ");
81
+ while (i < n && !(text[i] === "*" && text[i + 1] === "/")) {
82
+ out.push(text[i] === "\n" ? "\n" : text[i] === "\t" ? "\t" : " ");
83
+ i++;
84
+ }
85
+ if (i + 1 < n) {
86
+ out.push(" ");
87
+ i++; // the loop's i++ consumes the second char
88
+ }
89
+ continue;
90
+ }
91
+ out.push(c);
92
+ }
93
+ // Remove trailing commas: a comma whose next non-whitespace char is } or ].
94
+ const s = out;
95
+ let inStr2 = false;
96
+ for (let i = 0; i < s.length; i++) {
97
+ const c = s[i];
98
+ if (inStr2) {
99
+ if (c === "\\")
100
+ i++;
101
+ else if (c === '"')
102
+ inStr2 = false;
103
+ continue;
104
+ }
105
+ if (c === '"') {
106
+ inStr2 = true;
107
+ continue;
108
+ }
109
+ if (c === ",") {
110
+ let j = i + 1;
111
+ while (j < s.length && /\s/.test(s[j]))
112
+ j++;
113
+ if (j < s.length && (s[j] === "}" || s[j] === "]"))
114
+ s[i] = " ";
115
+ }
116
+ }
117
+ return s.join("");
118
+ }
119
+ /**
120
+ * Parse JSON while preserving integers that exceed JS's safe range as BigInt.
121
+ * The fast path (the vast majority of documents) is plain `JSON.parse`, so
122
+ * behaviour is byte-for-byte unchanged. Only when the text contains a run of 16+
123
+ * digits — the shortest that can exceed 2^53-1 — do we re-parse losslessly via
124
+ * the (already-bundled, battle-tested) YAML reader, which is a strict superset
125
+ * of JSON and supports `intAsBigInt`. `normalizeBigInts` then demotes any
126
+ * safe-range bigints back to plain numbers, so ordinary values are untouched.
127
+ * This works on every supported Node version (unlike the Node 21+ JSON reviver
128
+ * `context.source`) and in the browser playground. JSON-with-comments (`.jsonc`
129
+ * / tsconfig-style) input is tolerated via `stripJsonc` first.
130
+ */
131
+ function parseJsonContent(content) {
132
+ const text = stripJsonc(content);
133
+ if (!/\d{16,}/.test(text))
134
+ return JSON.parse(text);
135
+ // Validate as JSON first so malformed input still yields a JSON-style error.
136
+ JSON.parse(text);
137
+ return normalizeBigInts(parseYaml(text, { intAsBigInt: true, uniqueKeys: false }));
138
+ }
139
+ import ini from "ini";
140
+ import { XMLParser, XMLValidator } from "fast-xml-parser";
141
+ const EXT_MAP = {
142
+ ".json": "json",
143
+ ".jsonc": "json",
144
+ ".json5": "json",
145
+ ".yaml": "yaml",
146
+ ".yml": "yaml",
147
+ ".toml": "toml",
148
+ ".ini": "ini",
149
+ ".cfg": "ini",
150
+ ".conf": "ini",
151
+ ".env": "env",
152
+ ".properties": "properties",
153
+ ".csv": "csv",
154
+ ".tsv": "csv",
155
+ ".xml": "xml",
156
+ ".xhtml": "xml",
157
+ ".svg": "xml",
158
+ ".plist": "xml",
159
+ ".xsd": "xml",
160
+ };
161
+ /** Extensions confdiff recognizes as structured-config files (for directory diffs). */
162
+ export const KNOWN_EXTENSIONS = Object.keys(EXT_MAP);
163
+ /** True if a bare filename looks like a config file confdiff can parse by extension. */
164
+ export function isConfigFilename(name) {
165
+ const base = name.toLowerCase();
166
+ if (base === ".env" || base.startsWith(".env."))
167
+ return true;
168
+ const ext = extname(base);
169
+ return !!(ext && EXT_MAP[ext]);
170
+ }
171
+ /** Detect the format from a filename, falling back to content sniffing. */
172
+ export function detectFormat(filename, content) {
173
+ if (filename) {
174
+ const base = filename.toLowerCase();
175
+ // .env, .env.local, .env.production ...
176
+ if (base === ".env" || base.startsWith(".env.") || base.includes("/.env"))
177
+ return "env";
178
+ const ext = extname(base);
179
+ if (ext && EXT_MAP[ext])
180
+ return EXT_MAP[ext];
181
+ }
182
+ return sniff(content);
183
+ }
184
+ /** Best-effort content sniffing when the extension is unknown. */
185
+ export function sniff(content) {
186
+ const trimmed = content.trim();
187
+ if (!trimmed)
188
+ return "json";
189
+ if (trimmed[0] === "<")
190
+ return "xml";
191
+ if (trimmed[0] === "{" || trimmed[0] === "[") {
192
+ try {
193
+ JSON.parse(stripJsonc(trimmed));
194
+ return "json";
195
+ }
196
+ catch {
197
+ /* not strict json, fall through */
198
+ }
199
+ }
200
+ // env: lines of KEY=VALUE, no nesting, no leading spaces on keys
201
+ const lines = trimmed.split(/\r?\n/).filter((l) => l.trim() && !l.trim().startsWith("#"));
202
+ const envLike = lines.length > 0 && lines.every((l) => /^[A-Za-z_][A-Za-z0-9_.]*\s*=/.test(l));
203
+ const hasSection = lines.some((l) => /^\s*\[[^\]]+\]\s*$/.test(l));
204
+ if (hasSection)
205
+ return "ini";
206
+ if (envLike)
207
+ return "env";
208
+ return "yaml"; // YAML is a superset of JSON and forgiving
209
+ }
210
+ export function parseEnv(content) {
211
+ const out = {};
212
+ for (const rawLine of content.split(/\r?\n/)) {
213
+ const line = rawLine.trim();
214
+ if (!line || line.startsWith("#"))
215
+ continue;
216
+ const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)\s*=\s*(.*)$/);
217
+ if (!m)
218
+ continue;
219
+ let val = m[2];
220
+ // strip inline comments only for unquoted values
221
+ if (!(val.startsWith('"') || val.startsWith("'"))) {
222
+ const hash = val.indexOf(" #");
223
+ if (hash !== -1)
224
+ val = val.slice(0, hash);
225
+ val = val.trim();
226
+ }
227
+ else {
228
+ const quote = val[0];
229
+ const end = val.indexOf(quote, 1);
230
+ if (end !== -1)
231
+ val = val.slice(1, end);
232
+ }
233
+ out[m[1]] = val;
234
+ }
235
+ return out;
236
+ }
237
+ /**
238
+ * Parse a Java `.properties` file (as consumed by `java.util.Properties.load`).
239
+ *
240
+ * Unlike `.env`, a properties file accepts THREE key/value separators — `=`,
241
+ * `:`, or whitespace — plus `#`/`!` comments, backslash line continuations, and
242
+ * `\uXXXX`/`\t`/`\:` style escapes. Treating `.properties` as `.env` (only `=`)
243
+ * silently dropped every `key: value` or `key value` line, so comparing two real
244
+ * Spring/log4j property files produced wrong, incomplete diffs. This parser
245
+ * handles the full format so those lines are compared instead of vanishing.
246
+ */
247
+ export function parseProperties(content) {
248
+ const out = {};
249
+ // 1. Split into logical lines, honouring backslash continuation. A line
250
+ // continues onto the next when it ends with an ODD number of backslashes.
251
+ const physical = content.split(/\r\n|\r|\n/);
252
+ const logical = [];
253
+ let buf = null;
254
+ for (const raw of physical) {
255
+ // Leading whitespace of a *continuation* line is stripped; of a fresh line
256
+ // it is stripped when we locate the key below. Strip here for both.
257
+ const line = buf === null ? raw : raw.replace(/^[ \t\f]+/, "");
258
+ const joined = buf === null ? line : buf + line;
259
+ // Count trailing backslashes to decide if THIS logical line continues.
260
+ let bs = 0;
261
+ for (let i = joined.length - 1; i >= 0 && joined[i] === "\\"; i--)
262
+ bs++;
263
+ if (bs % 2 === 1) {
264
+ buf = joined.slice(0, -1); // drop the escaping backslash, keep accumulating
265
+ }
266
+ else {
267
+ logical.push(joined);
268
+ buf = null;
269
+ }
270
+ }
271
+ if (buf !== null)
272
+ logical.push(buf);
273
+ for (const logLine of logical) {
274
+ // 2. Skip leading whitespace, then blank/comment lines (# or !).
275
+ const s = logLine.replace(/^[ \t\f]+/, "");
276
+ if (s === "" || s[0] === "#" || s[0] === "!")
277
+ continue;
278
+ // 3. Find the key: characters up to the first UNESCAPED separator, which is
279
+ // whitespace, `=`, or `:`.
280
+ let i = 0;
281
+ let keyEnd = s.length;
282
+ for (; i < s.length; i++) {
283
+ const c = s[i];
284
+ if (c === "\\") {
285
+ i++; // skip the escaped char (it belongs to the key)
286
+ continue;
287
+ }
288
+ if (c === " " || c === "\t" || c === "\f" || c === "=" || c === ":") {
289
+ keyEnd = i;
290
+ break;
291
+ }
292
+ }
293
+ const rawKey = s.slice(0, keyEnd);
294
+ // 4. Skip whitespace after the key; then an optional `=`/`:`; then more ws.
295
+ let j = keyEnd;
296
+ while (j < s.length && (s[j] === " " || s[j] === "\t" || s[j] === "\f"))
297
+ j++;
298
+ if (j < s.length && (s[j] === "=" || s[j] === ":")) {
299
+ j++;
300
+ while (j < s.length && (s[j] === " " || s[j] === "\t" || s[j] === "\f"))
301
+ j++;
302
+ }
303
+ const rawVal = s.slice(j);
304
+ out[unescapeProperties(rawKey)] = unescapeProperties(rawVal);
305
+ }
306
+ return out;
307
+ }
308
+ /** Process `\uXXXX`, `\t\n\r\f`, and `\<char>` escapes in a properties token. */
309
+ function unescapeProperties(str) {
310
+ let out = "";
311
+ for (let i = 0; i < str.length; i++) {
312
+ const c = str[i];
313
+ if (c !== "\\") {
314
+ out += c;
315
+ continue;
316
+ }
317
+ const n = str[++i];
318
+ if (n === undefined)
319
+ break;
320
+ switch (n) {
321
+ case "t":
322
+ out += "\t";
323
+ break;
324
+ case "n":
325
+ out += "\n";
326
+ break;
327
+ case "r":
328
+ out += "\r";
329
+ break;
330
+ case "f":
331
+ out += "\f";
332
+ break;
333
+ case "u": {
334
+ const hex = str.slice(i + 1, i + 5);
335
+ if (/^[0-9a-fA-F]{4}$/.test(hex)) {
336
+ out += String.fromCharCode(parseInt(hex, 16));
337
+ i += 4;
338
+ }
339
+ else {
340
+ out += "u";
341
+ }
342
+ break;
343
+ }
344
+ default:
345
+ // \= \: \ \\ and any other -> the literal following character
346
+ out += n;
347
+ }
348
+ }
349
+ return out;
350
+ }
351
+ /**
352
+ * Parse CSV/TSV into an array of row objects keyed by the header row.
353
+ *
354
+ * Zero-config: the delimiter (`,` `\t` `;` `|`) is auto-detected from the header
355
+ * line unless `delimiter` is given. Handles RFC 4180 quoting — quoted fields may
356
+ * contain the delimiter, newlines, and `""`-escaped quotes. All cell values are
357
+ * strings, so pair with `--loose` to compare `"80"` against `80`, or with
358
+ * `--csv-key <col>` (in the CLI) to match rows by a key column instead of by
359
+ * position.
360
+ */
361
+ export function parseCsv(content, delimiter) {
362
+ const src = content.replace(/^\uFEFF/, "");
363
+ const delim = delimiter ?? sniffDelimiter(src);
364
+ const rows = tokenizeCsv(src, delim);
365
+ // drop trailing fully-empty rows produced by a final newline
366
+ while (rows.length && rows[rows.length - 1].every((c) => c === ""))
367
+ rows.pop();
368
+ if (rows.length === 0)
369
+ return [];
370
+ const header = rows[0];
371
+ const out = [];
372
+ for (let r = 1; r < rows.length; r++) {
373
+ const cells = rows[r];
374
+ const obj = {};
375
+ for (let c = 0; c < header.length; c++) {
376
+ obj[header[c]] = cells[c] ?? "";
377
+ }
378
+ out.push(obj);
379
+ }
380
+ return out;
381
+ }
382
+ function sniffDelimiter(content) {
383
+ const firstLine = content.slice(0, content.search(/\r?\n/) === -1 ? content.length : content.search(/\r?\n/));
384
+ const candidates = ["\t", ";", "|", ","];
385
+ let best = ",";
386
+ let bestCount = -1;
387
+ for (const d of candidates) {
388
+ const count = firstLine.split(d).length - 1;
389
+ if (count > bestCount) {
390
+ bestCount = count;
391
+ best = d;
392
+ }
393
+ }
394
+ return best;
395
+ }
396
+ /** RFC 4180-ish tokenizer supporting quoted fields with embedded delimiters/newlines. */
397
+ function tokenizeCsv(content, delim) {
398
+ const rows = [];
399
+ let field = "";
400
+ let row = [];
401
+ let inQuotes = false;
402
+ let i = 0;
403
+ const n = content.length;
404
+ while (i < n) {
405
+ const ch = content[i];
406
+ if (inQuotes) {
407
+ if (ch === '"') {
408
+ if (content[i + 1] === '"') {
409
+ field += '"';
410
+ i += 2;
411
+ continue;
412
+ }
413
+ inQuotes = false;
414
+ i++;
415
+ continue;
416
+ }
417
+ field += ch;
418
+ i++;
419
+ continue;
420
+ }
421
+ if (ch === '"') {
422
+ inQuotes = true;
423
+ i++;
424
+ continue;
425
+ }
426
+ if (ch === delim) {
427
+ row.push(field);
428
+ field = "";
429
+ i++;
430
+ continue;
431
+ }
432
+ if (ch === "\n" || ch === "\r") {
433
+ row.push(field);
434
+ field = "";
435
+ rows.push(row);
436
+ row = [];
437
+ if (ch === "\r" && content[i + 1] === "\n")
438
+ i++;
439
+ i++;
440
+ continue;
441
+ }
442
+ field += ch;
443
+ i++;
444
+ }
445
+ // flush trailing field/row (no final newline)
446
+ if (field !== "" || row.length > 0) {
447
+ row.push(field);
448
+ rows.push(row);
449
+ }
450
+ return rows;
451
+ }
452
+ /**
453
+ * Re-key an array of row objects into an object keyed by `column`, so rows are
454
+ * matched by that key rather than by position. Throws on missing column or
455
+ * duplicate keys (which would silently drop rows).
456
+ */
457
+ export function keyRowsByColumn(rows, column) {
458
+ const out = {};
459
+ for (let i = 0; i < rows.length; i++) {
460
+ const row = rows[i];
461
+ if (!Object.prototype.hasOwnProperty.call(row, column)) {
462
+ throw new Error(`csv key column "${column}" not found in header`);
463
+ }
464
+ const key = row[column];
465
+ if (Object.prototype.hasOwnProperty.call(out, key)) {
466
+ throw new Error(`duplicate csv key "${key}" in column "${column}"; use positional compare (drop --csv-key)`);
467
+ }
468
+ out[key] = row;
469
+ }
470
+ return out;
471
+ }
472
+ /**
473
+ * Parse XML into a plain nested object so it can be diffed semantically —
474
+ * element/attribute order and insignificant whitespace are ignored, and only
475
+ * structural or value changes are reported.
476
+ *
477
+ * Attributes are keyed with an `@_` prefix (`@_id`), an element's own text
478
+ * becomes `#text`, and repeated child elements become arrays. Scalar text and
479
+ * attribute values are type-coerced (so `<port>80</port>` compares equal to a
480
+ * JSON `"port": 80`); use `--loose` if you'd rather not coerce.
481
+ */
482
+ export function parseXml(content) {
483
+ const valid = XMLValidator.validate(content);
484
+ if (valid !== true) {
485
+ const err = valid.err;
486
+ const where = err?.line ? ` (line ${err.line})` : "";
487
+ throw new Error(`invalid XML${where}: ${err?.msg ?? "malformed document"}`);
488
+ }
489
+ const parser = new XMLParser({
490
+ ignoreAttributes: false,
491
+ attributeNamePrefix: "@_",
492
+ textNodeName: "#text",
493
+ parseTagValue: true,
494
+ parseAttributeValue: true,
495
+ trimValues: true,
496
+ ignoreDeclaration: true,
497
+ ignorePiTags: true,
498
+ processEntities: true,
499
+ });
500
+ return parser.parse(content);
501
+ }
502
+ /**
503
+ * Parse YAML, transparently supporting multi-document streams (`---`
504
+ * separators) as used by Kubernetes manifests, `kubectl get -o yaml`, and
505
+ * Helm renders. A single-document stream returns the document directly (so
506
+ * existing behaviour and cross-format compares are unchanged); a multi-document
507
+ * stream returns an array of documents (positional). Empty documents (e.g. a
508
+ * trailing `---`) are dropped so cosmetic separators don't create phantom diffs.
509
+ */
510
+ const YAML_OPTS = { merge: true, intAsBigInt: true };
511
+ function parseYamlContent(content) {
512
+ const docs = parseAllDocuments(content, YAML_OPTS);
513
+ if (docs.length <= 1) {
514
+ // Preserve exact single-doc semantics (including empty/blank input).
515
+ return parseYaml(content, YAML_OPTS);
516
+ }
517
+ const values = [];
518
+ for (const doc of docs) {
519
+ if (doc.errors.length > 0) {
520
+ throw doc.errors[0];
521
+ }
522
+ const js = doc.toJS();
523
+ // Skip truly empty documents (null/undefined from bare `---`).
524
+ if (js === null || js === undefined)
525
+ continue;
526
+ values.push(js);
527
+ }
528
+ // Collapse to single-document semantics when only zero/one real document
529
+ // remains after dropping empties, so cosmetic `---` separators never change
530
+ // the diff shape.
531
+ if (values.length === 0)
532
+ return parseYaml(content);
533
+ if (values.length === 1)
534
+ return values[0];
535
+ return values;
536
+ }
537
+ export function parseContent(content, format) {
538
+ // An empty or whitespace-only input is treated as an empty document, not a
539
+ // parse error. This mirrors how a git diff driver sees a newly-added or
540
+ // just-emptied config file (old side empty), so `confdiff empty.json full.json`
541
+ // cleanly reports every key as added instead of crashing. Behaviour is now
542
+ // consistent across every format (previously empty JSON threw).
543
+ if (content.trim() === "") {
544
+ return format === "csv" ? [] : {};
545
+ }
546
+ switch (format) {
547
+ case "json":
548
+ return parseJsonContent(content);
549
+ case "yaml":
550
+ return normalizeBigInts(parseYamlContent(content));
551
+ case "toml":
552
+ return normalizeBigInts(parseToml(content, { integersAsBigInt: true }));
553
+ case "ini":
554
+ return ini.parse(content);
555
+ case "env":
556
+ return parseEnv(content);
557
+ case "properties":
558
+ return parseProperties(content);
559
+ case "csv":
560
+ return parseCsv(content);
561
+ case "xml":
562
+ return parseXml(content);
563
+ default:
564
+ throw new Error(`unsupported format: ${format}`);
565
+ }
566
+ }
@@ -0,0 +1,39 @@
1
+ import { type Path } from "./diff.js";
2
+ /** Does a key name look like it holds a secret, by built-in heuristics? */
3
+ export declare function looksSecret(seg: string): boolean;
4
+ /**
5
+ * Shannon entropy (bits per character) of a string. A uniformly random
6
+ * high-entropy string (API key, JWT, base64 token) scores high (~4–6);
7
+ * repetitive or natural-language text scores low.
8
+ */
9
+ export declare function shannonEntropy(s: string): number;
10
+ /**
11
+ * Content-based secret heuristic: does a VALUE *look* like a random credential,
12
+ * regardless of its key name? Catches secrets stored under non-obvious keys
13
+ * (`x`, `data`, `value`) that the key-name heuristics miss.
14
+ *
15
+ * Deliberately conservative to avoid masking ordinary config: only long,
16
+ * whitespace-free, tokenish strings with high per-character entropy qualify.
17
+ * This *complements* the key-name heuristics — it does NOT replace them: a
18
+ * short weak password like `Letmein` under a `password:` key has low entropy
19
+ * and is only caught by the key-name check, while a 40-char API token under a
20
+ * bland key is only caught here. Enable both for the widest coverage.
21
+ */
22
+ export declare function looksHighEntropy(v: unknown): boolean;
23
+ export interface RedactMatcher {
24
+ (path: Path, value?: unknown): boolean;
25
+ }
26
+ /**
27
+ * Build a predicate deciding whether a given path's VALUE should be redacted.
28
+ * @param builtins use the built-in secret-key heuristics
29
+ * @param globs extra key-name substrings / path globs (matched via matchAnyGlob)
30
+ * @param entropy also redact values that *look* like high-entropy secrets,
31
+ * regardless of key name (complements, doesn't replace, the above)
32
+ */
33
+ export declare function makeRedactMatcher(builtins: boolean, globs: string[], entropy?: boolean): RedactMatcher;
34
+ /**
35
+ * Stable, non-reversible fingerprint of a value. Equal values -> equal token,
36
+ * so an *unchanged* redacted value never shows up as a spurious diff, while a
37
+ * *changed* one shows two visibly different tokens.
38
+ */
39
+ export declare function redactToken(v: unknown): string;