diff --git a/packages/cspell/src/app/__snapshots__/app.test.ts.snap b/packages/cspell/src/app/__snapshots__/app.test.ts.snap index 3287ae94d5f..f486fe65be3 100644 --- a/packages/cspell/src/app/__snapshots__/app.test.ts.snap +++ b/packages/cspell/src/app/__snapshots__/app.test.ts.snap @@ -537,6 +537,8 @@ exports[`Validate cli > app 'no-args' Expect Error: 'outputHelp' 1`] = ` " --file-list Specify a list of files to be spell checked. The", " list is filtered against the glob file patterns.", " Note: the format is 1 file path per line.", + " --file [file...] Specify files to spell check. They are filtered", + " by the [globs...].", " --no-issues Do not show the spelling errors.", " --no-progress Turn off progress messages", " --no-summary Turn off summary message in console.", @@ -589,6 +591,13 @@ exports[`Validate cli > app 'no-args' Expect Error: 'outputHelp' 1`] = ` " cspell . --reporter .//reporter.cjs", " Use a custom reporter. See API for details.", "", + " cspell "*.md" --exclude CHANGELOG.md --files README.md CHANGELOG.md", + " Spell check only check "README.md" but NOT "CHANGELOG.md".", + "", + " cspell "/*.md" --no-must-find-files --files $FILES", + " Only spell check the "/*.md" files in $FILES,", + " where $FILES is a shell variable that contains the list of files.", + "", "References:", " https://cspell.org", " https://github.com/streetsidesoftware/cspell", diff --git a/packages/cspell/src/app/commandLint.ts b/packages/cspell/src/app/commandLint.ts index fd55c2b08e0..ac5f9cdc4a7 100644 --- a/packages/cspell/src/app/commandLint.ts +++ b/packages/cspell/src/app/commandLint.ts @@ -43,16 +43,21 @@ More Examples: cspell . --reporter .//reporter.cjs Use a custom reporter. See API for details. + cspell "*.md" --exclude CHANGELOG.md --files README.md CHANGELOG.md + Spell check only check "README.md" but NOT "CHANGELOG.md". + + cspell "/*.md" --no-must-find-files --files $FILES + Only spell check the "/*.md" files in $FILES, + where $FILES is a shell variable that contains the list of files. + References: https://cspell.org https://github.com/streetsidesoftware/cspell `; -function collect(value: string, previous: string[] | undefined): string[] { - if (!previous) { - return [value]; - } - return previous.concat([value]); +function collect(value: string | string[], previous: string[] | undefined): string[] { + const values = Array.isArray(value) ? value : [value]; + return previous ? [...previous, ...values] : values; } export function commandLint(prog: Command): Command { @@ -70,21 +75,20 @@ export function commandLint(prog: Command): Command { ) .option('--language-id ', 'Force programming language for unknown extensions. i.e. "php" or "scala"') .addOption( - new CommanderOption( + crOpt( '--languageId ', 'Force programming language for unknown extensions. i.e. "php" or "scala"', ).hideHelp(), ) .option('--words-only', 'Only output the words not found in the dictionaries.') - .addOption( - new CommanderOption('--wordsOnly', 'Only output the words not found in the dictionaries.').hideHelp(), - ) + .addOption(crOpt('--wordsOnly', 'Only output the words not found in the dictionaries.').hideHelp()) .option('-u, --unique', 'Only output the first instance of a word not found in the dictionaries.') .option( '-e, --exclude ', 'Exclude files matching the glob pattern. This option can be used multiple times to add multiple globs. ', collect, ) + // .option('--include ', 'Include files matching the glob pattern. This option can be used multiple times.', collect) .option( '--file-list ', 'Specify a list of files to be spell checked.' + @@ -92,43 +96,45 @@ export function commandLint(prog: Command): Command { ' Note: the format is 1 file path per line.', collect, ) + .option('--file [file...]', 'Specify files to spell check. They are filtered by the [globs...].', collect) + .addOption(crOpt('--files [file...]', 'Files to spell check.', collect).hideHelp()) .option('--no-issues', 'Do not show the spelling errors.') .option('--no-progress', 'Turn off progress messages') .option('--no-summary', 'Turn off summary message in console.') .option('-s, --silent', 'Silent mode, suppress error messages.') .option('--no-exit-code', 'Do not return an exit code if issues are found.') .addOption( - new CommanderOption('--quiet', 'Only show spelling issues or errors.').implies({ + crOpt('--quiet', 'Only show spelling issues or errors.').implies({ summary: false, progress: false, }), ) .option('--fail-fast', 'Exit after first file with an issue or error.') - .addOption(new CommanderOption('--no-fail-fast', 'Process all files even if there is an error.').hideHelp()) + .addOption(crOpt('--no-fail-fast', 'Process all files even if there is an error.').hideHelp()) .option('-r, --root ', 'Root directory, defaults to current directory.') - .addOption( - new CommanderOption('--relative', 'Issues are displayed relative to the root.').default(true).hideHelp(), - ) + .addOption(crOpt('--relative', 'Issues are displayed relative to the root.').default(true).hideHelp()) .option('--no-relative', 'Issues are displayed with absolute path instead of relative to the root.') .option('--show-context', 'Show the surrounding text around an issue.') .option('--show-suggestions', 'Show spelling suggestions.') - .addOption( - new CommanderOption('--no-show-suggestions', 'Do not show spelling suggestions or fixes.').default( - undefined, - ), - ) - .addOption(new CommanderOption('--must-find-files', 'Error if no files are found.').default(true).hideHelp()) + .addOption(crOpt('--no-show-suggestions', 'Do not show spelling suggestions or fixes.').default(undefined)) + .addOption(crOpt('--must-find-files', 'Error if no files are found.').default(true).hideHelp()) .option('--no-must-find-files', 'Do not error if no files are found.') + // The --filter-files option is still under design review. + // .option('--filter-files', 'Use the `files` configuration to filter files found.') + // .option( + // '--no-filter-files', + // 'Do NOT use the `files` configuration to filter files (Only applies to --files options).', + // ) // The following options are planned features // .option('-w, --watch', 'Watch for any changes to the matching files and report any errors') // .option('--force', 'Force the exit value to always be 0') - .addOption(new CommanderOption('--legacy', 'Legacy output').hideHelp()) - .addOption(new CommanderOption('--local ', 'Deprecated -- Use: --locale').hideHelp()) + .addOption(crOpt('--legacy', 'Legacy output').hideHelp()) + .addOption(crOpt('--local ', 'Deprecated -- Use: --locale').hideHelp()) .option('--cache', 'Use cache to only check changed files.') .option('--no-cache', 'Do not use cache.') .option('--cache-reset', 'Reset the cache file.') .addOption( - new CommanderOption('--cache-strategy ', 'Strategy to use for detecting changed files.').choices([ + crOpt('--cache-strategy ', 'Strategy to use for detecting changed files.').choices([ 'metadata', 'content', ]), @@ -145,38 +151,33 @@ export function commandLint(prog: Command): Command { .option('--no-validate-directives', 'Do not validate in-document CSpell directives.') .option('--no-color', 'Turn off color.') .option('--color', 'Force color.') - .addOption( - new CommanderOption( - '--default-configuration', - 'Load the default configuration and dictionaries.', - ).hideHelp(), - ) - .addOption( - new CommanderOption( - '--no-default-configuration', - 'Do not load the default configuration and dictionaries.', - ), - ) + .addOption(crOpt('--default-configuration', 'Load the default configuration and dictionaries.').hideHelp()) + .addOption(crOpt('--no-default-configuration', 'Do not load the default configuration and dictionaries.')) .option('--debug', 'Output information useful for debugging cspell.json files.') .option('--reporter ', 'Specify one or more reporters to use.', collect) .addOption( - new CommanderOption('--skip-validation', 'Collect and process documents, but do not spell check.') + crOpt('--skip-validation', 'Collect and process documents, but do not spell check.') .implies({ cache: false }) .hideHelp(), ) - .addOption(new CommanderOption('--issues-summary-report', 'Output a summary of issues found.').hideHelp()) + .addOption(crOpt('--issues-summary-report', 'Output a summary of issues found.').hideHelp()) + // Planned options + // .option('--dictionary ', 'Enable a dictionary by name.', collect) + // .option('--no-dictionary ', 'Disable a dictionary by name.', collect) + // .option('--import', 'Import a configuration file.', collect) .usage(usage) .addHelpText('after', advanced) .arguments('[globs...]') .action(async (fileGlobs: string[], options: LinterCliOptions) => { + // console.error('lint: %o', { fileGlobs, options }); const useExitCode = options.exitCode ?? true; if (options.skipValidation) { options.cache = false; } App.parseApplicationFeatureFlags(options.flag); - const { mustFindFiles, fileList } = options; + const { mustFindFiles, fileList, files, file } = options; const result = await App.lint(fileGlobs, options); - if (!fileGlobs.length && !result.files && !result.errors && !fileList) { + if (!fileGlobs.length && !result.files && !result.errors && !fileList && !files?.length && !file?.length) { spellCheckCommand.outputHelp(); throw new CheckFailed('outputHelp', 1); } @@ -192,3 +193,27 @@ export function commandLint(prog: Command): Command { return spellCheckCommand; } + +/** + * Create Option - a helper function to create a commander option. + * @param name - the name of the option + * @param description - the description of the option + * @param parseArg - optional function to parse the argument + * @param defaultValue - optional default value + * @returns CommanderOption + */ +function crOpt( + name: string, + description: string, + parseArg?: (value: string, previous: T) => T, + defaultValue?: T, +): CommanderOption { + const option = new CommanderOption(name, description); + if (parseArg) { + option.argParser(parseArg); + } + if (defaultValue !== undefined) { + option.default(defaultValue); + } + return option; +} diff --git a/packages/cspell/src/app/lint/LintRequest.test.ts b/packages/cspell/src/app/lint/LintRequest.test.ts new file mode 100644 index 00000000000..33e5de3c2b8 --- /dev/null +++ b/packages/cspell/src/app/lint/LintRequest.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'vitest'; + +import { getReporter } from '../cli-reporter.js'; +import { LintRequest } from './LintRequest.js'; + +const oc = expect.objectContaining; + +describe('LintRequest', () => { + test.each` + options | expected + ${{}} | ${oc({ fileGlobs: [], files: undefined })} + ${{ files: ['one\ntwo'] }} | ${oc({ fileGlobs: [], files: ['one', 'two'] })} + ${{ file: ['one', 'two'], files: ['two'] }} | ${oc({ fileGlobs: [], files: ['one', 'two'] })} + ${{ file: ['one', 'two'] }} | ${oc({ fileGlobs: [], files: ['one', 'two'] })} + ${{ showContext: undefined }} | ${oc({ showContext: 0 })} + ${{ showContext: true }} | ${oc({ showContext: 20 })} + ${{ showContext: 3 }} | ${oc({ showContext: 3 })} + `('create LintRequest $options', ({ options, expected }) => { + const fileGlobs: string[] = []; + const request = new LintRequest(fileGlobs, options, getReporter({ fileGlobs })); + expect(request).toEqual(expected); + }); +}); diff --git a/packages/cspell/src/app/lint/LintRequest.ts b/packages/cspell/src/app/lint/LintRequest.ts index b08699458d1..42753c41c25 100644 --- a/packages/cspell/src/app/lint/LintRequest.ts +++ b/packages/cspell/src/app/lint/LintRequest.ts @@ -10,6 +10,7 @@ const defaultContextRange = 20; interface Deprecated { fileLists?: LinterOptions['fileList']; + local?: LinterOptions['locale']; } export class LintRequest { @@ -22,6 +23,7 @@ export class LintRequest { readonly showContext: number; readonly enableGlobDot: boolean | undefined; readonly fileLists: string[]; + readonly files: string[] | undefined; constructor( readonly fileGlobs: string[], @@ -31,12 +33,25 @@ export class LintRequest { this.root = path.resolve(options.root || process.cwd()); this.configFile = options.config; this.excludes = calcExcludeGlobInfo(this.root, options.exclude); - this.locale = options.locale || ''; + this.locale = options.locale ?? options.local ?? ''; this.enableGlobDot = options.dot; // this.uniqueFilter = options.unique ? util.uniqueFilterFnGenerator((issue: Issue) => issue.text) : () => true; this.uniqueFilter = () => true; this.showContext = options.showContext === true ? defaultContextRange : options.showContext ? options.showContext : 0; this.fileLists = (options.fileList ?? options.fileLists) || []; + this.files = mergeFiles(options.file, options.files); } } + +function mergeFiles(a: string[] | undefined, b: string[] | undefined): string[] | undefined { + const files = merge(a, b); + if (!files) return undefined; + return [...new Set(files.flatMap((a) => a.split('\n').map((a) => a.trim())).filter((a) => !!a))]; +} + +function merge(a: T[] | undefined, b: T[] | undefined): T[] | undefined { + if (!a) return b; + if (!b) return a; + return [...a, ...b]; +} diff --git a/packages/cspell/src/app/lint/lint.test.ts b/packages/cspell/src/app/lint/lint.test.ts index 4dd1b0671db..9c6909fa44c 100644 --- a/packages/cspell/src/app/lint/lint.test.ts +++ b/packages/cspell/src/app/lint/lint.test.ts @@ -35,6 +35,8 @@ describe('Linter Validation Tests', () => { expect(rWithFiles.files).toBe(1); }); + const optionsRootCSpellJson = { root, config: j(root, 'cspell.json') }; + // cspell:ignore Tufte checkedd test.each` files | options | expectedRunResult | expectedReport @@ -62,6 +64,12 @@ describe('Linter Validation Tests', () => { ${[]} | ${{ root, config: j(root, 'cspell.json'), fileLists: [filesToCheckWithMissing], mustFindFiles: true }} | ${oc({ errors: 1, files: 3 })} | ${oc({ errorCount: 1, errors: [expect.anything()], issues: [] })} ${["'**'"]} | ${{ root, config: j(root, 'cspell.json'), mustFindFiles: true }} | ${oc({ errors: 0, files: 0 })} | ${oc({ errorCount: 1, errors: [expect.any(CheckFailed)], issues: [] })} ${['**']} | ${{ root: j(configSamples, 'yaml-regexp') }} | ${oc({ errors: 0, files: 2 })} | ${oc({ errorCount: 0, errors: [], issues: [oc({ text: 'checkedd' })] })} + ${[]} | ${{ ...optionsRootCSpellJson, files: ['README.md', 'LICENSE'], dot: true }} | ${oc({ errors: 0, files: 2 })} | ${oc({ errorCount: 0, errors: [], issues: [] })} + ${['*.md']} | ${{ ...optionsRootCSpellJson, files: ['README.md', 'LICENSE'], dot: true }} | ${oc({ errors: 0, files: 1 })} | ${oc({ errorCount: 0, errors: [], issues: [] })} + ${[]} | ${{ ...optionsRootCSpellJson, files: ['README.md', 'missing.txt'], dot: true, mustFindFiles: false }} | ${oc({ errors: 0, files: 1 })} | ${oc({ errorCount: 0, errors: [], issues: [] })} + ${[]} | ${{ ...optionsRootCSpellJson, files: ['README.md', 'missing.txt'], dot: true, mustFindFiles: true }} | ${oc({ errors: 1, files: 2 })} | ${oc({ errorCount: 1, errors: [expect.anything()], issues: [] })} + ${[]} | ${{ ...optionsRootCSpellJson, files: ['../../README.md'], dot: true }} | ${oc({ errors: 0, files: 1 })} | ${oc({ errorCount: 0, errors: [], issues: [] })} + ${[]} | ${{ ...optionsRootCSpellJson, files: ['../../resources/patreon.png' /* skip binary */], dot: true }} | ${oc({ errors: 0, files: 0 })} | ${oc({ errorCount: 0, errors: [], issues: [] })} `('runLint $files $options', async ({ files, options, expectedRunResult, expectedReport }) => { const reporter = new InMemoryReporter(); const runResult = await runLint(new LintRequest(files, options, reporter)); diff --git a/packages/cspell/src/app/lint/lint.ts b/packages/cspell/src/app/lint/lint.ts index bed31a8a00e..f6fbf6a093b 100644 --- a/packages/cspell/src/app/lint/lint.ts +++ b/packages/cspell/src/app/lint/lint.ts @@ -1,4 +1,4 @@ -import { isAsyncIterable, operators, opFilter, pipeAsync, pipeSync } from '@cspell/cspell-pipe'; +import { isAsyncIterable, operators, opFilter, pipeAsync } from '@cspell/cspell-pipe'; import { opMap, pipe } from '@cspell/cspell-pipe/sync'; import type { CSpellSettings, @@ -41,10 +41,12 @@ import { filenameToUri, findFiles, isBinaryFile, + isFile, isNotDir, readConfig, readFileInfo, readFileListFiles, + resolveFilename, } from '../util/fileHelper.js'; import type { GlobOptions } from '../util/glob.js'; import { @@ -428,7 +430,7 @@ export async function runLint(cfg: LintRequest): Promise { const globInfo = await determineGlobs(configInfo, cfg); const { fileGlobs, excludeGlobs } = globInfo; const hasFileLists = !!cfg.fileLists.length; - if (!fileGlobs.length && !hasFileLists) { + if (!fileGlobs.length && !hasFileLists && !cfg.files?.length) { // Nothing to do. return runResult(); } @@ -511,7 +513,8 @@ async function determineGlobs(configInfo: ConfigInfo, cfg: LintRequest): Promise const gitIgnore = useGitignore ? await generateGitIgnore(gitignoreRoots) : undefined; const cliGlobs: string[] = cfg.fileGlobs; - const allGlobs: Glob[] = cliGlobs.length ? cliGlobs : configInfo.config.files || []; + const allGlobs: Glob[] = + (cliGlobs.length && cliGlobs) || (cfg.options.filterFiles !== false && configInfo.config.files) || []; const combinedGlobs = await normalizeFileOrGlobsToRoot(allGlobs, cfg.root); const cliExcludeGlobs = extractPatterns(cfg.excludes).map((p) => p.glob as Glob); const normalizedExcludes = normalizeGlobsToRoot(cliExcludeGlobs, cfg.root, true); @@ -551,14 +554,19 @@ async function determineFilesToCheck( globOptions.dot = enableGlobDot; } - const filterFiles = opFilter(filterFilesFn(globMatcher)); - const foundFiles = await (hasFileLists - ? useFileLists(fileLists, allGlobs, root, enableGlobDot) - : findFiles(fileGlobs, globOptions)); + const opFilterExcludedFiles = opFilter(filterOutExcludedFilesFn(globMatcher)); + const includeFilter = createIncludeFileFilterFn(allGlobs, root, enableGlobDot); + const rawCliFiles = cfg.files?.map((file) => resolveFilename(file, root)).filter(includeFilter); + const cliFiles = cfg.options.mustFindFiles + ? rawCliFiles + : rawCliFiles && pipeAsync(rawCliFiles, opFilterAsync(isFile)); + const foundFiles = hasFileLists + ? concatAsyncIterables(cliFiles, await useFileLists(fileLists, includeFilter)) + : cliFiles || (await findFiles(fileGlobs, globOptions)); const filtered = gitIgnore ? await gitIgnore.filterOutIgnored(foundFiles) : foundFiles; const files = isAsyncIterable(filtered) - ? pipeAsync(filtered, filterFiles) - : [...pipeSync(filtered, filterFiles)]; + ? pipeAsync(filtered, opFilterExcludedFiles) + : [...pipe(filtered, opFilterExcludedFiles)]; return files; } @@ -581,7 +589,7 @@ async function determineFilesToCheck( return r.matched; } - function filterFilesFn(globMatcherExclude: GlobMatcher): (file: string) => boolean { + function filterOutExcludedFilesFn(globMatcherExclude: GlobMatcher): (file: string) => boolean { const patterns = globMatcherExclude.patterns; const excludeInfo = patterns .map(extractGlobSource) @@ -680,18 +688,31 @@ async function generateGitIgnore(roots: string | string[] | undefined) { async function useFileLists( fileListFiles: string[], - includeGlobPatterns: Glob[], - root: string, - dot: boolean | undefined, + filterFiles: (file: string) => boolean, ): Promise> { - includeGlobPatterns = includeGlobPatterns.length ? includeGlobPatterns : ['**']; + const files = readFileListFiles(fileListFiles); + return pipeAsync(files, opFilter(filterFiles), opFilterAsync(isNotDir)); +} + +function createIncludeFileFilterFn(includeGlobPatterns: Glob[] | undefined, root: string, dot: boolean | undefined) { + if (!includeGlobPatterns?.length) { + return () => true; + } + const patterns = includeGlobPatterns.map((g) => (g === '.' ? '/**' : g)); const options: GlobMatchOptions = { root, mode: 'include' }; if (dot !== undefined) { options.dot = dot; } - const globMatcher = new GlobMatcher(includeGlobPatterns, options); + const globMatcher = new GlobMatcher(patterns, options); - const filterFiles = (file: string) => globMatcher.match(file); - const files = readFileListFiles(fileListFiles); - return pipeAsync(files, opFilter(filterFiles), opFilterAsync(isNotDir)); + return (file: string) => globMatcher.match(file); +} + +async function* concatAsyncIterables( + ...iterables: (AsyncIterable | Iterable | undefined)[] +): AsyncIterable { + for (const iter of iterables) { + if (!iter) continue; + yield* iter; + } } diff --git a/packages/cspell/src/app/options.ts b/packages/cspell/src/app/options.ts index 65e4a6131f4..7cc1d72e637 100644 --- a/packages/cspell/src/app/options.ts +++ b/packages/cspell/src/app/options.ts @@ -51,13 +51,33 @@ export interface LinterOptions extends BaseOptions, Omit