com.wallstop-studios.unity-helpers
Version:
Treasure chest of Unity developer tools
206 lines (184 loc) • 6.69 kB
JavaScript
// MIT License — Copyright (c) wallstop studios
//
// Adds one or more words to a cspell.json bucket without requiring a hand-edit
// of the 1000+-line file. Deduplicates, rejects cross-bucket duplicates, and
// validates the JSON round-trip before writing.
//
// Usage:
// node scripts/add-cspell-word.js <bucket> <word> [<word>...]
// node scripts/add-cspell-word.js --dry-run <bucket> <word> [...]
//
// Buckets:
// unity-terms, csharp-terms, package-terms, tech-terms -> dictionaryDefinitions
// words -> root `words` array
//
// Exit codes:
// 0 success (or no-op if word is already present in the target bucket)
// 1 usage / validation error, including cross-bucket duplicates
;
const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");
const CSPELL_PATH = path.resolve(__dirname, "..", "cspell.json");
const DICT_BUCKETS = new Set(["unity-terms", "csharp-terms", "package-terms", "tech-terms"]);
const ROOT_BUCKET = "words";
function usage(exitCode) {
const msg = [
"Usage: node scripts/add-cspell-word.js [--dry-run] <bucket> <word> [<word>...]",
"",
"Buckets:",
" unity-terms Unity Engine APIs, components, lifecycle hooks",
" csharp-terms C# language / BCL / SDK vocabulary",
" package-terms Public API / symbols exported by this package",
" tech-terms General programming / tooling vocabulary",
" words Root words array (project-specific, lint-error prefixes)"
].join("\n");
console.error(msg);
process.exit(exitCode);
}
function parseArgs(argv) {
const args = argv.slice(2);
let dryRun = false;
const positional = [];
for (const arg of args) {
if (arg === "--dry-run") {
dryRun = true;
continue;
}
if (arg === "-h" || arg === "--help") {
usage(0);
}
positional.push(arg);
}
if (positional.length < 2) {
usage(1);
}
const [bucket, ...words] = positional;
return { bucket, words, dryRun };
}
function loadConfig() {
const raw = fs.readFileSync(CSPELL_PATH, "utf8");
return { raw, config: JSON.parse(raw) };
}
function findWordLocations(config, lowerWord) {
const locations = [];
if (Array.isArray(config.dictionaryDefinitions)) {
for (const dict of config.dictionaryDefinitions) {
if (Array.isArray(dict.words) && dict.words.some((w) => w.toLowerCase() === lowerWord)) {
locations.push(`dictionary:${dict.name}`);
}
}
}
if (Array.isArray(config.words) && config.words.some((w) => w.toLowerCase() === lowerWord)) {
locations.push("root:words");
}
return locations;
}
function getBucketWords(config, bucket) {
if (bucket === ROOT_BUCKET) {
if (!Array.isArray(config.words)) {
config.words = [];
}
return config.words;
}
if (!Array.isArray(config.dictionaryDefinitions)) {
throw new Error("cspell.json is missing dictionaryDefinitions");
}
const dict = config.dictionaryDefinitions.find((d) => d.name === bucket);
if (!dict) {
throw new Error(`Bucket '${bucket}' not found in dictionaryDefinitions`);
}
if (!Array.isArray(dict.words)) {
dict.words = [];
}
return dict.words;
}
function getTargetLocation(bucket) {
return bucket === ROOT_BUCKET ? "root:words" : `dictionary:${bucket}`;
}
function main() {
const { bucket, words, dryRun } = parseArgs(process.argv);
if (!DICT_BUCKETS.has(bucket) && bucket !== ROOT_BUCKET) {
console.error(`Error: unknown bucket '${bucket}'.`);
usage(1);
}
const { raw, config } = loadConfig();
const targetWords = getBucketWords(config, bucket);
const targetLocation = getTargetLocation(bucket);
const added = [];
const alreadyPresentInTarget = [];
const crossBucketDuplicates = [];
for (const word of words) {
if (!word || /\s/.test(word)) {
console.error(`Error: refusing to add empty or whitespace-containing word: '${word}'`);
process.exit(1);
}
const lower = word.toLowerCase();
const existingLocations = findWordLocations(config, lower);
if (existingLocations.length > 0) {
const isTargetOnly = existingLocations.every((location) => location === targetLocation);
if (isTargetOnly) {
alreadyPresentInTarget.push(word);
continue;
}
crossBucketDuplicates.push({ word, locations: existingLocations });
continue;
}
targetWords.push(word);
added.push(word);
}
if (crossBucketDuplicates.length > 0) {
for (const s of crossBucketDuplicates) {
console.error(
`Error: '${s.word}' already present in: ${s.locations.join(", ")}. Refusing duplicate.`
);
}
process.exit(1);
}
if (added.length === 0) {
if (alreadyPresentInTarget.length > 0) {
console.log(
`No new words to add. Already present in '${bucket}': ${alreadyPresentInTarget.join(", ")}`
);
} else {
console.log("No new words to add.");
}
process.exit(0);
}
// Preserve existing line-ending style so .gitattributes-driven eol:check
// (cspell.json is mandated CRLF by .gitattributes) doesn't flag the write.
const eol = raw.includes("\r\n") ? "\r\n" : "\n";
const serialized = JSON.stringify(config, null, 2) + "\n";
const output = serialized.replace(/\r?\n/g, eol);
// Validate round-trip before writing
try {
JSON.parse(output);
} catch (err) {
console.error(`Error: JSON round-trip validation failed: ${err.message}`);
process.exit(1);
}
if (dryRun) {
console.log(`[dry-run] Would add to '${bucket}': ${added.join(", ")}`);
process.exit(0);
}
fs.writeFileSync(CSPELL_PATH, output, "utf8");
console.log(`Added to '${bucket}': ${added.join(", ")}`);
// Post-steps: format and validate. Do not fail the script if these tools
// are unavailable (e.g. node_modules not yet installed); just surface a hint.
const repoRoot = path.resolve(__dirname, "..");
const runOptional = (label, cmd) => {
try {
execSync(cmd, { cwd: repoRoot, stdio: "inherit" });
console.log(`[${label}] ok`);
} catch (err) {
console.warn(`[${label}] skipped: ${err.message}`);
}
};
runOptional("prettier", "node scripts/run-prettier.js --write -- cspell.json");
runOptional("lint:spelling:config", "node scripts/lint-cspell-config.js");
// Preserve original raw for diagnostics in case a caller wants diff
void raw;
process.exit(0);
}
main();