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;
}
|