File size: 5,418 Bytes
7421850
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import * as fs from 'node:fs';
import { parse, stringify } from 'comment-json';
import { coreEvents } from '@google/gemini-cli-core';

/**
 * Type representing an object that may contain Symbol keys for comments.
 */
type CommentedRecord = Record<string | symbol, unknown>;

/**
 * Updates a JSON file while preserving comments and formatting.
 */
export function updateSettingsFilePreservingFormat(
  filePath: string,
  updates: Record<string, unknown>,
): void {
  if (!fs.existsSync(filePath)) {
    fs.writeFileSync(filePath, JSON.stringify(updates, null, 2), 'utf-8');
    return;
  }

  const originalContent = fs.readFileSync(filePath, 'utf-8');

  let parsed: Record<string, unknown>;
  try {
    // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
    parsed = parse(originalContent) as Record<string, unknown>;
  } catch (error) {
    coreEvents.emitFeedback(
      'error',
      'Error parsing settings file. Please check the JSON syntax.',
      error,
    );
    return;
  }

  const updatedStructure = applyUpdates(parsed, updates);
  const updatedContent = stringify(updatedStructure, null, 2);

  fs.writeFileSync(filePath, updatedContent, 'utf-8');
}

/**
 * When deleting a property from a comment-json parsed object, relocate any
 * leading/trailing comments that were attached to that property so they are not lost.
 *
 * This function re-attaches comments to the next sibling's leading comments if
 * available, otherwise to the previous sibling's trailing comments, otherwise
 * to the container's leading/trailing comments.
 */
function preserveCommentsOnPropertyDeletion(
  container: Record<string, unknown>,
  propName: string,
): void {
  const target = container as CommentedRecord;
  const beforeSym = Symbol.for(`before:${propName}`);
  const afterSym = Symbol.for(`after:${propName}`);

  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
  const beforeComments = target[beforeSym] as unknown[] | undefined;
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
  const afterComments = target[afterSym] as unknown[] | undefined;

  if (!beforeComments && !afterComments) return;

  const keys = Object.getOwnPropertyNames(container);
  const idx = keys.indexOf(propName);
  const nextKey = idx >= 0 && idx + 1 < keys.length ? keys[idx + 1] : undefined;
  const prevKey = idx > 0 ? keys[idx - 1] : undefined;

  function appendToSymbol(destSym: symbol, comments: unknown[]) {
    if (!comments || comments.length === 0) return;
    const existing = target[destSym];
    target[destSym] = Array.isArray(existing)
      ? existing.concat(comments)
      : comments;
  }

  if (beforeComments && beforeComments.length > 0) {
    if (nextKey) {
      appendToSymbol(Symbol.for(`before:${nextKey}`), beforeComments);
    } else if (prevKey) {
      appendToSymbol(Symbol.for(`after:${prevKey}`), beforeComments);
    } else {
      appendToSymbol(Symbol.for('before'), beforeComments);
    }
    delete target[beforeSym];
  }

  if (afterComments && afterComments.length > 0) {
    if (nextKey) {
      appendToSymbol(Symbol.for(`before:${nextKey}`), afterComments);
    } else if (prevKey) {
      appendToSymbol(Symbol.for(`after:${prevKey}`), afterComments);
    } else {
      appendToSymbol(Symbol.for('after'), afterComments);
    }
    delete target[afterSym];
  }
}

/**
 * Applies sync-by-omission semantics: synchronizes base to match desired.
 * - Adds/updates keys from desired
 * - Removes keys from base that are not in desired
 * - Recursively applies to nested objects
 * - Preserves comments when deleting keys
 */
function applyKeyDiff(
  base: Record<string, unknown>,
  desired: Record<string, unknown>,
): void {
  for (const existingKey of Object.getOwnPropertyNames(base)) {
    if (!Object.prototype.hasOwnProperty.call(desired, existingKey)) {
      preserveCommentsOnPropertyDeletion(base, existingKey);
      delete base[existingKey];
    }
  }

  for (const nextKey of Object.getOwnPropertyNames(desired)) {
    const nextVal = desired[nextKey];
    const baseVal = base[nextKey];

    const isObj =
      typeof nextVal === 'object' &&
      nextVal !== null &&
      !Array.isArray(nextVal);
    const isBaseObj =
      typeof baseVal === 'object' &&
      baseVal !== null &&
      !Array.isArray(baseVal);
    const isArr = Array.isArray(nextVal);
    const isBaseArr = Array.isArray(baseVal);

    if (isObj && isBaseObj) {
      applyKeyDiff(
        // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
        baseVal as Record<string, unknown>,
        // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
        nextVal as Record<string, unknown>,
      );
    } else if (isArr && isBaseArr) {
      // In-place mutate arrays to preserve array-level comments on CommentArray
      const baseArr = baseVal as unknown[];
      const desiredArr = nextVal as unknown[];
      baseArr.length = 0;
      for (const el of desiredArr) {
        baseArr.push(el);
      }
    } else {
      base[nextKey] = nextVal;
    }
  }
}

function applyUpdates(
  current: Record<string, unknown>,
  updates: Record<string, unknown>,
): Record<string, unknown> {
  // Apply sync-by-omission semantics consistently at all levels
  applyKeyDiff(current, updates);
  return current;
}