Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions packages/cspell/src/__snapshots__/app.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -10540,11 +10540,13 @@ exports[`Validate cli > app help [ 'init', '--help' ] 1`] = `
"Initialize a CSpell configuration file.",
"",
"Options:",
" -c, --config <path> Path to the CSpell configuration file. Conflicts",
" with --output and --format.",
" -o, --output <path> Define where to write file.",
" --format <format> Define the format of the file. (choices: "yaml",",
" "yml", "json", "jsonc", default: "yaml")",
" -c, --config <path> Path to the CSpell configuration file. Defaults to",
" the one in the current directory. Conflicts with",
" --output and --format.",
" -o, --output <path> Define where to write a new file instead of using",
" an existing config.",
" --format <format> Define the format of a new file. Default: yaml.",
" (choices: "yaml", "yml", "json", "jsonc")",
" --import <path|package> Import a configuration file or dictionary package.",
" --locale <locale> Define the locale to use when spell checking (e.g.,",
" en, en-US, de).",
Expand Down
98 changes: 98 additions & 0 deletions packages/cspell/src/commandInit.test.ts
Original file line number Diff line number Diff line change
@@ -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}`;
Expand Down Expand Up @@ -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<void> {
await execFileAsync(process.execPath, [path.join(pathPackageRoot, 'bin.mjs'), 'init', ...args], {
cwd: tempDir,
});
}

function readHere(fileName: string): Promise<string> {
return fs.readFile(path.join(tempDir, fileName), 'utf8');
}

async function writeHere(fileName: string, content: string): Promise<void> {
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');
});
});
});
13 changes: 8 additions & 5 deletions packages/cspell/src/commandInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,17 @@ export function commandInit(prog: Command): Command {
.addOption(
crOpt(
'-c, --config <path>',
'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 <path>', 'Define where to write file.')
.option('-o, --output <path>', 'Define where to write a new file instead of using an existing config.')
.addOption(
crOpt('--format <format>', 'Define the format of the file.')
.choices(['yaml', 'yml', 'json', 'jsonc'])
.default('yaml'),
crOpt('--format <format>', 'Define the format of a new file. Default: yaml.').choices([
'yaml',
'yml',
'json',
'jsonc',
]),
)
.option('--import <path|package>', 'Import a configuration file or dictionary package.', collect)
.option('--locale <locale>', 'Define the locale to use when spell checking (e.g., en, en-US, de).')
Expand Down
65 changes: 60 additions & 5 deletions packages/cspell/src/config/configInit.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -24,9 +25,14 @@ const defaultConfigYaml = `

export async function configInit(options: InitOptions): Promise<void> {
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) {
Expand All @@ -36,9 +42,58 @@ export async function configInit(options: InitOptions): Promise<void> {
}
}

async function applyOptionsToConfigFile(configFile: CSpellConfigFile, options: InitOptions): Promise<CSpellConfigFile> {
/**
* 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<URL | undefined> {
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<boolean> {
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<boolean> {
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<CSpellConfigFile> {
const settings: CSpellSettings = {};
const hasSettings = Object.keys(configFile.settings).length > 0;

const addComments =
options.comments ||
Expand Down
6 changes: 4 additions & 2 deletions packages/cspell/src/config/options.ts
Original file line number Diff line number Diff line change
@@ -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;
/**
Expand Down Expand Up @@ -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`.
*/
Expand Down
Loading