diff --git a/packages/cspell/src/__snapshots__/app.test.ts.snap b/packages/cspell/src/__snapshots__/app.test.ts.snap index 60111d5b8e8..3a02622598d 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 c32fc560a9a..4af687078af 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 3fd51508cbb..9021873d638 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 0c254895875..03ddc155fd8 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 cddfc84ec3c..4f51485db1b 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`. */