Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
13 changes: 13 additions & 0 deletions packages/cspell-eslint-plugin/assets/options.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,19 @@
},
"type": "object"
},
"cspellOptionsRoot": {
"anyOf": [
{
"type": "string"
},
{
"format": "uri",
"type": "string"
}
],
"description": "Specify the root path of the cspell configuration. It is used to resolve `imports` found in {@link cspell } .\n\nexample: ```js cspellOptionsRoot: import.meta.url // or cspellOptionsRoot: __filename ```",
"markdownDescription": "Specify the root path of the cspell configuration.\nIt is used to resolve `imports` found in {@link cspell } .\n\nexample:\n```js\ncspellOptionsRoot: import.meta.url\n// or\ncspellOptionsRoot: __filename\n```"
},
"customWordListFile": {
"anyOf": [
{
Expand Down
4 changes: 0 additions & 4 deletions packages/cspell-eslint-plugin/cspell.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,4 @@ ignoreWords:
- todos
- bluelist
words:
- estree
- pnpm
- synckit
- treeshake
- tsbuildinfo
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
words:
- isentinel
18 changes: 18 additions & 0 deletions packages/cspell-eslint-plugin/fixtures/import-support/sample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* This is a sample file to test the import support.
* Related to issue: [#5789](https://github.com/streetsidesoftware/cspell/issues/5789)
*/

export function sample() {
return 'sample';
}

export const words = [
`
readstring
resetmemorycategory
retargeting
rrotate
rshift
`,
];
3 changes: 3 additions & 0 deletions packages/cspell-eslint-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.5.0",
"@internal/cspell-eslint-plugin-scripts": "workspace:*",
"@internal/fixture-test-dictionary": "workspace:*",
"@types/eslint": "^8.56.10",
"@types/estree": "^1.0.5",
"@types/mocha": "^10.0.7",
"@typescript-eslint/parser": "^7.13.1",
Expand All @@ -101,6 +103,7 @@
},
"dependencies": {
"@cspell/cspell-types": "workspace:*",
"@cspell/url": "workspace:*",
"cspell-lib": "workspace:*",
"synckit": "^0.9.0"
},
Expand Down
12 changes: 12 additions & 0 deletions packages/cspell-eslint-plugin/src/common/options.cts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,18 @@ export interface Check {
* CSpell options to pass to the spell checker.
*/
cspell?: CSpellOptions;
/**
* Specify the root path of the cspell configuration.
* It is used to resolve `imports` found in {@link cspell}.
*
* example:
* ```js
* cspellOptionsRoot: import.meta.url
* // or
* cspellOptionsRoot: __filename
* ```
*/
cspellOptionsRoot?: string | URL;
/**
* Specify a path to a custom word list file.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ function create(context: Rule.RuleContext): Rule.RuleListener {
'errors: %o',
errors.map((e) => e.message),
);
errors.forEach((error) => console.error('%s', error.message));
errors.forEach((error) => context.report({ message: error.message, loc: { line: 1, column: 1 } }));
}
issues.forEach((issue) => reportIssue(issue));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const defaultCheckOptions: Required<Check> = {
checkStrings: true,
checkStringTemplates: true,
configFile: '',
cspellOptionsRoot: '',
cspell: {
words: [],
flagWords: [],
Expand All @@ -26,6 +27,7 @@ export const defaultOptions: RequiredOptions = {
};

export function normalizeOptions(opts: Options | undefined, cwd: string): WorkerOptions {
const options: WorkerOptions = Object.assign({}, defaultOptions, opts || {}, { cwd });
const cspellOptionsRoot = opts?.cspellOptionsRoot || 'eslint-configuration-file';
const options: WorkerOptions = Object.assign({}, defaultOptions, opts || {}, { cspellOptionsRoot, cwd });
return options;
}
147 changes: 147 additions & 0 deletions packages/cspell-eslint-plugin/src/test/import.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';

import typeScriptParser from '@typescript-eslint/parser';
import { RuleTester } from 'eslint';

import type { Options as RuleOptions } from '../plugin/index.cjs';
import Rule from '../plugin/index.cjs';

const __dirname = fileURLToPath(new URL('.', import.meta.url));
const root = path.resolve(__dirname, '../..');
const fixturesDir = path.join(root, 'fixtures');

const parsers: Record<string, string | undefined | unknown> = {
// Note: it is possible for @typescript-eslint/parser to break the path
'.ts': typeScriptParser,
};

type ValidTestCase = RuleTester.ValidTestCase;
type Options = Partial<RuleOptions>;

const ruleTester = new RuleTester({});

ruleTester.run('cspell', Rule.rules.spellchecker, {
valid: [
readFix('import-support/sample.ts', {
cspell: {
import: ['@internal/fixture-test-dictionary'],
},
}),
readFix('import-support/sample.ts', {
configFile: '@internal/fixture-test-dictionary',
cspell: {
language: 'en-US',
},
}),
],
invalid: [
// cspell:ignore readstring resetmemorycategory retargeting rrotate rshift
readInvalid('import-support/sample.ts', [
unknownWord('readstring', 8),
unknownWord('resetmemorycategory'),
unknownWord('retargeting', 8),
unknownWord('rrotate', 8),
unknownWord('rshift', 8),
]),
readInvalid(
'import-support/sample.ts',
[
ce(
'Configuration Error: \n' +
' Failed to resolve configuration file: "bad-import" referenced from ' +
`"./eslint-configuration-file"`,
),
],
{
cspellOptionsRoot: '',
cspell: {
import: ['@internal/fixture-test-dictionary', 'bad-import'],
},
},
),
readInvalid(
'import-support/sample.ts',
[
ce(
'Configuration Error: \n' +
' Failed to resolve configuration file: "missing-import" referenced from ' +
`"./eslint-test-configuration-file"`,
),
],
{
cspellOptionsRoot: 'eslint-test-configuration-file',
cspell: {
import: ['@internal/fixture-test-dictionary', 'missing-import'],
},
},
),
],
});

function resolveFix(filename: string): string {
return path.resolve(fixturesDir, filename);
}

interface ValidTestCaseEsLint9 extends ValidTestCase {
languageOptions?: {
parser?: unknown;
};
}

function readFix(filename: string, options?: Options): ValidTestCase {
const __filename = resolveFix(filename);
const code = fs.readFileSync(__filename, 'utf8');

const sample: ValidTestCaseEsLint9 = {
code,
filename: __filename,
};
if (options) {
sample.options = [options];
}

const parser = parsers[path.extname(__filename)];
if (parser) {
sample.languageOptions ??= {};
sample.languageOptions.parser = parser;
}

return sample;
}

interface TestCaseError {
message?: string | RegExp | undefined;
messageId?: string | undefined;
type?: string | undefined;
data?: unknown | undefined;
line?: number | undefined;
column?: number | undefined;
endLine?: number | undefined;
endColumn?: number | undefined;
suggestions?: RuleTester.SuggestionOutput[] | undefined | number;
}

type InvalidTestCaseError = RuleTester.TestCaseError | TestCaseError;

function readInvalid(filename: string, errors: TestCaseError[], options?: Options) {
const sample = readFix(filename, options);
return {
...sample,
errors: errors.map((err) => csError(err)),
};
}

function unknownWord(word: string, suggestions?: number): InvalidTestCaseError {
return ce(`Unknown word: "${word}"`, suggestions);
}

function ce(message: string, suggestions?: number): RuleTester.TestCaseError {
return { message, suggestions } as RuleTester.TestCaseError;
}

function csError(error: InvalidTestCaseError, suggestions?: number): RuleTester.TestCaseError {
if (error && typeof error === 'object') return error as RuleTester.TestCaseError;
return ce(error, suggestions);
}
5 changes: 4 additions & 1 deletion packages/cspell-eslint-plugin/src/worker/spellCheck.mts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import assert from 'node:assert';
import * as path from 'node:path';

import { toFileDirURL, toFileURL } from '@cspell/url';
import type { TSESTree } from '@typescript-eslint/types';
import type { CSpellSettings, TextDocument, ValidationIssue } from 'cspell-lib';
import {
Expand All @@ -24,6 +25,7 @@ import type { Issue, SpellCheckResults, Suggestions } from './types.cjs';
import { walkTree } from './walkTree.mjs';

const defaultSettings: CSpellSettings = {
name: 'eslint-configuration-file',
patterns: [
// @todo: be able to use cooked / transformed strings.
// {
Expand Down Expand Up @@ -376,7 +378,8 @@ function getDocValidator(filename: string, text: string, options: WorkerOptions)
return cachedValidator;
}

const validator = new DocumentValidator(doc, options, settings);
const resolveImportsRelativeTo = toFileURL(options.cspellOptionsRoot || import.meta.url, toFileDirURL(options.cwd));
const validator = new DocumentValidator(doc, { ...options, resolveImportsRelativeTo }, settings);
docValCache.set(doc, validator);
return validator;
}
Expand Down
12 changes: 12 additions & 0 deletions packages/cspell-lib/api/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,13 @@ interface IConfigLoader {
* The loader caches configuration files for performance. This method clears the cache.
*/
clearCachedSettingsFiles(): void;
/**
* Resolve and merge the settings from the imports.
* This will create a virtual configuration file that is used to resolve the settings.
* @param settings - settings to resolve imports for
* @param filename - the path / URL to the settings file. Used to resolve imports.
*/
resolveSettingsImports(settings: CSpellUserSettings, filename: string | URL): Promise<CSpellSettingsI>;
/**
* Resolve imports and merge.
* @param cfgFile - configuration file.
Expand Down Expand Up @@ -710,6 +717,11 @@ interface DocumentValidatorOptions extends ValidateTextOptions {
* @defaultValue undefined
*/
noConfigSearch?: boolean;
/**
* If `settings: CSpellUserSettings` contains imports, they will be resolved using this path.
* If not set, the current working directory will be used.
*/
resolveImportsRelativeTo?: string | URL;
}
type PerfTimings = Record<string, number>;
declare class DocumentValidator {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { onClearCache } from '../../../events/index.js';
import type { VFileSystem } from '../../../fileSystem.js';
import { getVirtualFS } from '../../../fileSystem.js';
import { createCSpellSettingsInternal as csi } from '../../../Models/CSpellSettingsInternalDef.js';
import { AutoResolveCache } from '../../../util/AutoResolve.js';
import { autoResolve, AutoResolveCache, autoResolveWeak } from '../../../util/AutoResolve.js';
import { logError, logWarning } from '../../../util/logger.js';
import { FileResolver } from '../../../util/resolveFile.js';
import { envToTemplateVars } from '../../../util/templates.js';
Expand Down Expand Up @@ -129,6 +129,14 @@ export interface IConfigLoader {
*/
clearCachedSettingsFiles(): void;

/**
* Resolve and merge the settings from the imports.
* This will create a virtual configuration file that is used to resolve the settings.
* @param settings - settings to resolve imports for
* @param filename - the path / URL to the settings file. Used to resolve imports.
*/
resolveSettingsImports(settings: CSpellUserSettings, filename: string | URL): Promise<CSpellSettingsI>;

/**
* Resolve imports and merge.
* @param cfgFile - configuration file.
Expand Down Expand Up @@ -196,6 +204,7 @@ export class ConfigLoader implements IConfigLoader {
protected cachedConfigFiles = new Map<string, CSpellConfigFile>();
protected cachedPendingConfigFile = new AutoResolveCache<string, Promise<CSpellConfigFile | Error>>();
protected cachedMergedConfig = new WeakMap<CSpellConfigFile, CacheMergeConfigFileWithImports>();
protected cachedCSpellConfigFileInMemory = new WeakMap<CSpellUserSettings, Map<string, CSpellConfigFileInMemory>>();
protected globalSettings: CSpellSettingsI | undefined;
protected cspellConfigFileReaderWriter: CSpellConfigFileReaderWriter;
protected configSearch: ConfigSearch;
Expand Down Expand Up @@ -296,10 +305,21 @@ export class ConfigLoader implements IConfigLoader {
this.configSearch.clearCache();
this.cachedPendingConfigFile.clear();
this.cspellConfigFileReaderWriter.clearCachedFiles();
this.cachedMergedConfig = new WeakMap<CSpellConfigFile, CacheMergeConfigFileWithImports>();
this.cachedMergedConfig = new WeakMap();
this.cachedCSpellConfigFileInMemory = new WeakMap();
this.prefetchGlobalSettingsAsync();
}

/**
* Resolve and merge the settings from the imports.
* @param settings - settings to resolve imports for
* @param filename - the path / URL to the settings file. Used to resolve imports.
*/
resolveSettingsImports(settings: CSpellUserSettings, filename: string | URL): Promise<CSpellSettingsI> {
const settingsFile = this.createCSpellConfigFile(filename, settings);
return this.mergeConfigFileWithImports(settingsFile, settings);
}

protected init(): Promise<void> {
this.onReady = Promise.all([this.prefetchGlobalSettingsAsync(), this.resolveDefaultConfig()]).then(
() => undefined,
Expand Down Expand Up @@ -520,7 +540,8 @@ export class ConfigLoader implements IConfigLoader {
}

createCSpellConfigFile(filename: URL | string, settings: CSpellUserSettings): CSpellConfigFile {
return new CSpellConfigFileInMemory(toFileURL(filename), settings);
const map = autoResolveWeak(this.cachedCSpellConfigFileInMemory, settings, () => new Map());
return autoResolve(map, filename, () => new CSpellConfigFileInMemory(toFileURL(filename), settings));
}

dispose() {
Expand Down
Loading