Skip to content

Commit ceb97af

Browse files
Add configurable array merge behaviour (concat|replace)
1 parent 36445f7 commit ceb97af

File tree

6 files changed

+110
-4
lines changed

6 files changed

+110
-4
lines changed

README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Options:
5252
--dry-run Preview without writing files [boolean] [default: false]
5353
--backup Create .bak before overwriting [boolean] [default: false]
5454
--indent Custom indentation spaces [number]
55+
--array-merge Array merge strategy [choices: "replace", "concat"] [default: "replace"]
5556
-h, --help Show help
5657
-v, --version Show version
5758
```
@@ -65,13 +66,73 @@ const result = mergeJsonc({
6566
inputs: ["base.json", "dev.jsonc", "local.json5"],
6667
out: "config.json",
6768
skipMissing: true,
69+
arrayMerge: "replace", // or "concat"
6870
});
6971

7072
console.log(result.wrote ? "Merged!" : result.reason);
7173
```
7274

7375
---
7476

77+
## 🔀 Array Merge Strategies
78+
79+
By default, `merge-jsonc` **replaces** arrays when merging. You can configure this behavior:
80+
81+
### Replace (default)
82+
83+
Arrays from later files completely replace arrays from earlier files:
84+
85+
```json
86+
// base.json
87+
{ "items": [1, 2, 3] }
88+
89+
// override.json
90+
{ "items": [4, 5] }
91+
92+
// Result with --array-merge replace (default)
93+
{ "items": [4, 5] }
94+
```
95+
96+
```bash
97+
merge-jsonc base.json override.json --out result.json
98+
# or explicitly:
99+
merge-jsonc base.json override.json --out result.json --array-merge replace
100+
```
101+
102+
### Concat
103+
104+
Arrays are concatenated together:
105+
106+
```json
107+
// base.json
108+
{ "items": [1, 2, 3] }
109+
110+
// override.json
111+
{ "items": [4, 5] }
112+
113+
// Result with --array-merge concat
114+
{ "items": [1, 2, 3, 4, 5] }
115+
```
116+
117+
```bash
118+
merge-jsonc base.json override.json --out result.json --array-merge concat
119+
```
120+
121+
### Configuration File
122+
123+
You can also set the array merge strategy in your config file:
124+
125+
```javascript
126+
// .merge-jsonc.config.mjs
127+
export default {
128+
arrayMerge: "concat",
129+
backup: true,
130+
pretty: true,
131+
};
132+
```
133+
134+
---
135+
75136
## 🧪 Examples
76137

77138
See the [`examples/`](./examples/) directory for real-world usage patterns:

src/cli.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface ParsedArgs {
1414
dryRun?: boolean;
1515
backup?: boolean;
1616
indent?: number;
17+
arrayMerge?: "replace" | "concat";
1718
inputs: string[];
1819
}
1920

@@ -48,6 +49,7 @@ function mergeArgsWithConfig(args: ParsedArgs, config: ConfigOptions): MergeOpti
4849
dryRun: args.dryRun ?? config.dryRun ?? false,
4950
backup: args.backup ?? config.backup ?? false,
5051
indent: args.indent ?? config.indent,
52+
arrayMerge: args.arrayMerge ?? config.arrayMerge ?? "replace",
5153
};
5254
}
5355

@@ -87,6 +89,12 @@ async function run() {
8789
describe: "Custom indentation spaces (overrides --min)",
8890
type: "number",
8991
})
92+
.option("array-merge", {
93+
describe: "Array merge strategy: 'replace' (default) or 'concat'",
94+
type: "string",
95+
choices: ["replace", "concat"],
96+
default: "replace",
97+
})
9098
.help("help")
9199
.alias("help", "h")
92100
.version(version)
@@ -124,6 +132,7 @@ async function run() {
124132
dryRun: argv["dry-run"],
125133
backup: argv.backup,
126134
indent: argv.indent,
135+
arrayMerge: argv["array-merge"] as "replace" | "concat" | undefined,
127136
inputs: Array.from(argv._, (value) => String(value)),
128137
};
129138

src/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface ConfigOptions {
99
dryRun?: boolean;
1010
backup?: boolean;
1111
indent?: number;
12+
arrayMerge?: "replace" | "concat";
1213
}
1314

1415
type ConfigKey = keyof ConfigOptions;
@@ -52,6 +53,12 @@ const validators: { [K in ConfigKey]-?: ConfigValidator<K> } = {
5253
}
5354
return value;
5455
},
56+
arrayMerge: (value, source) => {
57+
if (value !== "replace" && value !== "concat") {
58+
throw new Error(`Invalid 'arrayMerge' value in '${source}'. Expected 'replace' or 'concat'.`);
59+
}
60+
return value;
61+
},
5562
};
5663

5764
function setConfigValue<K extends ConfigKey>(

src/core.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface MergeOptions {
1212
dryRun?: boolean; // preview changes without writing
1313
backup?: boolean; // create .bak file before overwriting
1414
indent?: number; // custom indentation (overrides pretty)
15+
arrayMerge?: "replace" | "concat"; // array merge strategy (default: replace)
1516
}
1617

1718
export type MergeResult =
@@ -73,13 +74,23 @@ function parseJsonFile(filePath: string, content: string): Record<string, unknow
7374
}
7475
}
7576

76-
function mergeFiles(inputAbs: string[]): Record<string, unknown> {
77+
function mergeFiles(
78+
inputAbs: string[],
79+
arrayMerge: "replace" | "concat" = "replace"
80+
): Record<string, unknown> {
7781
let combined: Record<string, unknown> = {};
7882

83+
const mergeOptions =
84+
arrayMerge === "replace"
85+
? {
86+
arrayMerge: (target: unknown[], source: unknown[]) => source,
87+
}
88+
: undefined;
89+
7990
for (const abs of inputAbs) {
8091
const content = readText(abs);
8192
const parsed = parseJsonFile(abs, content);
82-
combined = deepmerge<Record<string, unknown>>(combined, parsed);
93+
combined = deepmerge<Record<string, unknown>>(combined, parsed, mergeOptions);
8394
}
8495

8596
return combined;
@@ -110,6 +121,7 @@ export function mergeJsonc(opts: MergeOptions): MergeResult {
110121
dryRun = false,
111122
backup = false,
112123
indent,
124+
arrayMerge = "replace",
113125
} = opts;
114126

115127
const inputAbs = resolveInputPaths(inputs, skipMissing);
@@ -120,7 +132,7 @@ export function mergeJsonc(opts: MergeOptions): MergeResult {
120132

121133
const outAbs = safeOutputPath(out);
122134

123-
const combined = mergeFiles(inputAbs);
135+
const combined = mergeFiles(inputAbs, arrayMerge);
124136
const spaces = calculateIndentation(indent, pretty);
125137
const text = JSON.stringify(combined, null, spaces);
126138

test/cli-module.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ describe("CLI module", () => {
7171
dryRun: false,
7272
backup: true,
7373
indent: 4,
74+
arrayMerge: "replace",
7475
});
7576
expect(logSpy).toHaveBeenCalledWith(
7677
expect.stringContaining("DRY RUN - would write to result.json")

test/core.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ describe("mergeJsonc core functionality", () => {
228228
expect(content).toContain("\n");
229229
});
230230

231-
test("should merge arrays by concatenation (deepmerge default)", () => {
231+
test("should replace arrays by default", () => {
232232
writeTestFile("a.jsonc", '{"items": [1, 2]}');
233233
writeTestFile("b.jsonc", '{"items": [3, 4]}');
234234

@@ -239,6 +239,22 @@ describe("mergeJsonc core functionality", () => {
239239

240240
expect(result.wrote).toBe(true);
241241

242+
const merged = readJsonObject("merged.jsonc");
243+
expect(merged).toMatchObject({ items: [3, 4] });
244+
});
245+
246+
test("should concatenate arrays when arrayMerge is 'concat'", () => {
247+
writeTestFile("a.jsonc", '{"items": [1, 2]}');
248+
writeTestFile("b.jsonc", '{"items": [3, 4]}');
249+
250+
const result = mergeJsonc({
251+
inputs: [join(TEST_DIR, "a.jsonc"), join(TEST_DIR, "b.jsonc")],
252+
out: join(TEST_DIR, "merged.jsonc"),
253+
arrayMerge: "concat",
254+
});
255+
256+
expect(result.wrote).toBe(true);
257+
242258
const merged = readJsonObject("merged.jsonc");
243259
expect(merged).toMatchObject({ items: [1, 2, 3, 4] });
244260
});

0 commit comments

Comments
 (0)