#!/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 closingBraceIndex = objectNode.end - 1; 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: closingBraceIndex, 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();