From 7d9b18ba136b3b49b97def579d458681c00dbb40 Mon Sep 17 00:00:00 2001 From: Jason Dent Date: Fri, 6 Nov 2026 12:59:35 +0100 Subject: [PATCH 1/2] fix: `cspell init` only changes what was asked for in existing configs --- .../cspell/src/__snapshots__/app.test.ts.snap | 4 +- packages/cspell/src/commandInit.test.ts | 58 ++++++++++++++++++- packages/cspell/src/commandInit.ts | 2 +- packages/cspell/src/config/adjustConfig.ts | 2 +- packages/cspell/src/config/configInit.ts | 42 +++++--------- packages/cspell/src/config/options.ts | 2 +- 6 files changed, 78 insertions(+), 32 deletions(-) diff --git a/packages/cspell/src/__snapshots__/app.test.ts.snap b/packages/cspell/src/__snapshots__/app.test.ts.snap index 2d17607fbf5a..60111d5b8e88 100644 --- a/packages/cspell/src/__snapshots__/app.test.ts.snap +++ b/packages/cspell/src/__snapshots__/app.test.ts.snap @@ -10551,8 +10551,8 @@ exports[`Validate cli > app help [ 'init', '--help' ] 1`] = ` " --dictionary Enable a dictionary. Can be used multiple times.", " --no-comments Do not add comments to the config file.", " --remove-comments Remove all comments from the config file.", - " --schema Add or update the schema reference. This is the", - " default for a new file.", + " --schema Add or update the schema reference. Default for a", + " config with no settings.", " --no-schema Do not add the schema reference to the config file.", " --stdout Write the configuration to stdout instead of a", " file.", diff --git a/packages/cspell/src/commandInit.test.ts b/packages/cspell/src/commandInit.test.ts index ceb6e72cfb92..c32fc560a9a7 100644 --- a/packages/cspell/src/commandInit.test.ts +++ b/packages/cspell/src/commandInit.test.ts @@ -11,7 +11,7 @@ import { run } from './app.mjs'; const oldSchema = 'https://example.com/old.schema.json'; const yamlSchemaComment = `# yaml-language-server: $schema=${cspellConfigFileSchema}`; -describe('cspell init $schema', () => { +describe('cspell init', () => { let tempDir: string; beforeEach(async () => { @@ -84,4 +84,60 @@ describe('cspell init $schema', () => { const content = await init('cspell.config.yaml', ...args); expect(content.includes(yamlSchemaComment)).toBe(expected); }); + + async function initConfig(fileName: string, ...args: string[]): Promise { + const filePath = path.join(tempDir, fileName); + await run(new Command(), [...process.argv.slice(0, 2), 'init', '--config', filePath, ...args]); + return fs.readFile(filePath, 'utf8'); + } + + const newFileSettings = { $schema: cspellConfigFileSchema, version: '0.2', language: 'en' }; + + test.each` + fileName + ${'cspell.json'} + ${'cspell.jsonc'} + `('new $fileName with --config', async ({ fileName }) => { + expect(JSON.parse(await initConfig(fileName, '--no-comments'))).toEqual(newFileSettings); + }); + + test('new JSON file with --output and the default format', async () => { + const filePath = path.join(tempDir, 'cspell.json'); + await run(new Command(), [...process.argv.slice(0, 2), 'init', '--output', filePath]); + expect(JSON.parse(await fs.readFile(filePath, 'utf8'))).toEqual(newFileSettings); + }); + + test('existing JSON file only changes what was asked for', async () => { + await writeConfig('cspell.json', '{ "words": ["one"] }\n'); + expect(JSON.parse(await initConfig('cspell.json'))).toEqual({ words: ['one'] }); + expect(JSON.parse(await initConfig('cspell.json', '--locale', 'en-GB'))).toEqual({ + words: ['one'], + language: 'en-GB', + }); + }); + + test('existing YAML file is unchanged', async () => { + const content = 'words:\n - one\n'; + await writeConfig('cspell.config.yaml', content); + expect(await initConfig('cspell.config.yaml')).toBe(content); + }); + + test('existing file with no settings is initialized', async () => { + await writeConfig('cspell.json', '{}\n'); + expect(JSON.parse(await initConfig('cspell.json'))).toEqual(newFileSettings); + }); + + test('package.json without a cspell section gets one', async () => { + await writeConfig('package.json', '{ "name": "sample" }\n'); + expect(JSON.parse(await initConfig('package.json'))).toEqual({ + name: 'sample', + cspell: { version: '0.2', language: 'en' }, + }); + }); + + test('package.json with a cspell section is unchanged', async () => { + const packageJson = { name: 'sample', cspell: { words: ['one'] } }; + await writeConfig('package.json', JSON.stringify(packageJson)); + expect(JSON.parse(await initConfig('package.json'))).toEqual(packageJson); + }); }); diff --git a/packages/cspell/src/commandInit.ts b/packages/cspell/src/commandInit.ts index 68659d504ef2..3fd51508cbb5 100644 --- a/packages/cspell/src/commandInit.ts +++ b/packages/cspell/src/commandInit.ts @@ -31,7 +31,7 @@ export function commandInit(prog: Command): Command { .option('--no-comments', 'Do not add comments to the config file.') .addOption(crOpt('--remove-comments', 'Remove all comments from the config file.').implies({ comments: false })) .addOption( - crOpt('--schema', 'Add or update the schema reference. This is the default for a new file.').default( + crOpt('--schema', 'Add or update the schema reference. Default for a config with no settings.').default( undefined, ), ) diff --git a/packages/cspell/src/config/adjustConfig.ts b/packages/cspell/src/config/adjustConfig.ts index eb3aa953f1ff..3fef5847f163 100644 --- a/packages/cspell/src/config/adjustConfig.ts +++ b/packages/cspell/src/config/adjustConfig.ts @@ -10,7 +10,7 @@ import { isCfgArrayNode, MutableCSpellConfigFile } from 'cspell-config-lib'; import { toError } from '../util/errors.js'; -export async function fileExists(url: URL): Promise { +async function fileExists(url: URL): Promise { if (url.protocol !== 'file:') { return false; // Only check file URLs } diff --git a/packages/cspell/src/config/configInit.ts b/packages/cspell/src/config/configInit.ts index b1956f43ab17..0c2548958753 100644 --- a/packages/cspell/src/config/configInit.ts +++ b/packages/cspell/src/config/configInit.ts @@ -6,8 +6,9 @@ import type { CSpellConfigFile, CSpellConfigFileReaderWriter } from 'cspell-conf import { createReaderWriter, cspellConfigFileSchema } from 'cspell-config-lib'; import { console } from '../console.js'; -import { addDictionariesToConfigFile, addImportsToConfigFile, fileExists } from './adjustConfig.js'; +import { addDictionariesToConfigFile, addImportsToConfigFile } from './adjustConfig.js'; import { applyValuesToConfigFile } from './config.js'; +import type { CommentConfig } from './constants.js'; import { defaultConfig } from './constants.js'; import type { InitOptions } from './options.js'; @@ -24,9 +25,8 @@ const defaultConfigYaml = ` export async function configInit(options: InitOptions): Promise { const rw = createReaderWriter(); const url = determineFileNameURL(options); - const schema = options.schema ?? !(await fileExists(url)); - const configFile = await createConfigFile(rw, url, options); - await applyOptionsToConfigFile(configFile, { ...options, schema }); + const configFile = await createConfigFile(rw, url); + await applyOptionsToConfigFile(configFile, options); await fs.mkdir(new URL('./', configFile.url), { recursive: true }); if (options.stdout) { @@ -38,6 +38,7 @@ export async function configInit(options: InitOptions): Promise { async function applyOptionsToConfigFile(configFile: CSpellConfigFile, options: InitOptions): Promise { const settings: CSpellSettings = {}; + const hasSettings = Object.keys(configFile.settings).length > 0; const addComments = options.comments || @@ -47,7 +48,7 @@ async function applyOptionsToConfigFile(configFile: CSpellConfigFile, options: I configFile.removeAllComments(); } - if (options.schema) { + if (options.schema ?? !hasSettings) { configFile.setSchema(schemaRef); } @@ -55,7 +56,8 @@ async function applyOptionsToConfigFile(configFile: CSpellConfigFile, options: I settings.language = options.locale; } - applyValuesToConfigFile(configFile, settings, defaultConfig, addComments); + const defaults = hasSettings ? withoutDefaultValues(defaultConfig) : defaultConfig; + applyValuesToConfigFile(configFile, settings, defaults, addComments); if (options.import) { await addImportsToConfigFile( @@ -76,6 +78,10 @@ async function applyOptionsToConfigFile(configFile: CSpellConfigFile, options: I return configFile; } +function withoutDefaultValues(config: CommentConfig): CommentConfig { + return Object.fromEntries(Object.entries(config).map(([key, entry]) => [key, { ...entry, value: undefined }])); +} + function determineFileNameURL(options: InitOptions): URL { if (options.config) { return toFileURL(options.config); @@ -110,32 +116,16 @@ function determineDefaultFileName(options: InitOptions): string { throw new Error(`Unsupported format: ${options.format}`); } -function getDefaultContent(options: InitOptions): string { - switch (options.format) { - case undefined: - case 'yaml': { - return defaultConfigYaml; - } - case 'json': - case 'jsonc': { - return defaultConfigJson; - } - default: { - throw new Error(`Unsupported format: ${options.format}`); - } - } +function getDefaultContent(url: URL): string { + return /\.jsonc?$/.test(url.pathname) ? defaultConfigJson : defaultConfigYaml; } -async function createConfigFile( - rw: CSpellConfigFileReaderWriter, - url: URL, - options: InitOptions, -): Promise { +async function createConfigFile(rw: CSpellConfigFileReaderWriter, url: URL): Promise { if (url.pathname.endsWith('package.json')) { return rw.readConfig(url); } - const content = await fs.readFile(url, 'utf8').catch(() => getDefaultContent(options)); + const content = await fs.readFile(url, 'utf8').catch(() => getDefaultContent(url)); return rw.parse({ url, content }); } diff --git a/packages/cspell/src/config/options.ts b/packages/cspell/src/config/options.ts index df05e714da91..cddfc84ec3c3 100644 --- a/packages/cspell/src/config/options.ts +++ b/packages/cspell/src/config/options.ts @@ -33,7 +33,7 @@ export interface BaseConfigOptions { * Whether to add the schema reference to the configuration file. * - `true` - the schema reference will be added / updated. * - `false` - no schema reference will be added. - * - `undefined` - the schema reference will be added only when creating a new file. + * - `undefined` - the schema reference will be added only if the configuration has no settings yet. */ schema?: boolean; /** From ca9e95e22fe9dba3d03a59a6b552a99a204eec08 Mon Sep 17 00:00:00 2001 From: Jason Dent Date: Fri, 6 Nov 2026 13:14:37 +0100 Subject: [PATCH 2/2] feat: `cspell init` updates the config in the current directory --- .../cspell/src/__snapshots__/app.test.ts.snap | 12 ++- packages/cspell/src/commandInit.test.ts | 98 +++++++++++++++++++ packages/cspell/src/commandInit.ts | 13 ++- packages/cspell/src/config/configInit.ts | 65 +++++++++++- packages/cspell/src/config/options.ts | 6 +- 5 files changed, 177 insertions(+), 17 deletions(-) diff --git a/packages/cspell/src/__snapshots__/app.test.ts.snap b/packages/cspell/src/__snapshots__/app.test.ts.snap index 60111d5b8e88..3a02622598d3 100644 --- a/packages/cspell/src/__snapshots__/app.test.ts.snap +++ b/packages/cspell/src/__snapshots__/app.test.ts.snap @@ -10540,11 +10540,13 @@ exports[`Validate cli > app help [ 'init', '--help' ] 1`] = ` "Initialize a CSpell configuration file.", "", "Options:", - " -c, --config Path to the CSpell configuration file. Conflicts", - " with --output and --format.", - " -o, --output Define where to write file.", - " --format Define the format of the file. (choices: "yaml",", - " "yml", "json", "jsonc", default: "yaml")", + " -c, --config Path to the CSpell configuration file. Defaults to", + " the one in the current directory. Conflicts with", + " --output and --format.", + " -o, --output Define where to write a new file instead of using", + " an existing config.", + " --format Define the format of a new file. Default: yaml.", + " (choices: "yaml", "yml", "json", "jsonc")", " --import Import a configuration file or dictionary package.", " --locale Define the locale to use when spell checking (e.g.,", " en, en-US, de).", diff --git a/packages/cspell/src/commandInit.test.ts b/packages/cspell/src/commandInit.test.ts index c32fc560a9a7..4af687078af3 100644 --- a/packages/cspell/src/commandInit.test.ts +++ b/packages/cspell/src/commandInit.test.ts @@ -1,12 +1,17 @@ +import { execFile } from 'node:child_process'; import { promises as fs } from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { promisify } from 'node:util'; import { Command } from 'commander'; import { cspellConfigFileSchema } from 'cspell-config-lib'; import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { run } from './app.mjs'; +import { pathPackageRoot } from './test/test.helper.js'; + +const execFileAsync = promisify(execFile); const oldSchema = 'https://example.com/old.schema.json'; const yamlSchemaComment = `# yaml-language-server: $schema=${cspellConfigFileSchema}`; @@ -140,4 +145,97 @@ describe('cspell init', () => { await writeConfig('package.json', JSON.stringify(packageJson)); expect(JSON.parse(await initConfig('package.json'))).toEqual(packageJson); }); + + describe('without a path', () => { + const codeConfig = 'export default { words: ["one"] };\n'; + + async function initHere(...args: string[]): Promise { + await execFileAsync(process.execPath, [path.join(pathPackageRoot, 'bin.mjs'), 'init', ...args], { + cwd: tempDir, + }); + } + + function readHere(fileName: string): Promise { + return fs.readFile(path.join(tempDir, fileName), 'utf8'); + } + + async function writeHere(fileName: string, content: string): Promise { + await fs.mkdir(path.dirname(path.join(tempDir, fileName)), { recursive: true }); + await writeConfig(fileName, content); + } + + test('creates the default file when there is no config', async () => { + await initHere(); + expect(await fs.readdir(tempDir)).toEqual(['cspell.config.yaml']); + }); + + test.each` + fileName + ${'cspell.json'} + ${'.cspell.config.yaml'} + ${'.vscode/cspell.json'} + ${'.config/cspell.yaml'} + `('updates $fileName', async ({ fileName }) => { + await writeHere(fileName, '{ "words": ["one"] }\n'); + await initHere('--locale', 'en-GB'); + expect(await readHere(fileName)).toContain('en-GB'); + expect(await fs.readdir(tempDir)).not.toContain('cspell.config.yaml'); + }); + + test('updates the file cspell would load', async () => { + await writeHere('cspell.json', '{ "words": ["one"] }\n'); + await writeHere('cspell.config.yaml', 'words:\n - two\n'); + await initHere('--locale', 'en-GB'); + expect(await readHere('cspell.json')).toContain('en-GB'); + expect(await readHere('cspell.config.yaml')).not.toContain('en-GB'); + }); + + test('package.json counts only with a cspell section', async () => { + await writeHere('package.json', '{ "name": "sample" }\n'); + await initHere(); + expect(JSON.parse(await readHere('package.json'))).toEqual({ name: 'sample' }); + expect(await readHere('cspell.config.yaml')).toContain('version'); + + await fs.rm(path.join(tempDir, 'cspell.config.yaml')); + await writeHere('package.json', '{ "name": "sample", "cspell": { "words": ["one"] } }\n'); + await initHere('--locale', 'en-GB'); + expect(JSON.parse(await readHere('package.json')).cspell.language).toBe('en-GB'); + expect(await fs.readdir(tempDir)).toEqual(['package.json']); + }); + + test.each` + args | fileName + ${['--output', 'o.json']} | ${'o.json'} + ${['--format', 'jsonc']} | ${'cspell.jsonc'} + `('$args creates a new file', async ({ args, fileName }) => { + await writeHere('cspell.json', '{ "words": ["one"] }\n'); + await initHere(...args); + expect(await readHere('cspell.json')).toBe('{ "words": ["one"] }\n'); + expect(await readHere(fileName)).toContain('version'); + }); + + test('writes nothing for a code config when no change is requested', async () => { + await writeHere('cspell.config.mjs', codeConfig); + await initHere(); + expect(await fs.readdir(tempDir)).toEqual(['cspell.config.mjs']); + }); + + test('imports a code config into a new file with only what was asked for', async () => { + await writeHere('cspell.config.mjs', codeConfig); + await initHere('--locale', 'en-GB', '--no-comments'); + expect(await readHere('cspell.config.mjs')).toBe(codeConfig); + expect(await readHere('cspell.config.yaml')).toBe( + 'language: en-GB\n' + 'import:\n' + ' - ./cspell.config.mjs\n', + ); + }); + + test('imports a code config in a subfolder', async () => { + await writeHere('.config/cspell.config.ts', 'export default {};\n'); + await initHere('--schema'); + const content = await readHere('cspell.config.yaml'); + expect(content).toContain(yamlSchemaComment); + expect(content).toContain('- ./.config/cspell.config.ts'); + expect(content).not.toContain('version'); + }); + }); }); diff --git a/packages/cspell/src/commandInit.ts b/packages/cspell/src/commandInit.ts index 3fd51508cbb5..9021873d6385 100644 --- a/packages/cspell/src/commandInit.ts +++ b/packages/cspell/src/commandInit.ts @@ -11,14 +11,17 @@ export function commandInit(prog: Command): Command { .addOption( crOpt( '-c, --config ', - 'Path to the CSpell configuration file. Conflicts with --output and --format.', + 'Path to the CSpell configuration file. Defaults to the one in the current directory. Conflicts with --output and --format.', ).conflicts(['output', 'format']), ) - .option('-o, --output ', 'Define where to write file.') + .option('-o, --output ', 'Define where to write a new file instead of using an existing config.') .addOption( - crOpt('--format ', 'Define the format of the file.') - .choices(['yaml', 'yml', 'json', 'jsonc']) - .default('yaml'), + crOpt('--format ', 'Define the format of a new file. Default: yaml.').choices([ + 'yaml', + 'yml', + 'json', + 'jsonc', + ]), ) .option('--import ', 'Import a configuration file or dictionary package.', collect) .option('--locale ', 'Define the locale to use when spell checking (e.g., en, en-US, de).') diff --git a/packages/cspell/src/config/configInit.ts b/packages/cspell/src/config/configInit.ts index 0c2548958753..03ddc155fd85 100644 --- a/packages/cspell/src/config/configInit.ts +++ b/packages/cspell/src/config/configInit.ts @@ -1,9 +1,10 @@ import fs from 'node:fs/promises'; import type { CSpellSettings } from '@cspell/cspell-types'; -import { toFileDirURL, toFileURL } from '@cspell/url'; +import { toFileDirURL, toFileURL, urlRelative } from '@cspell/url'; import type { CSpellConfigFile, CSpellConfigFileReaderWriter } from 'cspell-config-lib'; import { createReaderWriter, cspellConfigFileSchema } from 'cspell-config-lib'; +import { defaultConfigFilenames } from 'cspell-lib'; import { console } from '../console.js'; import { addDictionariesToConfigFile, addImportsToConfigFile } from './adjustConfig.js'; @@ -24,9 +25,14 @@ const defaultConfigYaml = ` export async function configInit(options: InitOptions): Promise { const rw = createReaderWriter(); - const url = determineFileNameURL(options); + const existingConfig = await findExistingConfig(options); + const importFile = existingConfig && isCodeConfig(existingConfig) ? existingConfig : undefined; + if (importFile && !hasRequestedChange(options)) return; + + const url = importFile ? determineFileNameURL({}) : (existingConfig ?? determineFileNameURL(options)); const configFile = await createConfigFile(rw, url); - await applyOptionsToConfigFile(configFile, options); + const hasSettings = !!importFile || Object.keys(configFile.settings).length > 0; + await applyOptionsToConfigFile(configFile, withImportFile(options, importFile), hasSettings); await fs.mkdir(new URL('./', configFile.url), { recursive: true }); if (options.stdout) { @@ -36,9 +42,58 @@ export async function configInit(options: InitOptions): Promise { } } -async function applyOptionsToConfigFile(configFile: CSpellConfigFile, options: InitOptions): Promise { +/** + * Find the config file cspell would load for the current directory, without looking in parent directories. + * Naming the file or its format with an option means the existing config isn't wanted. + */ +async function findExistingConfig(options: InitOptions): Promise { + if (options.config || options.output || options.format) return undefined; + const cwd = toFileDirURL('./'); + for (const filename of defaultConfigFilenames) { + const url = new URL(filename, cwd); + if (await isConfigFile(url)) return url; + } + return undefined; +} + +async function isConfigFile(url: URL): Promise { + const stats = await fs.stat(url).catch(() => undefined); + if (!stats?.isFile()) return false; + return !url.pathname.endsWith('/package.json') || hasCSpellSection(url); +} + +async function hasCSpellSection(packageJsonUrl: URL): Promise { + try { + const pkg = JSON.parse(await fs.readFile(packageJsonUrl, 'utf8')); + return typeof pkg.cspell === 'object'; + } catch { + return false; + } +} + +function isCodeConfig(url: URL): boolean { + return /\.[cm]?[jt]s$/.test(url.pathname); +} + +/** + * A code config can't be edited, so changes are only possible through a new config file. + */ +function hasRequestedChange(options: InitOptions): boolean { + return !!(options.locale || options.schema || options.import?.length || options.dictionary?.length); +} + +function withImportFile(options: InitOptions, importFile: URL | undefined): InitOptions { + if (!importFile) return options; + const relativePath = './' + urlRelative(toFileDirURL('./'), importFile); + return { ...options, import: [relativePath, ...(options.import ?? [])] }; +} + +async function applyOptionsToConfigFile( + configFile: CSpellConfigFile, + options: InitOptions, + hasSettings: boolean, +): Promise { const settings: CSpellSettings = {}; - const hasSettings = Object.keys(configFile.settings).length > 0; const addComments = options.comments || diff --git a/packages/cspell/src/config/options.ts b/packages/cspell/src/config/options.ts index cddfc84ec3c3..4f51485db1b3 100644 --- a/packages/cspell/src/config/options.ts +++ b/packages/cspell/src/config/options.ts @@ -1,7 +1,8 @@ export interface BaseConfigOptions { /** * The path to the configuration file to create / update. - * If not provided, a default file name will be used based on the format. + * If not provided, the configuration file in the current directory is updated. + * If there is none, a default file name will be used based on the format. */ config?: string; /** @@ -52,7 +53,8 @@ export interface BaseConfigOptions { export interface InitOptions extends BaseConfigOptions { /** * The path where the configuration file will be written, it can be a file path or a URL. - * If not provided, a default file name will be used based on the format. + * If not provided, the configuration file in the current directory is updated. + * If there is none, a default file name will be used based on the format. * The default will be `cspell.config.yaml` or `cspell.json` based on the format. * @conflicts `config` - If `config` is provided, it will be used instead of `output`. */