248 lines
5.6 KiB
JavaScript
248 lines
5.6 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
function usage() {
|
|
console.error(
|
|
'Usage: node install/merge-vscode-settings.mjs --target <settings.json> --template <settings.template.jsonc> [--set NAME=value]'
|
|
);
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const options = {
|
|
replacements: {},
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
|
|
if (arg === '--target') {
|
|
options.target = argv[index + 1];
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--template') {
|
|
options.template = argv[index + 1];
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (arg === '--set') {
|
|
const assignment = argv[index + 1] ?? '';
|
|
const equalsIndex = assignment.indexOf('=');
|
|
if (equalsIndex <= 0) {
|
|
throw new Error(`Invalid --set assignment: ${assignment}`);
|
|
}
|
|
const key = assignment.slice(0, equalsIndex);
|
|
const value = assignment.slice(equalsIndex + 1);
|
|
options.replacements[key] = value;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
|
|
if (!options.target || !options.template) {
|
|
usage();
|
|
throw new Error('Both --target and --template are required.');
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
function stripJsonComments(input) {
|
|
let output = '';
|
|
let inString = false;
|
|
let escaping = false;
|
|
let inLineComment = false;
|
|
let inBlockComment = false;
|
|
|
|
for (let index = 0; index < input.length; index += 1) {
|
|
const char = input[index];
|
|
const next = input[index + 1];
|
|
|
|
if (inLineComment) {
|
|
if (char === '\n') {
|
|
inLineComment = false;
|
|
output += char;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (inBlockComment) {
|
|
if (char === '*' && next === '/') {
|
|
inBlockComment = false;
|
|
index += 1;
|
|
} else if (char === '\n' || char === '\r') {
|
|
output += char;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (inString) {
|
|
output += char;
|
|
if (escaping) {
|
|
escaping = false;
|
|
} else if (char === '\\') {
|
|
escaping = true;
|
|
} else if (char === '"') {
|
|
inString = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (char === '"') {
|
|
inString = true;
|
|
output += char;
|
|
continue;
|
|
}
|
|
|
|
if (char === '/' && next === '/') {
|
|
inLineComment = true;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (char === '/' && next === '*') {
|
|
inBlockComment = true;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
output += char;
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
function stripTrailingCommas(input) {
|
|
let output = '';
|
|
let inString = false;
|
|
let escaping = false;
|
|
|
|
for (let index = 0; index < input.length; index += 1) {
|
|
const char = input[index];
|
|
|
|
if (inString) {
|
|
output += char;
|
|
if (escaping) {
|
|
escaping = false;
|
|
} else if (char === '\\') {
|
|
escaping = true;
|
|
} else if (char === '"') {
|
|
inString = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (char === '"') {
|
|
inString = true;
|
|
output += char;
|
|
continue;
|
|
}
|
|
|
|
if (char === ',') {
|
|
let lookahead = index + 1;
|
|
while (lookahead < input.length && /\s/.test(input[lookahead])) {
|
|
lookahead += 1;
|
|
}
|
|
if (input[lookahead] === '}' || input[lookahead] === ']') {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
output += char;
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
function renderTemplate(input, replacements) {
|
|
return input.replace(/\{\{([A-Z0-9_]+)\}\}/g, (match, key) => {
|
|
if (!(key in replacements)) {
|
|
return match;
|
|
}
|
|
return replacements[key]
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/"/g, '\\"');
|
|
});
|
|
}
|
|
|
|
function parseJsonc(input, label) {
|
|
const sanitized = stripTrailingCommas(stripJsonComments(input)).trim();
|
|
if (!sanitized) {
|
|
return {};
|
|
}
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(sanitized);
|
|
} catch (error) {
|
|
throw new Error(`Failed to parse ${label}: ${error.message}`);
|
|
}
|
|
|
|
if (!isPlainObject(parsed)) {
|
|
throw new Error(`${label} must contain a JSON object at the root.`);
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function isPlainObject(value) {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function cloneValue(value) {
|
|
if (Array.isArray(value)) {
|
|
return value.map((item) => cloneValue(item));
|
|
}
|
|
if (isPlainObject(value)) {
|
|
return Object.fromEntries(
|
|
Object.entries(value).map(([key, nested]) => [key, cloneValue(nested)])
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function deepMerge(target, source) {
|
|
const merged = { ...target };
|
|
|
|
for (const [key, value] of Object.entries(source)) {
|
|
if (isPlainObject(value) && isPlainObject(merged[key])) {
|
|
merged[key] = deepMerge(merged[key], value);
|
|
continue;
|
|
}
|
|
merged[key] = cloneValue(value);
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
const templateText = fs.readFileSync(options.template, 'utf8');
|
|
const renderedTemplate = renderTemplate(templateText, options.replacements);
|
|
const managedSettings = parseJsonc(renderedTemplate, options.template);
|
|
|
|
const targetText = fs.existsSync(options.target)
|
|
? fs.readFileSync(options.target, 'utf8')
|
|
: '{}\n';
|
|
const currentSettings = parseJsonc(targetText, options.target);
|
|
const mergedSettings = deepMerge(currentSettings, managedSettings);
|
|
const output = `${JSON.stringify(mergedSettings, null, 2)}\n`;
|
|
|
|
fs.mkdirSync(path.dirname(options.target), { recursive: true });
|
|
|
|
if (output === targetText) {
|
|
console.log(`VS Code settings already up to date: ${options.target}`);
|
|
return;
|
|
}
|
|
|
|
fs.writeFileSync(options.target, output, 'utf8');
|
|
console.log(`Merged managed VS Code settings into: ${options.target}`);
|
|
}
|
|
|
|
main(); |