• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

devonfw / IDEasy / 23822201076

31 Mar 2026 10:22PM UTC coverage: 70.634% (+0.07%) from 70.568%
23822201076

push

github

web-flow
#1752: Fixed JSON merger to support JSONC (JSON with Comments) for VSCode (#1792)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

4196 of 6560 branches covered (63.96%)

Branch coverage included in aggregate %.

10885 of 14791 relevant lines covered (73.59%)

3.1 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

59.01
cli/src/main/java/com/devonfw/tools/ide/merge/JsonMerger.java
1
package com.devonfw.tools.ide.merge;
2

3
import java.io.IOException;
4
import java.io.OutputStream;
5
import java.io.Reader;
6
import java.io.StringReader;
7
import java.io.Writer;
8
import java.nio.file.Files;
9
import java.nio.file.Path;
10
import java.util.ArrayList;
11
import java.util.Collections;
12
import java.util.HashMap;
13
import java.util.Iterator;
14
import java.util.List;
15
import java.util.Map;
16
import java.util.Set;
17
import jakarta.json.Json;
18
import jakarta.json.JsonArray;
19
import jakarta.json.JsonArrayBuilder;
20
import jakarta.json.JsonObject;
21
import jakarta.json.JsonObjectBuilder;
22
import jakarta.json.JsonReader;
23
import jakarta.json.JsonString;
24
import jakarta.json.JsonStructure;
25
import jakarta.json.JsonValue;
26
import jakarta.json.JsonWriter;
27
import jakarta.json.JsonWriterFactory;
28
import jakarta.json.stream.JsonGenerator;
29

30
import org.slf4j.Logger;
31
import org.slf4j.LoggerFactory;
32

33
import com.devonfw.tools.ide.context.IdeContext;
34
import com.devonfw.tools.ide.environment.EnvironmentVariables;
35
import com.fasterxml.jackson.core.JsonParser;
36
import com.fasterxml.jackson.core.util.DefaultIndenter;
37
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter;
38
import com.fasterxml.jackson.databind.JsonNode;
39
import com.fasterxml.jackson.databind.ObjectMapper;
40
import com.fasterxml.jackson.databind.node.ArrayNode;
41
import com.fasterxml.jackson.databind.node.ObjectNode;
42
import com.fasterxml.jackson.databind.node.TextNode;
43

44
/**
45
 * Implementation of {@link FileMerger} for JSON.
46
 */
47
public class JsonMerger extends FileMerger {
48

49
  private static final Logger LOG = LoggerFactory.getLogger(JsonMerger.class);
3✔
50

51
  private static final ObjectMapper JSONC_MAPPER = new ObjectMapper().configure(JsonParser.Feature.ALLOW_COMMENTS, true);
8✔
52

53
  /**
54
   * The constructor.
55
   *
56
   * @param context the {@link #context}.
57
   */
58
  public JsonMerger(IdeContext context) {
59

60
    super(context);
3✔
61
  }
1✔
62

63
  @Override
64
  protected void doMerge(Path setup, Path update, EnvironmentVariables variables, Path workspace) {
65

66
    JsonStructure json = null;
2✔
67
    Path template = setup;
2✔
68
    boolean updateFileExists = Files.exists(update);
5✔
69
    Map<String, String> workspaceComments = Collections.emptyMap();
2✔
70
    if (Files.exists(workspace)) {
5✔
71
      if (!updateFileExists) {
2!
72
        return; // nothing to do ...
×
73
      }
74
      try {
75
        workspaceComments = extractComments(workspace);
3✔
76
      } catch (IOException e) {
×
77
        LOG.debug("Failed to extract comments from {}, comments will not be preserved", workspace, e);
×
78
      }
1✔
79
      json = load(workspace);
4✔
80
    } else if (Files.exists(setup)) {
5✔
81
      json = load(setup);
3✔
82
    }
83
    JsonStructure mergeJson = null;
2✔
84
    if (updateFileExists) {
2!
85
      if (json == null) {
2✔
86
        json = load(update);
4✔
87
      } else {
88
        mergeJson = load(update);
3✔
89
      }
90
      template = update;
2✔
91
    }
92
    Status status = new Status();
4✔
93
    JsonStructure result = (JsonStructure) mergeAndResolve(json, mergeJson, variables, status, template.toString());
10✔
94
    if (status.updated) {
3✔
95
      save(result, workspace);
3✔
96
      if (!workspaceComments.isEmpty()) {
3✔
97
        try {
98
          injectComments(workspace, workspaceComments);
3✔
99
        } catch (IOException e) {
×
100
          LOG.debug("Failed to inject comments into {}", workspace, e);
×
101
        }
1✔
102
      }
103
      LOG.debug("Saved created/updated file {}", workspace);
5✔
104
    } else {
105
      LOG.trace("No changes for file {}", workspace);
4✔
106
    }
107
  }
1✔
108

109
  /**
110
   * Scans a JSONC file and maps each property key to the comment block immediately preceding it.
111
   * Only leading-line comments ({@code //} and block comments) are captured; inline trailing comments are ignored.
112
   * Blank lines between a comment block and its property are tolerated.
113
   *
114
   * @param file the JSONC file to scan.
115
   * @return map from property key to its preceding comment text (trimmed lines joined with {@code \n}).
116
   * @throws IOException on read error.
117
   */
118
  private static Map<String, String> extractComments(Path file) throws IOException {
119

120
    List<String> lines = Files.readAllLines(file);
3✔
121
    Map<String, String> comments = new HashMap<>();
4✔
122
    List<String> pendingComments = new ArrayList<>();
4✔
123
    for (String line : lines) {
10✔
124
      String trimmed = line.trim();
3✔
125
      if (trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) {
12!
126
        pendingComments.add(trimmed);
5✔
127
      } else if (trimmed.startsWith("\"") && !pendingComments.isEmpty()) {
7✔
128
        int endQuote = trimmed.indexOf('"', 1);
5✔
129
        if (endQuote > 0) {
2!
130
          String key = trimmed.substring(1, endQuote);
5✔
131
          comments.put(key, String.join("\n", pendingComments));
7✔
132
        }
133
        pendingComments.clear();
2✔
134
      } else if (!trimmed.isEmpty()) {
4!
135
        pendingComments.clear();
2✔
136
      }
137
      // blank lines: leave pendingComments intact so a blank line between comment and property is tolerated
138
    }
1✔
139
    return comments;
2✔
140
  }
141

142
  /**
143
   * Re-inserts comment blocks into a clean JSON file above the property each comment was associated with.
144
   * The indentation of each injected comment line matches the indentation of the following property line.
145
   *
146
   * @param file the saved JSON file to rewrite with comments.
147
   * @param comments map from property key to its preceding comment text (as returned by {@link #extractComments}).
148
   * @throws IOException on read/write error.
149
   */
150
  private static void injectComments(Path file, Map<String, String> comments) throws IOException {
151

152
    List<String> lines = Files.readAllLines(file);
3✔
153
    StringBuilder sb = new StringBuilder();
4✔
154
    for (String line : lines) {
10✔
155
      String trimmed = line.trim();
3✔
156
      if (trimmed.startsWith("\"")) {
4✔
157
        int endQuote = trimmed.indexOf('"', 1);
5✔
158
        if (endQuote > 0) {
2!
159
          String key = trimmed.substring(1, endQuote);
5✔
160
          String comment = comments.get(key);
5✔
161
          if (comment != null) {
2✔
162
            int indentLength = line.indexOf('"');
4✔
163
            String indent = indentLength > 0 ? line.substring(0, indentLength) : "";
8!
164
            for (String commentLine : comment.split("\n")) {
18✔
165
              sb.append(indent).append(commentLine).append('\n');
8✔
166
            }
167
          }
168
        }
169
      }
170
      sb.append(line).append('\n');
6✔
171
    }
1✔
172
    Files.writeString(file, sb.toString());
7✔
173
  }
1✔
174

175
  private static JsonStructure load(Path file) {
176

177
    try {
178
      JsonNode node = JSONC_MAPPER.readTree(file.toFile());
5✔
179
      String cleanJson = JSONC_MAPPER.writeValueAsString(node);
4✔
180
      try (Reader reader = new StringReader(cleanJson)) {
5✔
181
        JsonReader jsonReader = Json.createReader(reader);
3✔
182
        return jsonReader.read();
5✔
183
      }
184
    } catch (Exception e) {
×
185
      throw new IllegalStateException("Failed to read JSON from " + file, e);
×
186
    }
187
  }
188

189
  private static void save(JsonStructure json, Path file) {
190

191
    ensureParentDirectoryExists(file);
2✔
192
    try (OutputStream out = Files.newOutputStream(file)) {
5✔
193

194
      Map<String, Object> config = new HashMap<>();
4✔
195
      config.put(JsonGenerator.PRETTY_PRINTING, Boolean.TRUE);
5✔
196
      // JSON-P API sucks: no way to set the indentation string
197
      // preferred would be two spaces, implementation has four whitespaces hardcoded
198
      // See org.glassfish.json.JsonPrettyGeneratorImpl
199
      // when will they ever learn...?
200
      JsonWriterFactory jsonWriterFactory = Json.createWriterFactory(config);
3✔
201
      JsonWriter jsonWriter = jsonWriterFactory.createWriter(out);
4✔
202
      jsonWriter.write(json);
3✔
203
      jsonWriter.close();
2✔
204
    } catch (Exception e) {
×
205
      throw new IllegalStateException("Failed to save JSON to " + file, e);
×
206
    }
1✔
207
  }
1✔
208

209
  @Override
210
  public void inverseMerge(Path workspace, EnvironmentVariables variables, boolean addNewProperties, Path updateFile) {
211

212
    if (!Files.exists(workspace) || !Files.exists(updateFile)) {
×
213
      return;
×
214
    }
215
    JsonStructure updateDocument = load(updateFile);
×
216
    JsonStructure workspaceDocument = load(workspace);
×
217
    Status status = new Status(addNewProperties);
×
218
    JsonStructure result = (JsonStructure) mergeAndResolve(workspaceDocument, updateDocument, variables, status,
×
219
        workspace.getFileName());
×
220
    if (status.updated) {
×
221
      save(result, updateFile);
×
222
      LOG.debug("Saved changes from {} to {}", workspace.getFileName(), updateFile);
×
223
    } else {
224
      LOG.trace("No changes for {}", updateFile);
×
225
    }
226
  }
×
227

228
  private JsonValue mergeAndResolve(JsonValue json, JsonValue mergeJson, EnvironmentVariables variables, Status status,
229
      Object src) {
230

231
    if (json == null) {
2✔
232
      if (mergeJson == null) {
2!
233
        return null;
×
234
      } else {
235
        return mergeAndResolve(mergeJson, null, variables, status, src);
8✔
236
      }
237
    } else {
238
      if (mergeJson == null) {
2✔
239
        status.updated = true; // JSON to merge does not exist and needs to be created
3✔
240
      }
241
      return switch (json.getValueType()) {
7!
242
        case OBJECT -> mergeAndResolveObject((JsonObject) json, (JsonObject) mergeJson, variables, status, src);
10✔
243
        case ARRAY -> mergeAndResolveArray((JsonArray) json, (JsonArray) mergeJson, variables, status, src);
10✔
244
        case STRING -> mergeAndResolveString((JsonString) json, (JsonString) mergeJson, variables, status, src);
10✔
245
        case NUMBER, FALSE, TRUE, NULL -> mergeAndResolveNativeType(json, mergeJson, variables, status);
7✔
246
        default -> {
247
          LOG.error("Undefined JSON type {}", json.getClass());
×
248
          yield null;
×
249
        }
250
      };
251
    }
252
  }
253

254
  private JsonObject mergeAndResolveObject(JsonObject json, JsonObject mergeJson, EnvironmentVariables variables,
255
      Status status, Object src) {
256

257
    // json = workspace/setup
258
    // mergeJson = update
259
    JsonObjectBuilder builder = Json.createObjectBuilder();
2✔
260
    Set<String> mergeKeySet = Collections.emptySet();
2✔
261
    if (mergeJson != null) {
2✔
262
      mergeKeySet = mergeJson.keySet();
3✔
263
      for (String key : mergeKeySet) {
10✔
264
        JsonValue mergeValue = mergeJson.get(key);
5✔
265
        JsonValue value = json.get(key);
5✔
266
        value = mergeAndResolve(value, mergeValue, variables, status, src);
8✔
267
        builder.add(key, value);
5✔
268
      }
1✔
269
    }
270
    if (status.addNewProperties || !status.inverse) {
6!
271
      for (String key : json.keySet()) {
11✔
272
        if (!mergeKeySet.contains(key)) {
4✔
273
          JsonValue value = json.get(key);
5✔
274
          value = mergeAndResolve(value, null, variables, status, src);
8✔
275
          builder.add(key, value);
5✔
276
          if (status.inverse) {
3!
277
            // added new property on inverse merge...
278
            status.updated = true;
×
279
          }
280
        }
281
      }
1✔
282
    }
283
    return builder.build();
3✔
284
  }
285

286
  private JsonArray mergeAndResolveArray(JsonArray json, JsonArray mergeJson, EnvironmentVariables variables,
287
      Status status, Object src) {
288

289
    JsonArrayBuilder builder = Json.createArrayBuilder();
2✔
290
    // KISS: Merging JSON arrays could be very complex. We simply let mergeJson override json...
291
    JsonArray source = json;
2✔
292
    if (mergeJson != null) {
2!
293
      source = mergeJson;
2✔
294
    }
295
    for (JsonValue value : source) {
10✔
296
      JsonValue resolvedValue = mergeAndResolve(value, null, variables, status, src);
8✔
297
      builder.add(resolvedValue);
4✔
298
    }
1✔
299
    return builder.build();
3✔
300
  }
301

302
  private JsonString mergeAndResolveString(JsonString json, JsonString mergeJson, EnvironmentVariables variables,
303
      Status status, Object src) {
304

305
    JsonString jsonString = json;
2✔
306
    if (mergeJson != null) {
2✔
307
      jsonString = mergeJson;
2✔
308
    }
309
    String string = jsonString.getString();
3✔
310
    String resolvedString;
311
    if (status.inverse) {
3!
312
      resolvedString = variables.inverseResolve(string, src);
×
313
    } else {
314
      resolvedString = variables.resolve(string, src, this.legacySupport);
7✔
315
    }
316
    if (!resolvedString.equals(string)) {
4✔
317
      status.updated = true;
3✔
318
    }
319
    return Json.createValue(resolvedString);
3✔
320
  }
321

322
  private JsonValue mergeAndResolveNativeType(JsonValue json, JsonValue mergeJson, EnvironmentVariables variables,
323
      Status status) {
324

325
    if (mergeJson == null) {
2✔
326
      return json;
2✔
327
    } else {
328
      return mergeJson;
2✔
329
    }
330
  }
331

332
  @Override
333
  protected boolean doUpgrade(Path workspaceFile) throws Exception {
334

335
    JsonNode jsonNode;
336
    ObjectMapper mapper = new ObjectMapper();
×
337
    try (Reader reader = Files.newBufferedReader(workspaceFile)) {
×
338
      jsonNode = mapper.reader().readTree(reader);
×
339
    }
340
    JsonNode migratedNode = upgradeJsonNode(jsonNode);
×
341
    boolean modified = (migratedNode != jsonNode);
×
342
    if (migratedNode == null) {
×
343
      migratedNode = jsonNode;
×
344
    }
345
    if (modified) {
×
346
      try (Writer writer = Files.newBufferedWriter(workspaceFile)) {
×
347
        mapper.writer(new JsonPrettyPrinter()).writeValue(writer, migratedNode);
×
348
      }
349
    }
350
    return modified;
×
351
  }
352

353
  /**
354
   * @param jsonNode the {@link JsonNode} to upgrade.
355
   * @return the given {@link JsonNode} if unmodified after upgrade. Otherwise, a new migrated {@link JsonNode} or {@code null} if the given {@link JsonNode}
356
   *     was mutable and the migration could be applied directly.
357
   */
358
  private JsonNode upgradeJsonNode(JsonNode jsonNode) {
359

360
    if (jsonNode instanceof ArrayNode jsonArray) {
×
361
      return upgradeJsonArray(jsonArray);
×
362
    } else if (jsonNode instanceof ObjectNode jsonObject) {
×
363
      return upgradeJsonObject(jsonObject);
×
364
    } else if (jsonNode instanceof TextNode jsonString) {
×
365
      return upgradeJsonString(jsonString);
×
366
    } else {
367
      assert jsonNode.isValueNode();
×
368
      return jsonNode;
×
369
    }
370
  }
371

372
  private ObjectNode upgradeJsonObject(ObjectNode jsonObject) {
373

374
    ObjectNode result = jsonObject;
×
375
    Iterator<String> fieldNames = jsonObject.fieldNames();
×
376
    while (fieldNames.hasNext()) {
×
377
      String fieldName = fieldNames.next();
×
378
      JsonNode child = jsonObject.get(fieldName);
×
379
      JsonNode migratedChild = upgradeJsonNode(child);
×
380
      if (migratedChild != child) {
×
381
        result = null;
×
382
        if (migratedChild != null) {
×
383
          jsonObject.set(fieldName, migratedChild);
×
384
        }
385
      }
386
    }
×
387
    return result;
×
388
  }
389

390
  private ArrayNode upgradeJsonArray(ArrayNode jsonArray) {
391

392
    ArrayNode result = jsonArray;
×
393
    int size = jsonArray.size();
×
394
    for (int i = 0; i < size; i++) {
×
395
      JsonNode child = jsonArray.get(i);
×
396
      JsonNode migratedChild = upgradeJsonNode(child);
×
397
      if (migratedChild != child) {
×
398
        result = null;
×
399
        if (migratedChild != null) {
×
400
          jsonArray.set(i, migratedChild);
×
401
        }
402
      }
403
    }
404
    return result;
×
405
  }
406

407
  private JsonNode upgradeJsonString(TextNode jsonString) {
408

409
    String text = jsonString.textValue();
×
410
    String migratedText = upgradeWorkspaceContent(text);
×
411
    if (migratedText.equals(text)) {
×
412
      return jsonString;
×
413
    } else {
414
      return new TextNode(migratedText);
×
415
    }
416
  }
417

418
  private static class Status {
419

420
    /**
421
     * {@code true} for inverse merge, {@code false} otherwise (for regular forward merge).
422
     */
423
    private final boolean inverse;
424

425
    private final boolean addNewProperties;
426

427
    private boolean updated;
428

429
    /**
430
     * The constructor.
431
     */
432
    public Status() {
433

434
      this(false, false);
4✔
435
    }
1✔
436

437
    /**
438
     * The constructor.
439
     *
440
     * @param addNewProperties - {@code true} to add new properties from workspace on reverse merge, {@code false} otherwise.
441
     */
442
    public Status(boolean addNewProperties) {
443

444
      this(true, addNewProperties);
×
445
    }
×
446

447
    private Status(boolean inverse, boolean addNewProperties) {
448

449
      super();
2✔
450
      this.inverse = inverse;
3✔
451
      this.addNewProperties = addNewProperties;
3✔
452
      this.updated = false;
3✔
453
    }
1✔
454

455
  }
456

457
  /**
458
   * Extends {@link DefaultPrettyPrinter} to get nicely formatted JSON output.
459
   */
460
  private static class JsonPrettyPrinter extends DefaultPrettyPrinter {
461

462
    public JsonPrettyPrinter() {
×
463
      DefaultPrettyPrinter.Indenter indenter = new DefaultIndenter("  ", "\n");
×
464
      indentObjectsWith(indenter);
×
465
      indentArraysWith(indenter);
×
466
      _objectFieldValueSeparatorWithSpaces = ": ";
×
467
    }
×
468

469
    private JsonPrettyPrinter(JsonPrettyPrinter pp) {
470
      super(pp);
×
471
    }
×
472

473
    @Override
474
    public void writeEndArray(com.fasterxml.jackson.core.JsonGenerator g, int nrOfValues) throws IOException {
475

476
      if (!_arrayIndenter.isInline()) {
×
477
        _nesting--;
×
478
      }
479
      if (nrOfValues > 0) {
×
480
        _arrayIndenter.writeIndentation(g, _nesting);
×
481
      }
482
      g.writeRaw(']');
×
483
    }
×
484

485
    @Override
486
    public DefaultPrettyPrinter createInstance() {
487
      return new JsonPrettyPrinter(this);
×
488
    }
489
  }
490
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc