diff --git a/cspell.schema.json b/cspell.schema.json index 144c4d2ce554..75fe28aba556 100644 --- a/cspell.schema.json +++ b/cspell.schema.json @@ -581,6 +581,11 @@ "description": "Optional name of configuration", "type": "string" }, + "noConfigSearch": { + "default": false, + "description": "Prevents searching for local configuration when checking individual documents.", + "type": "boolean" + }, "numSuggestions": { "default": 10, "description": "Number of suggestions to make", diff --git a/packages/cspell-lib/cSpell.json b/packages/cspell-lib/cspell.config.json similarity index 67% rename from packages/cspell-lib/cSpell.json rename to packages/cspell-lib/cspell.config.json index 27bdca8d9ab7..05ff03df12a5 100644 --- a/packages/cspell-lib/cSpell.json +++ b/packages/cspell-lib/cspell.config.json @@ -1,12 +1,9 @@ { - "version": "0.1", - "id": "cspell-project-config", - "name": "cspell Project Config", + "version": "0.2", + "id": "cspell-package-config", + "name": "cspell Package Config", "language": "en", - "words": [ - "gensequence", - "xregexp" - ], + "words": ["gensequence"], "maxNumberOfProblems": 100, "ignorePaths": [ "dictionaries/**", @@ -23,7 +20,5 @@ "allowCompoundWords": false, "dictionaryDefinitions": [], "ignoreWords": [], - "import": [ - "../../cspell.json" - ] + "import": ["../../cspell.json"] } diff --git a/packages/cspell-lib/docs/configuration.md b/packages/cspell-lib/docs/configuration.md new file mode 100644 index 000000000000..ee7a5b00bdd8 --- /dev/null +++ b/packages/cspell-lib/docs/configuration.md @@ -0,0 +1,108 @@ +# cspell Configuration + +## Supported Configuration Files + +The spell checker will look for the following configuration files. + +- `.cspell.json` +- `cspell.json` +- `.cSpell.json` +- `cSpell.json` +- `.vscode/cspell.json` +- `.vscode/cSpell.json` +- `.vscode/.cspell.json` +- `cspell.config.js` +- `cspell.config.cjs` +- `cspell.config.json` +- `cspell.config.yaml` +- `cspell.config.yml` +- `cspell.yaml` +- `cspell.yml` + +## Configuration Search + +While spell checking files, the spell checker will look for the nearest configuration file in the directory hierarchy. +This allows for folder level configurations to be honored. +It is possible to stop this behavior by adding adding `"noConfigSearch": true` to the top level configuration. + +A Monorepo Example: + +``` +repo-root +├── cspell.config.json +├─┬ packages +│ ├─┬ package-A +│ │ ├── cspell.json +│ │ ├── README.md +│ │ └── CHANGELOG.md +│ ├─┬ package-B +│ │ ├── README.md +│ │ └── CHANGELOG.md +│ ├─┬ package-C +│ │ ├── cspell.yaml +│ │ ├── README.md +│ │ └── CHANGELOG.md +``` + +The following command will search the repo start at `repo-root` looking for `.md` files. + +``` +repo-root % cspell "**/*.md" +``` + +The root configuration is used to determine which files to check. Files matching the globs in `ignorePaths` will not be checked. When a file is found, the directory hierarchy is searched looking for the nearest configuration file. + +For example: + +| File | Config Used | +| ------------------------------ | -------------------------------- | +| `packages/package-A/README.md` | `packages/package-A/cspell.json` | +| `packages/package-A/CONFIG.md` | `packages/package-A/cspell.json` | +| `packages/package-B/README.md` | `cspell.config.json` | +| `packages/package-C/README.md` | `packages/package-C/cspell.yaml` | + +## Example Configurations: + +### Example `cspell.config.js` + +```javascript +'use strict'; + +/** @type { import("@cspell/cspell-types").CSpellUserSettings } */ +const cspell = { + description: 'Company cspell settings', + languageSettings: [ + { + languageId: 'cpp', + allowCompoundWords: false, + patterns: [ + { + // define a pattern to ignore #includes + name: 'pound-includes', + pattern: /^\s*#include.*/g, + }, + ], + ignoreRegExpList: ['pound-includes'], + }, + ], + dictionaryDefinitions: [ + { + name: 'custom-words', + path: './custom-words.txt', + }, + ], + dictionaries: ['custom-words'], +}; + +module.exports = cspell; +``` + +### Example import from `cspell.json` + +Import a `cspell.config.js` file from a `cspell.json` file. + +```javascript +{ + "import": ["../cspell.config.js"] +} +``` diff --git a/packages/cspell-lib/samples/js-config/cspell.config.js b/packages/cspell-lib/samples/js-config/cspell.config.js index 77071dd53c5a..22a975fa9d4d 100644 --- a/packages/cspell-lib/samples/js-config/cspell.config.js +++ b/packages/cspell-lib/samples/js-config/cspell.config.js @@ -3,6 +3,26 @@ /** @type { import("@cspell/cspell-types").CSpellUserSettings } */ const cspell = { description: 'cspell.config.js file in samples/js-config', + languageSettings: [ + { + languageId: 'cpp', + allowCompoundWords: false, + patterns: [ + { + name: 'pound-includes', + pattern: /^\s*#include.*/g, + }, + ], + ignoreRegExpList: ['pound-includes'], + }, + ], + dictionaryDefinitions: [ + { + name: 'custom-words', + path: './custom-words.txt', + }, + ], + dictionaries: ['custom-words'], }; module.exports = cspell; diff --git a/packages/cspell-lib/samples/js-config/custom-words.txt b/packages/cspell-lib/samples/js-config/custom-words.txt new file mode 100644 index 000000000000..c1ddc83cab6c --- /dev/null +++ b/packages/cspell-lib/samples/js-config/custom-words.txt @@ -0,0 +1,4 @@ +here +are +some +words diff --git a/packages/cspell-lib/src/Settings/CSpellSettingsServer.test.ts b/packages/cspell-lib/src/Settings/CSpellSettingsServer.test.ts index 503f5c5ccd32..f12a1f7d5583 100644 --- a/packages/cspell-lib/src/Settings/CSpellSettingsServer.test.ts +++ b/packages/cspell-lib/src/Settings/CSpellSettingsServer.test.ts @@ -175,7 +175,7 @@ describe('Validate CSpellSettingsServer', () => { }); test('tests loading a missing cSpell.json file', () => { - const filename = path.join(__dirname, '..', '..', 'cSpell.json'); + const filename = path.join(__dirname, '..', '..', 'cspell.config.json'); const settings = readSettings(filename); expect(settings.__importRef?.filename).toBe(path.resolve(filename)); expect(settings.__importRef?.error).toBeUndefined(); diff --git a/packages/cspell-lib/src/Settings/CSpellSettingsServer.ts b/packages/cspell-lib/src/Settings/CSpellSettingsServer.ts index 53028e42ce5e..4e1d037c8d9a 100644 --- a/packages/cspell-lib/src/Settings/CSpellSettingsServer.ts +++ b/packages/cspell-lib/src/Settings/CSpellSettingsServer.ts @@ -16,6 +16,7 @@ import { resolveFile } from '../util/resolveFile'; import { getRawGlobalSettings } from './GlobalSettings'; import { cosmiconfig, cosmiconfigSync, OptionsSync as CosmicOptionsSync, Options as CosmicOptions } from 'cosmiconfig'; import { GlobMatcher } from 'cspell-glob'; +import { ImportError } from './ImportError'; const currentSettingsFileVersion = '0.1'; @@ -543,12 +544,6 @@ export function extractImportErrors(settings: CSpellSettings): ImportFileRefWith return !imports ? [] : [...imports.values()].filter(isImportFileRefWithError); } -class ImportError extends Error { - constructor(msg: string, readonly cause?: Error) { - super(msg); - } -} - function resolveGlobRoot(settings: CSpellSettings, pathToSettingsFile: string): string { const envGlobRoot = process.env[ENV_CSPELL_GLOB_ROOT]; const defaultGlobRoot = envGlobRoot ?? '${cwd}'; diff --git a/packages/cspell-lib/src/Settings/ImportError.ts b/packages/cspell-lib/src/Settings/ImportError.ts new file mode 100644 index 000000000000..22133e0f2792 --- /dev/null +++ b/packages/cspell-lib/src/Settings/ImportError.ts @@ -0,0 +1,5 @@ +export class ImportError extends Error { + constructor(msg: string, readonly cause?: Error) { + super(msg); + } +} diff --git a/packages/cspell-lib/src/index.ts b/packages/cspell-lib/src/index.ts index 13f1bdec07c3..8a8ce54bb939 100644 --- a/packages/cspell-lib/src/index.ts +++ b/packages/cspell-lib/src/index.ts @@ -5,9 +5,9 @@ export { TextOffset, TextDocumentOffset } from './util/text'; export { checkText, CheckTextInfo, - TextInfoItem, - IncludeExcludeOptions, IncludeExcludeFlag, + IncludeExcludeOptions, + TextInfoItem, validateText, } from './validator'; export { defaultFileName as defaultSettingsFilename } from './Settings'; @@ -15,13 +15,20 @@ export { CompoundWordsMethod, createSpellingDictionary, getDictionary, + refreshDictionaryCache, SpellingDictionary, SuggestionCollector, SuggestionResult, - refreshDictionaryCache, } from './SpellingDictionary'; export { combineTextAndLanguageSettings } from './Settings/TextDocumentSettings'; export { combineTextAndLanguageSettings as constructSettingsForText } from './Settings/TextDocumentSettings'; +export { + Document, + spellCheckDocument, + spellCheckFile, + SpellCheckFileOptions, + SpellCheckFileResult, +} from './spellCheckFile'; import * as Text from './util/text'; import * as Link from './Settings/link'; diff --git a/packages/cspell-lib/src/spellCheckFile.test.ts b/packages/cspell-lib/src/spellCheckFile.test.ts index 9a52b1dc72fa..b7c44ef03f69 100644 --- a/packages/cspell-lib/src/spellCheckFile.test.ts +++ b/packages/cspell-lib/src/spellCheckFile.test.ts @@ -1,29 +1,200 @@ import { CSpellUserSettings } from '@cspell/cspell-types'; -import * as path from 'path'; -import { spellCheckFile, SpellCheckFileOptions } from './spellCheckFile'; +import * as Path from 'path'; +import { posix } from 'path'; +import { URI } from 'vscode-uri'; +import { ImportError } from './Settings/ImportError'; +import { + Document, + spellCheckDocument, + spellCheckFile, + SpellCheckFileOptions, + SpellCheckFileResult, +} from './spellCheckFile'; -const samples = path.resolve(__dirname, '../samples'); +const samples = Path.resolve(__dirname, '../samples'); +const isWindows = process.platform === 'win32'; +const hasDriveLetter = /^[A-Z]:\\/; -describe('Validate Spell Check Files', () => { +describe('Validate Spell Checking Files', () => { interface TestSpellCheckFile { filename: string; settings: CSpellUserSettings; options: SpellCheckFileOptions; + expected: Partial; } + function oc(t: T): T { + return expect.objectContaining(t); + } + + function err(msg: string): Error { + return new ImportError(msg); + } + + function eFailed(file: string): Error { + return err(`Failed to find config file at: "${s(file)}"`); + } + + function errNoEnt(file: string): Error { + const message = `ENOENT: no such file or directory, open '${file}'`; + return expect.objectContaining(new Error(message)); + } + + test.each` + filename | settings | options | expected + ${'src/not_found.c'} | ${{}} | ${{}} | ${{ checked: false, errors: [errNoEnt('src/not_found.c')] }} + ${'src/sample.c'} | ${{}} | ${{}} | ${{ checked: true, issues: [], localConfigFilepath: s('.cspell.json'), errors: undefined }} + ${'src/sample.c'} | ${{}} | ${{ noConfigSearch: true }} | ${{ checked: true, localConfigFilepath: undefined, errors: undefined }} + ${'src/sample.c'} | ${{ noConfigSearch: true }} | ${{}} | ${{ checked: true, localConfigFilepath: undefined, errors: undefined }} + ${'src/sample.c'} | ${{}} | ${{ configFile: s('../cspell.config.json') }} | ${{ checked: true, localConfigFilepath: s('../cspell.config.json'), errors: undefined }} + ${'src/sample.c'} | ${{ noConfigSearch: true }} | ${{ configFile: s('../cspell.config.json') }} | ${{ checked: true, localConfigFilepath: s('../cspell.config.json'), errors: undefined }} + ${'src/sample.c'} | ${{ noConfigSearch: true }} | ${{ noConfigSearch: false }} | ${{ checked: true, localConfigFilepath: s('.cspell.json'), errors: undefined }} + ${'src/sample.c'} | ${{}} | ${{}} | ${{ document: expect.anything(), errors: undefined }} + ${'src/sample.c'} | ${{}} | ${{ configFile: s('../cSpell.json') }} | ${{ checked: false, localConfigFilepath: s('../cSpell.json'), errors: [eFailed(s('../cSpell.json'))] }} + ${'src/not_found.c'} | ${{}} | ${{}} | ${{ checked: false, errors: [errNoEnt('src/not_found.c')] }} + ${__filename} | ${{}} | ${{}} | ${{ checked: true, localConfigFilepath: s('../cspell.config.json'), errors: undefined }} + `( + 'spellCheckFile $filename $settings $options', + async ({ filename, settings, options, expected }: TestSpellCheckFile) => { + const r = await spellCheckFile(s(filename), options, settings); + expect(r).toEqual(oc(expected)); + } + ); +}); + +describe('Validate Spell Checking Documents', () => { + interface TestSpellCheckFile { + uri: string; + text: string | undefined; + settings: CSpellUserSettings; + options: SpellCheckFileOptions; + expected: Partial; + } + + function oc(t: T): T { + return expect.objectContaining(t); + } + + function err(msg: string): Error { + return new ImportError(msg); + } + + function eFailed(file: string): Error { + return err(`Failed to find config file at: "${s(file)}"`); + } + + function errNoEnt(file: string): Error { + const message = `ENOENT: no such file or directory, open '${file}'`; + return expect.objectContaining(new Error(message)); + } + + function f(file: string): string { + return URI.file(s(file)).toString(); + } + + function d(uri: string, text?: string): Document { + return text === undefined ? { uri } : { uri, text }; + } + + type ArrayType = T extends (infer R)[] ? R : never; + + type Issue = ArrayType; + + function i(...words: string[]): Partial[] { + return words.map((text) => ({ text })).map((i) => expect.objectContaining(i)); + } + + // cspell:ignore texxt test.each` - filename | settings | options - ${s('src/sample.c')} | ${{}} | ${{}} - `('spellCheckFile $file $settings', async ({ filename, settings, options }: TestSpellCheckFile) => { - const r = await spellCheckFile(filename, options, settings); - expect(r).toEqual( - expect.objectContaining({ - issues: [], - }) - ); + uri | text | settings | options | expected + ${f('src/not_found.c')} | ${''} | ${{}} | ${{}} | ${{ checked: false, errors: [errNoEnt('src/not_found.c')] }} + ${f('src/sample.c')} | ${''} | ${{}} | ${{}} | ${{ checked: true, issues: [], localConfigFilepath: s('.cspell.json'), errors: undefined }} + ${f('src/sample.c')} | ${''} | ${{}} | ${{ noConfigSearch: true }} | ${{ checked: true, localConfigFilepath: undefined, errors: undefined }} + ${f('src/sample.c')} | ${''} | ${{ noConfigSearch: true }} | ${{}} | ${{ checked: true, localConfigFilepath: undefined, errors: undefined }} + ${f('src/sample.c')} | ${''} | ${{}} | ${{ configFile: s('../cspell.config.json') }} | ${{ checked: true, localConfigFilepath: s('../cspell.config.json'), errors: undefined }} + ${f('src/sample.c')} | ${''} | ${{ noConfigSearch: true }} | ${{ configFile: s('../cspell.config.json') }} | ${{ checked: true, localConfigFilepath: s('../cspell.config.json'), errors: undefined }} + ${f('src/sample.c')} | ${''} | ${{ noConfigSearch: true }} | ${{ noConfigSearch: false }} | ${{ checked: true, localConfigFilepath: s('.cspell.json'), errors: undefined }} + ${f('src/sample.c')} | ${''} | ${{}} | ${{}} | ${{ document: oc(d(f('src/sample.c'))), errors: undefined }} + ${f('src/sample.c')} | ${''} | ${{}} | ${{ configFile: s('../cSpell.json') }} | ${{ checked: false, localConfigFilepath: s('../cSpell.json'), errors: [eFailed(s('../cSpell.json'))] }} + ${f('src/not_found.c')} | ${''} | ${{}} | ${{}} | ${{ checked: false, errors: [errNoEnt('src/not_found.c')] }} + ${f(__filename)} | ${''} | ${{}} | ${{}} | ${{ checked: true, localConfigFilepath: s('../cspell.config.json'), errors: undefined }} + ${'stdin:///'} | ${'some text'} | ${{ languageId: 'plaintext' }} | ${{}} | ${{ checked: true, issues: [], localConfigFilepath: undefined, errors: undefined }} + ${'stdin:///'} | ${'some text'} | ${{ languageId: 'plaintext' }} | ${{}} | ${{ document: oc(d('stdin:///')) }} + ${'stdin:///'} | ${'some texxt'} | ${{ languageId: 'plaintext' }} | ${{}} | ${{ checked: true, issues: i('texxt'), localConfigFilepath: undefined, errors: undefined }} + ${'stdin:///'} | ${''} | ${{ languageId: 'plaintext' }} | ${{}} | ${{ checked: false, issues: [], localConfigFilepath: undefined, errors: [err('Unsupported schema: "stdin", open "stdin:/"')] }} + `( + 'spellCheckFile $uri $settings $options', + async ({ uri, text, settings, options, expected }: TestSpellCheckFile) => { + const r = await spellCheckDocument(d(uri, text || undefined), options, settings); + expect(r).toEqual(oc(expected)); + } + ); +}); + +describe('Validate Uri assumptions', () => { + interface UriComponents { + scheme: string; + authority: string; + path: string; + query: string; + fragment: string; + fsPath?: string; + } + + type PartialUri = Partial; + + function u(filename: string): string { + return URI.file(fixDriveLetter(filename)).toString(); + } + + function schema(scheme: string): PartialUri { + return { scheme }; + } + + function authority(authority: string): PartialUri { + return { authority }; + } + + function path(path: string): PartialUri { + return { path }; + } + + function m(...parts: PartialUri[]): PartialUri { + const u: PartialUri = {}; + for (const p of parts) { + Object.assign(u, p); + } + return u; + } + + function normalizePath(p: string): string { + return posix.normalize('/' + fixDriveLetter(p).replace(/\\/g, '/')); + } + + interface UriTestCase { + uri: string; + expected: PartialUri; + } + + test.each` + uri | expected | comment + ${u(__filename)} | ${m(schema('file'), path(normalizePath(__filename)))} | ${''} + ${'stdin:///'} | ${m(schema('stdin'), path('/'), authority(''))} | ${''} + ${'https://github.com/streetsidesoftware/cspell/issues'} | ${m(schema('https'), authority('github.com'), path('/streetsidesoftware/cspell/issues'))} | ${''} + ${'C:\\home\\project\\file.js'} | ${m(schema('C'), path('\\home\\project\\file.js'))} | ${'Windows path by "accident"'} + `('URI assumptions uri: "$uri" $comment -- $expected', ({ uri, expected }: UriTestCase) => { + const u = URI.parse(uri); + expect(u).toEqual(expect.objectContaining(expected)); }); }); +function fixDriveLetter(p: string): string { + if (!hasDriveLetter.test(p)) return p; + return p[0].toLowerCase() + p.slice(1); +} + function s(file: string) { - return path.resolve(samples, file); + const p = Path.resolve(samples, file); + // Force lowercase drive letter if windows + return isWindows ? fixDriveLetter(p) : p; } diff --git a/packages/cspell-lib/src/spellCheckFile.ts b/packages/cspell-lib/src/spellCheckFile.ts index fb00eb3cc311..9ff6f0c947b4 100644 --- a/packages/cspell-lib/src/spellCheckFile.ts +++ b/packages/cspell-lib/src/spellCheckFile.ts @@ -6,6 +6,7 @@ import { calcOverrideSettings, getDefaultSettings, getGlobalSettings, + loadConfig, mergeSettings, searchForConfig, } from './Settings'; @@ -14,31 +15,134 @@ import * as path from 'path'; import { combineTextAndLanguageSettings } from './Settings/TextDocumentSettings'; export interface SpellCheckFileOptions { + /** + * Optional path to a configuration file. + * If given, it will be used instead of searching for a configuration file. + */ configFile?: string; + /** + * File encoding + * @defaultValue 'utf-8' + */ encoding?: BufferEncoding; + /** + * Prevents searching for local configuration files + * By default the spell checker looks for configuration files + * starting at the location of given filename. + * If `configFile` is defined it will still be loaded instead of searching. + * `false` will override the value in `settings.noConfigSearch`. + * @defaultValue undefined + */ + noConfigSearch?: boolean; } export interface SpellCheckFileResult { - document: Document; + document: Document | DocumentWithText; settingsUsed: CSpellSettingsWithSourceTrace; + localConfigFilepath: string | undefined; options: SpellCheckFileOptions; issues: ValidationIssue[]; checked: boolean; + errors: Error[] | undefined; } const defaultEncoding: BufferEncoding = 'utf8'; -interface Document { - uri: URI; +export type UriString = string; + +export interface DocumentWithText extends Document { text: string; } +export interface Document { + uri: UriString; + text?: string; + languageId?: string; +} -export async function spellCheckFile( +/** + * Spell Check a file + * @param file - absolute path to file to read and check. + * @param options - options to control checking + * @param settings - default settings to use. + */ +export function spellCheckFile( file: string, options: SpellCheckFileOptions, settings: CSpellUserSettings ): Promise { - const [localConfig, document] = await Promise.all([searchForConfig(file), readDocument(file, options.encoding)]); + const doc: Document = { + uri: URI.file(file).toString(), + }; + return spellCheckDocument(doc, options, settings); +} + +/** + * Spell Check a Document. + * @param document - document to be checked. If `document.text` is `undefined` the file will be loaded + * @param options - options to control checking + * @param settings - default settings to use. + */ +export async function spellCheckDocument( + document: Document | DocumentWithText, + options: SpellCheckFileOptions, + settings: CSpellUserSettings +): Promise { + try { + return spellCheckFullDocument(await resolveDocument(document), options, settings); + } catch (e) { + return { + document, + options, + settingsUsed: settings, + localConfigFilepath: undefined, + issues: [], + checked: false, + errors: [e], + }; + } +} + +async function spellCheckFullDocument( + document: DocumentWithText, + options: SpellCheckFileOptions, + settings: CSpellUserSettings +): Promise { + const errors: Error[] = []; + function addPossibleError(error: Error | undefined) { + if (!error) return; + errors.push(error); + } + + function catchError

(p: Promise

): Promise

{ + return p.catch((error) => { + addPossibleError(error); + return undefined; + }); + } + + const useSearchForConfig = + (!options.noConfigSearch && !settings.noConfigSearch) || options.noConfigSearch === false; + const pLocalConfig = options.configFile + ? catchError(loadConfig(options.configFile)) + : useSearchForConfig + ? catchError(searchForDocumentConfig(document, settings)) + : undefined; + const localConfig = await pLocalConfig; + + addPossibleError(localConfig?.__importRef?.error); + + if (errors.length) { + return { + document, + options, + settingsUsed: localConfig || settings, + localConfigFilepath: localConfig?.__importRef?.filename, + issues: [], + checked: false, + errors, + }; + } + const config = localConfig ? mergeSettings(settings, localConfig) : settings; const docSettings = determineDocumentSettings(document, config); @@ -50,16 +154,28 @@ export async function spellCheckFile( document, options, settingsUsed: docSettings.settings, + localConfigFilepath: localConfig?.__importRef?.filename, issues, checked: shouldCheck, + errors: undefined, }; return result; } -async function readDocument(filename: string, encoding: BufferEncoding = defaultEncoding): Promise { +function searchForDocumentConfig( + document: DocumentWithText, + defaultConfig: CSpellSettingsWithSourceTrace +): Promise { + const { uri } = document; + const u = URI.parse(uri); + if (u.scheme !== 'file') return Promise.resolve(defaultConfig); + return searchForConfig(u.fsPath).then((s) => s || defaultConfig); +} + +async function readDocument(filename: string, encoding: BufferEncoding = defaultEncoding): Promise { const text = await readFile(filename, encoding); - const uri = URI.file(filename); + const uri = URI.file(filename).toString(); return { uri, @@ -67,17 +183,38 @@ async function readDocument(filename: string, encoding: BufferEncoding = default }; } +function resolveDocument(document: DocumentWithText | Document, encoding?: BufferEncoding): Promise { + if (isDocumentWithText(document)) return Promise.resolve(document); + const uri = URI.parse(document.uri); + if (uri.scheme !== 'file') { + throw new Error(`Unsupported schema: "${uri.scheme}", open "${uri.toString()}"`); + } + return readDocument(uri.fsPath, encoding); +} + +function isDocumentWithText(doc: DocumentWithText | Document): doc is DocumentWithText { + return doc.text !== undefined; +} + interface DetermineDocumentSettingsResult { - document: Document; + document: DocumentWithText; settings: CSpellUserSettings; } -function determineDocumentSettings(document: Document, settings: CSpellUserSettings): DetermineDocumentSettingsResult { - const filename = document.uri.fsPath; +export function determineDocumentSettings( + document: DocumentWithText, + settings: CSpellUserSettings +): DetermineDocumentSettingsResult { + const uri = URI.parse(document.uri); + const filename = uri.fsPath; const ext = path.extname(filename); - const fileOverrideSettings = calcOverrideSettings(settings, path.resolve(filename)); + const fileOverrideSettings = calcOverrideSettings(settings, filename); const fileSettings = mergeSettings(getDefaultSettings(), getGlobalSettings(), fileOverrideSettings); - const languageIds = settings.languageId ? [settings.languageId] : getLanguagesForExt(ext); + const languageIds = document.languageId + ? document.languageId + : settings.languageId + ? settings.languageId + : getLanguagesForExt(ext); const config = combineTextAndLanguageSettings(fileSettings, document.text, languageIds); return { document, diff --git a/packages/cspell-types/cspell.schema.json b/packages/cspell-types/cspell.schema.json index 144c4d2ce554..75fe28aba556 100644 --- a/packages/cspell-types/cspell.schema.json +++ b/packages/cspell-types/cspell.schema.json @@ -581,6 +581,11 @@ "description": "Optional name of configuration", "type": "string" }, + "noConfigSearch": { + "default": false, + "description": "Prevents searching for local configuration when checking individual documents.", + "type": "boolean" + }, "numSuggestions": { "default": 10, "description": "Number of suggestions to make", diff --git a/packages/cspell-types/src/settings/CSpellSettingsDef.ts b/packages/cspell-types/src/settings/CSpellSettingsDef.ts index ec9780f0a5a4..66d05742ba27 100644 --- a/packages/cspell-types/src/settings/CSpellSettingsDef.ts +++ b/packages/cspell-types/src/settings/CSpellSettingsDef.ts @@ -65,6 +65,12 @@ export interface FileSettings extends ExtendableSettings { * Glob patterns are relative to the `globRoot` of the configuration file that defines them. */ ignorePaths?: Glob[]; + + /** + * Prevents searching for local configuration when checking individual documents. + * @default false + */ + noConfigSearch?: boolean; } export interface ExtendableSettings extends Settings { diff --git a/packages/cspell/src/CSpellApplicationConfiguration.ts b/packages/cspell/src/CSpellApplicationConfiguration.ts new file mode 100644 index 000000000000..110e2012ff3f --- /dev/null +++ b/packages/cspell/src/CSpellApplicationConfiguration.ts @@ -0,0 +1,52 @@ +import { GlobSrcInfo, calcExcludeGlobInfo } from './util/glob'; +import * as path from 'path'; +import * as util from './util/util'; +import { IOptions } from './util/IOptions'; +import { + DebugEmitter, + Emitters, + MessageEmitter, + MessageTypes, + ProgressEmitter, + SpellingErrorEmitter, + Issue, +} from './emitters'; +import { CSpellApplicationOptions } from './options'; + +const defaultContextRange = 20; +const nullEmitter: () => void = () => { + /* empty */ +}; + +// cspell:word nocase +const defaultMinimatchOptions: IOptions = { nocase: true }; +export const defaultConfigGlobOptions: IOptions = defaultMinimatchOptions; + +export class CSpellApplicationConfiguration { + readonly info: MessageEmitter; + readonly progress: ProgressEmitter; + readonly debug: DebugEmitter; + readonly logIssue: SpellingErrorEmitter; + readonly uniqueFilter: (issue: Issue) => boolean; + readonly locale: string; + + readonly configFile: string | undefined; + readonly configGlobOptions: IOptions = defaultConfigGlobOptions; + readonly excludes: GlobSrcInfo[]; + readonly root: string; + readonly showContext: number; + + constructor(readonly files: string[], readonly options: CSpellApplicationOptions, readonly emitters: Emitters) { + this.root = path.resolve(options.root || process.cwd()); + this.info = emitters.info || nullEmitter; + this.debug = emitters.debug || ((msg: string) => this.info(msg, MessageTypes.Debug)); + this.configFile = options.config; + this.excludes = calcExcludeGlobInfo(this.root, options.exclude); + this.logIssue = emitters.issue || nullEmitter; + this.locale = options.locale || options.local || ''; + this.uniqueFilter = options.unique ? util.uniqueFilterFnGenerator((issue: Issue) => issue.text) : () => true; + this.progress = emitters.progress || nullEmitter; + this.showContext = + options.showContext === true ? defaultContextRange : options.showContext ? options.showContext : 0; + } +} diff --git a/packages/cspell/src/app.ts b/packages/cspell/src/app.ts index c260a4717866..c54b26fcc9e9 100644 --- a/packages/cspell/src/app.ts +++ b/packages/cspell/src/app.ts @@ -2,7 +2,8 @@ import * as path from 'path'; import * as commander from 'commander'; // eslint-disable-next-line @typescript-eslint/no-var-requires const npmPackage = require(path.join(__dirname, '..', 'package.json')); -import { CSpellApplicationOptions, BaseOptions, checkText } from './application'; +import { checkText } from './application'; +import { CSpellApplicationOptions, BaseOptions, TraceOptions } from './options'; import * as App from './application'; import chalk = require('chalk'); import { @@ -27,7 +28,6 @@ interface Options extends CSpellApplicationOptions { */ relative?: boolean; } -type TraceOptions = App.TraceOptions; // interface InitOptions extends Options {} const templateIssue = `{green $uri}:{yellow $row:$col} - Unknown word ({red $text})`; diff --git a/packages/cspell/src/application.test.ts b/packages/cspell/src/application.test.ts index 3ea6d15a2b8a..214bd282c8c9 100644 --- a/packages/cspell/src/application.test.ts +++ b/packages/cspell/src/application.test.ts @@ -1,6 +1,7 @@ import * as path from 'path'; import * as App from './application'; import { Emitters, ProgressFileComplete, Issue } from './emitters'; +import { CSpellApplicationOptions } from './options'; const getStdinResult = { value: '', @@ -154,7 +155,7 @@ describe('Application, Validate Samples', () => { interface SampleTest { file: string; issues: string[]; - options?: App.CSpellApplicationOptions; + options?: CSpellApplicationOptions; } function sampleTests(): SampleTest[] { diff --git a/packages/cspell/src/application.ts b/packages/cspell/src/application.ts index ae11dab2aa0d..ba20ba312500 100644 --- a/packages/cspell/src/application.ts +++ b/packages/cspell/src/application.ts @@ -1,10 +1,9 @@ import { - globP, - GlobOptions, - GlobSrcInfo, - calcExcludeGlobInfo, extractGlobExcludesFromConfig, extractPatterns, + GlobOptions, + globP, + GlobSrcInfo, normalizeGlobsToRoot, } from './util/glob'; import * as cspell from 'cspell-lib'; @@ -16,69 +15,13 @@ import { traceWords, TraceResult, CheckTextInfo, getDictionary } from 'cspell-li import { CSpellSettings, CSpellUserSettings, Glob } from '@cspell/cspell-types'; import getStdin from 'get-stdin'; export { TraceResult, IncludeExcludeFlag } from 'cspell-lib'; -import { IOptions } from './util/IOptions'; import { measurePromise } from './util/timer'; -import { - DebugEmitter, - Emitters, - MessageEmitter, - MessageTypes, - ProgressEmitter, - SpellingErrorEmitter, - Issue, -} from './emitters'; - -// cspell:word nocase +import { Emitters, MessageTypes, Issue } from './emitters'; +import { CSpellApplicationConfiguration } from './CSpellApplicationConfiguration'; +import { BaseOptions, CSpellApplicationOptions, TraceOptions } from './options'; const UTF8: BufferEncoding = 'utf8'; const STDIN = 'stdin'; -const defaultContextRange = 20; -export interface CSpellApplicationOptions extends BaseOptions { - /** - * Display verbose information - */ - verbose?: boolean; - /** - * Show extensive output. - */ - debug?: boolean; - /** - * a globs to exclude files from being checked. - */ - exclude?: string[] | string; - /** - * Only report the words, no line numbers or file names. - */ - wordsOnly?: boolean; - /** - * unique errors per file only. - */ - unique?: boolean; - /** - * root directory, defaults to `cwd` - */ - root?: string; - /** - * Show part of a line where an issue is found. - * if true, it will show the default number of characters on either side. - * if a number, it will shat number of characters on either side. - */ - showContext?: boolean | number; - /** - * Show suggestions for spelling errors. - */ - showSuggestions?: boolean; -} - -export type TraceOptions = BaseOptions; - -export interface BaseOptions { - config?: string; - languageId?: string; - locale?: string; - local?: string; // deprecated -} - export type AppError = NodeJS.ErrnoException; export interface RunResult { @@ -88,42 +31,6 @@ export interface RunResult { errors: number; } -const defaultMinimatchOptions: IOptions = { nocase: true }; -const defaultConfigGlobOptions: IOptions = defaultMinimatchOptions; - -const nullEmitter = () => { - /* empty */ -}; - -export class CSpellApplicationConfiguration { - readonly info: MessageEmitter; - readonly progress: ProgressEmitter; - readonly debug: DebugEmitter; - readonly logIssue: SpellingErrorEmitter; - readonly uniqueFilter: (issue: Issue) => boolean; - readonly locale: string; - - readonly configFile: string | undefined; - readonly configGlobOptions: IOptions = defaultConfigGlobOptions; - readonly excludes: GlobSrcInfo[]; - readonly root: string; - readonly showContext: number; - - constructor(readonly files: string[], readonly options: CSpellApplicationOptions, readonly emitters: Emitters) { - this.root = path.resolve(options.root || process.cwd()); - this.info = emitters.info || nullEmitter; - this.debug = emitters.debug || ((msg: string) => this.info(msg, MessageTypes.Debug)); - this.configFile = options.config; - this.excludes = calcExcludeGlobInfo(this.root, options.exclude); - this.logIssue = emitters.issue || nullEmitter; - this.locale = options.locale || options.local || ''; - this.uniqueFilter = options.unique ? util.uniqueFilterFnGenerator((issue: Issue) => issue.text) : () => true; - this.progress = emitters.progress || nullEmitter; - this.showContext = - options.showContext === true ? defaultContextRange : options.showContext ? options.showContext : 0; - } -} - interface ConfigInfo { source: string; config: CSpellUserSettings; diff --git a/packages/cspell/src/options.ts b/packages/cspell/src/options.ts new file mode 100644 index 000000000000..c28d802940d7 --- /dev/null +++ b/packages/cspell/src/options.ts @@ -0,0 +1,45 @@ +export interface CSpellApplicationOptions extends BaseOptions { + /** + * Display verbose information + */ + verbose?: boolean; + /** + * Show extensive output. + */ + debug?: boolean; + /** + * a globs to exclude files from being checked. + */ + exclude?: string[] | string; + /** + * Only report the words, no line numbers or file names. + */ + wordsOnly?: boolean; + /** + * unique errors per file only. + */ + unique?: boolean; + /** + * root directory, defaults to `cwd` + */ + root?: string; + /** + * Show part of a line where an issue is found. + * if true, it will show the default number of characters on either side. + * if a number, it will shat number of characters on either side. + */ + showContext?: boolean | number; + /** + * Show suggestions for spelling errors. + */ + showSuggestions?: boolean; +} + +export type TraceOptions = BaseOptions; + +export interface BaseOptions { + config?: string; + languageId?: string; + locale?: string; + local?: string; // deprecated +}