#!/usr/bin/env node import fs from 'node:fs'; import path from 'node:path'; import { isDeepStrictEqual } from 'node:util'; function usage() { console.error( 'Usage: node install/merge-vscode-settings.mjs --target --template [--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 parseJsoncAst(input, label) { let index = 0; function fail(message) { throw new Error(`Failed to parse ${label}: ${message}`); } function skipTrivia() { while (index < input.length) { const char = input[index]; const next = input[index + 1]; if (/\s/.test(char)) { index += 1; continue; } if (char === '/' && next === '/') { index += 2; while (index < input.length && input[index] !== '\n') { index += 1; } continue; } if (char === '/' && next === '*') { index += 2; while (index < input.length && !(input[index] === '*' && input[index + 1] === '/')) { index += 1; } if (index >= input.length) { fail('unterminated block comment'); } index += 2; continue; } break; } } function parseStringNode() { const start = index; index += 1; while (index < input.length) { const char = input[index]; if (char === '\\') { index += 2; continue; } if (char === '"') { index += 1; const raw = input.slice(start, index); return { type: 'string', start, end: index, value: JSON.parse(raw), }; } index += 1; } fail('unterminated string literal'); } function parseLiteralNode(expectedText, value) { const start = index; if (!input.startsWith(expectedText, index)) { fail(`expected ${expectedText}`); } index += expectedText.length; return { type: typeof value, start, end: index, value, }; } function parseNumberNode() { const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(input.slice(index)); if (!match) { fail(`invalid number at offset ${index}`); } const start = index; index += match[0].length; return { type: 'number', start, end: index, value: Number(match[0]), }; } function parseArrayNode() { const start = index; index += 1; const elements = []; skipTrivia(); while (index < input.length && input[index] !== ']') { const valueNode = parseValueNode(); elements.push(valueNode); skipTrivia(); if (input[index] === ',') { index += 1; skipTrivia(); continue; } if (input[index] !== ']') { fail(`expected ',' or ']' at offset ${index}`); } } if (input[index] !== ']') { fail('unterminated array'); } index += 1; return { type: 'array', start, end: index, elements, value: elements.map((element) => element.value), }; } function parseObjectNode() { const start = index; index += 1; const properties = []; const value = {}; skipTrivia(); while (index < input.length && input[index] !== '}') { if (input[index] !== '"') { fail(`expected string property name at offset ${index}`); } const keyNode = parseStringNode(); const key = keyNode.value; skipTrivia(); if (input[index] !== ':') { fail(`expected ':' after property name at offset ${index}`); } index += 1; skipTrivia(); const valueNode = parseValueNode(); const property = { key, keyNode, value: valueNode, hasTrailingComma: false, }; properties.push(property); value[key] = valueNode.value; skipTrivia(); if (input[index] === ',') { property.hasTrailingComma = true; index += 1; skipTrivia(); continue; } if (input[index] !== '}') { fail(`expected ',' or '}' at offset ${index}`); } } if (input[index] !== '}') { fail('unterminated object'); } index += 1; return { type: 'object', start, end: index, properties, value, }; } function parseValueNode() { skipTrivia(); const char = input[index]; if (char === '{') { return parseObjectNode(); } if (char === '[') { return parseArrayNode(); } if (char === '"') { return parseStringNode(); } if (char === 't') { return parseLiteralNode('true', true); } if (char === 'f') { return parseLiteralNode('false', false); } if (char === 'n') { return parseLiteralNode('null', null); } if (char === '-' || /\d/.test(char ?? '')) { return parseNumberNode(); } fail(`unexpected token at offset ${index}`); } skipTrivia(); const root = parseValueNode(); skipTrivia(); if (index !== input.length) { fail(`unexpected trailing content at offset ${index}`); } if (root.type !== 'object') { throw new Error(`${label} must contain a JSON object at the root.`); } return root; } function isPlainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } function detectIndentUnit(input) { const matches = input.match(/^( +|\t+)\S/m); if (!matches) { return ' '; } return matches[1]; } function detectEol(input) { return input.includes('\r\n') ? '\r\n' : '\n'; } function lineStartIndex(input, index) { const newlineIndex = input.lastIndexOf('\n', index - 1); return newlineIndex === -1 ? 0 : newlineIndex + 1; } function lineIndentAt(input, index) { const start = lineStartIndex(input, index); let end = start; while (end < input.length && (input[end] === ' ' || input[end] === '\t')) { end += 1; } return input.slice(start, end); } function renderValue(value, propertyIndent, indentUnit, eol) { const raw = JSON.stringify(value, null, indentUnit); if (!raw.includes('\n')) { return raw; } return raw .split('\n') .map((line, index) => (index === 0 ? line : `${propertyIndent}${line}`)) .join(eol); } function renderProperty(key, value, propertyIndent, indentUnit, eol) { return `${JSON.stringify(key)}: ${renderValue(value, propertyIndent, indentUnit, eol)}`; } function getObjectChildIndent(input, objectNode, indentUnit) { if (objectNode.properties.length > 0) { return lineIndentAt(input, objectNode.properties[0].keyNode.start); } return `${lineIndentAt(input, objectNode.start)}${indentUnit}`; } function buildMergeEdits(input, objectNode, managedSettings, indentUnit, eol, edits) { const missingEntries = []; for (const [key, managedValue] of Object.entries(managedSettings)) { const property = objectNode.properties.find((candidate) => candidate.key === key); if (!property) { missingEntries.push([key, managedValue]); continue; } if (isPlainObject(managedValue) && property.value.type === 'object') { buildMergeEdits(input, property.value, managedValue, indentUnit, eol, edits); continue; } if (!isDeepStrictEqual(property.value.value, managedValue)) { const propertyIndent = lineIndentAt(input, property.keyNode.start); edits.push({ start: property.value.start, end: property.value.end, text: renderValue(managedValue, propertyIndent, indentUnit, eol), }); } } if (missingEntries.length === 0) { return; } const propertyIndent = getObjectChildIndent(input, objectNode, indentUnit); const closingIndent = lineIndentAt(input, objectNode.end); const renderedProperties = missingEntries .map(([key, value]) => `${propertyIndent}${renderProperty(key, value, propertyIndent, indentUnit, eol)}`) .join(`,${eol}`); if (objectNode.properties.length === 0) { edits.push({ start: objectNode.start + 1, end: objectNode.end, text: `${eol}${renderedProperties}${eol}${closingIndent}`, }); return; } const lastProperty = objectNode.properties[objectNode.properties.length - 1]; if (!lastProperty.hasTrailingComma) { edits.push({ start: lastProperty.value.end, end: lastProperty.value.end, text: ',', }); } const closingLineStart = lineStartIndex(input, objectNode.end); const insertBeforeClosingLine = closingLineStart > lastProperty.value.end; edits.push({ start: insertBeforeClosingLine ? closingLineStart : objectNode.end, end: insertBeforeClosingLine ? closingLineStart : objectNode.end, text: insertBeforeClosingLine ? `${renderedProperties}${eol}` : `${eol}${renderedProperties}${eol}${closingIndent}`, }); } function applyEdits(input, edits) { return edits .sort((left, right) => right.start - left.start || right.end - left.end) .reduce( (text, edit) => `${text.slice(0, edit.start)}${edit.text}${text.slice(edit.end)}`, input ); } 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 targetAst = parseJsoncAst(targetText, options.target); const indentUnit = detectIndentUnit(targetText); const eol = detectEol(targetText); const edits = []; buildMergeEdits(targetText, targetAst, managedSettings, indentUnit, eol, edits); const output = edits.length === 0 ? targetText : applyEdits(targetText, edits); parseJsonc(output, options.target); 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();