diff --git a/packages/code-analyzer-eslint-engine/package.json b/packages/code-analyzer-eslint-engine/package.json index ebb9aa6e..beaec864 100644 --- a/packages/code-analyzer-eslint-engine/package.json +++ b/packages/code-analyzer-eslint-engine/package.json @@ -1,7 +1,7 @@ { "name": "@salesforce/code-analyzer-eslint-engine", "description": "Plugin package that adds 'eslint' as an engine into Salesforce Code Analyzer", - "version": "0.15.0", + "version": "0.15.1-SNAPSHOT", "author": "The Salesforce Code Analyzer Team", "license": "BSD-3-Clause", "homepage": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/overview", diff --git a/packages/code-analyzer-eslint-engine/src/base-config.ts b/packages/code-analyzer-eslint-engine/src/base-config.ts index d1cc5e62..8a284765 100644 --- a/packages/code-analyzer-eslint-engine/src/base-config.ts +++ b/packages/code-analyzer-eslint-engine/src/base-config.ts @@ -41,27 +41,27 @@ export class LegacyBaseConfigFactory { } private useJsConfig(): boolean { - return !this.config.disable_javascript_base_config && this.config.javascript_file_extensions.length > 0; + return !this.config.disable_javascript_base_config && this.config.file_extensions.javascript.length > 0; } private useLwcConfig(): boolean { - return !this.config.disable_lwc_base_config && this.config.javascript_file_extensions.length > 0; + return !this.config.disable_lwc_base_config && this.config.file_extensions.javascript.length > 0; } private useTsConfig(): boolean { - return !this.config.disable_typescript_base_config && this.config.typescript_file_extensions.length > 0; + return !this.config.disable_typescript_base_config && this.config.file_extensions.typescript.length > 0; } private createJavascriptConfig(baseRuleset: BaseRuleset): Linter.ConfigOverride { return { - files: this.config.javascript_file_extensions.map(ext => `*${ext}`), + files: this.config.file_extensions.javascript.map(ext => `*${ext}`), extends: [`eslint:${baseRuleset}`] } } private createLwcConfig(): Linter.ConfigOverride { return { - files: this.config.javascript_file_extensions.map(ext => `*${ext}`), + files: this.config.file_extensions.javascript.map(ext => `*${ext}`), extends: [ "@salesforce/eslint-config-lwc/base" // Always using base for now. all and recommended both require additional plugins ], @@ -93,7 +93,7 @@ export class LegacyBaseConfigFactory { private createTypescriptConfig(baseRuleset: BaseRuleset): Linter.ConfigOverride { return { - files: this.config.typescript_file_extensions.map(ext => `*${ext}`), + files: this.config.file_extensions.typescript.map(ext => `*${ext}`), extends: [ `eslint:${baseRuleset}`, // The typescript plugin applies the base rules to the typescript files, so we want this `plugin:@typescript-eslint/${baseRuleset}`, // May override some rules from eslint: as needed diff --git a/packages/code-analyzer-eslint-engine/src/config.ts b/packages/code-analyzer-eslint-engine/src/config.ts index de4c4642..62bbf582 100644 --- a/packages/code-analyzer-eslint-engine/src/config.ts +++ b/packages/code-analyzer-eslint-engine/src/config.ts @@ -1,4 +1,8 @@ -import {ConfigDescription, ConfigValueExtractor, ValueValidator} from '@salesforce/code-analyzer-engine-api'; +import { + ConfigDescription, + ConfigValueExtractor, + ValueValidator +} from '@salesforce/code-analyzer-engine-api'; import {getMessage} from "./messages"; import path from "node:path"; import {makeUnique} from "./utils"; @@ -29,18 +33,22 @@ export type ESLintEngineConfig = { // Default: false disable_typescript_base_config: boolean - // Extensions of the javascript files in your workspace that will be used to discover rules. - // Default: ['.js', '.cjs', '.mjs'] - javascript_file_extensions: string[] - - // Extensions of the typescript files in your workspace that will be used to discover rules. - // Default: ['.ts'] - typescript_file_extensions: string[] + // Extensions of the files in your workspace that will be used to discover rules for javascript and typescript. + // Each file extension can only be associated to one language. If a specific language is not specified, then the + // following list of default file extensions will be used: + // javascript: ['.js', '.cjs', '.mjs'] + // typescript: ['.ts'] + file_extensions: FileExtensionsObject // (INTERNAL USE ONLY) Copy of the code analyzer config root. config_root: string } +type FileExtensionsObject = { + javascript: string[], + typescript: string[] +}; + export const DEFAULT_CONFIG: ESLintEngineConfig = { eslint_config_file: undefined, eslint_ignore_file: undefined, @@ -48,8 +56,10 @@ export const DEFAULT_CONFIG: ESLintEngineConfig = { disable_javascript_base_config: false, disable_lwc_base_config: false, disable_typescript_base_config: false, - javascript_file_extensions: ['.js', '.cjs', '.mjs'], - typescript_file_extensions: ['.ts'], + file_extensions: { + javascript: ['.js', '.cjs', '.mjs'], + typescript: ['.ts'] + }, config_root: process.cwd() // INTERNAL USE ONLY } @@ -86,15 +96,10 @@ export const ESLINT_ENGINE_CONFIG_DESCRIPTION: ConfigDescription = { valueType: "boolean", defaultValue: DEFAULT_CONFIG.disable_typescript_base_config }, - javascript_file_extensions: { - descriptionText: getMessage('ConfigFieldDescription_javascript_file_extensions'), - valueType: "array", - defaultValue: DEFAULT_CONFIG.javascript_file_extensions - }, - typescript_file_extensions: { - descriptionText: getMessage('ConfigFieldDescription_typescript_file_extensions'), - valueType: "array", - defaultValue: DEFAULT_CONFIG.typescript_file_extensions + file_extensions: { + descriptionText: getMessage('ConfigFieldDescription_file_extensions'), + valueType: "object", + defaultValue: DEFAULT_CONFIG.file_extensions } } } @@ -108,7 +113,6 @@ export const LEGACY_ESLINT_IGNORE_FILE: string = '.eslintignore'; export function validateAndNormalizeConfig(configValueExtractor: ConfigValueExtractor): ESLintEngineConfig { const eslintConfigValueExtractor: ESLintEngineConfigValueExtractor = new ESLintEngineConfigValueExtractor(configValueExtractor); - const [jsExts, tsExts] = eslintConfigValueExtractor.extractFileExtensionsValues(); return { config_root: configValueExtractor.getConfigRoot(), // INTERNAL USE ONLY eslint_config_file: eslintConfigValueExtractor.extractESLintConfigFileValue(), @@ -117,8 +121,7 @@ export function validateAndNormalizeConfig(configValueExtractor: ConfigValueExtr disable_javascript_base_config: eslintConfigValueExtractor.extractBooleanValue('disable_javascript_base_config'), disable_lwc_base_config: eslintConfigValueExtractor.extractBooleanValue('disable_lwc_base_config'), disable_typescript_base_config: eslintConfigValueExtractor.extractBooleanValue('disable_typescript_base_config'), - javascript_file_extensions: jsExts, - typescript_file_extensions: tsExts + file_extensions: eslintConfigValueExtractor.extractFileExtensionsValue(), }; } @@ -150,27 +153,45 @@ class ESLintEngineConfigValueExtractor { return eslintIgnoreFile; } - extractFileExtensionsValues(): string[][] { - const jsExtsField: string = 'javascript_file_extensions'; - const tsExtsField: string = 'typescript_file_extensions'; - const jsExts: string[] = makeUnique(this.extractExtensionsValue(jsExtsField, DEFAULT_CONFIG.javascript_file_extensions)!); - const tsExts: string[] = makeUnique(this.extractExtensionsValue(tsExtsField, DEFAULT_CONFIG.typescript_file_extensions)!); + extractFileExtensionsValue(): FileExtensionsObject { + if (!this.delegateExtractor.hasValueDefinedFor('file_extensions')) { + return DEFAULT_CONFIG.file_extensions; + } + + const fileExtsObjExtractor: ConfigValueExtractor = this.delegateExtractor.extractObjectAsExtractor('file_extensions'); + + // Validate languages + const validLanguages: string[] = Object.keys(DEFAULT_CONFIG.file_extensions); + for (const key of fileExtsObjExtractor.getKeys()) { + // Note: In the future we may want to make the languages case-insensitive. Right now it is a little tricky + // because the extract* methods (like extractArray) look for the exact key name. + if (!(validLanguages.includes(key))) { + throw new Error(getMessage('InvalidFieldKeyForObject', fileExtsObjExtractor.getFieldPath(), key, validLanguages.join(', '))) + } + } + + // Validate file extension patterns + const extractExtensionsValue = function (fieldName: string, defaultValue: string[]): string[] { + const fileExts: string[] = fileExtsObjExtractor.extractArray(fieldName, ValueValidator.validateString, defaultValue)!; + fileExts.map((fileExt, i) => validateStringMatches( + ESLintEngineConfigValueExtractor.FILE_EXT_PATTERN, fileExt, `${fileExtsObjExtractor.getFieldPath(fieldName)}[${i}]`)); + return makeUnique(fileExts); + } + const fileExtsObj: FileExtensionsObject = { + javascript: extractExtensionsValue('javascript', DEFAULT_CONFIG.file_extensions.javascript), + typescript: extractExtensionsValue('typescript', DEFAULT_CONFIG.file_extensions.typescript) + } - const allExts: string[] = jsExts.concat(tsExts); + // Validate that there is no file extension listed with multiple languages + const allExts: string[] = fileExtsObj.javascript.concat(fileExtsObj.typescript); if (allExts.length != (new Set(allExts)).size) { const currentValuesString: string = - ` ${this.delegateExtractor.getFieldPath(jsExtsField)}: ${JSON.stringify(jsExts)}\n` + - ` ${this.delegateExtractor.getFieldPath(tsExtsField)}: ${JSON.stringify(tsExts)}`; + ` ${fileExtsObjExtractor.getFieldPath('javascript')}: ${JSON.stringify(fileExtsObj.javascript)}\n` + + ` ${fileExtsObjExtractor.getFieldPath('typescript')}: ${JSON.stringify(fileExtsObj.typescript)}`; throw new Error(getMessage('ConfigStringArrayValuesMustNotShareElements', currentValuesString)); } - return [jsExts, tsExts]; - } - - extractExtensionsValue(fieldName: string, defaultValue: string[]): string[] { - const fileExts: string[] = this.delegateExtractor.extractArray(fieldName, ValueValidator.validateString, defaultValue)!; - return fileExts.map((fileExt, i) => validateStringMatches( - ESLintEngineConfigValueExtractor.FILE_EXT_PATTERN, fileExt, `${this.delegateExtractor.getFieldPath(fieldName)}[${i}]`)); + return fileExtsObj; } extractBooleanValue(field_name: string): boolean { diff --git a/packages/code-analyzer-eslint-engine/src/messages.ts b/packages/code-analyzer-eslint-engine/src/messages.ts index 01e74ae5..99974a27 100644 --- a/packages/code-analyzer-eslint-engine/src/messages.ts +++ b/packages/code-analyzer-eslint-engine/src/messages.ts @@ -28,11 +28,12 @@ const MESSAGE_CATALOG : { [key: string]: string } = { ConfigFieldDescription_disable_typescript_base_config: `Whether to turn off the default base configuration that supplies the standard rules for typescript files.`, - ConfigFieldDescription_javascript_file_extensions: - `Extensions of the javascript files in your workspace that will be associated with javascript and LWC rules.`, - - ConfigFieldDescription_typescript_file_extensions: - `Extensions of the typescript files in your workspace that will be associated with typescript rules.`, + ConfigFieldDescription_file_extensions: + `Extensions of the files in your workspace that will be used to discover rules for javascript and typescript.\n` + + `Each file extension can only be associated to one language. If a specific language is not specified, then the\n` + + `following list of default file extensions will be used:\n` + + ` javascript: ['.js', '.cjs', '.mjs']\n` + + ` typescript: ['.ts']`, UnsupportedEngineName: `The ESLintEnginePlugin does not support an engine with name '%s'.`, @@ -43,6 +44,9 @@ const MESSAGE_CATALOG : { [key: string]: string } = { InvalidLegacyIgnoreFileName: `The '%s' configuration value is invalid. Expected the file name '%s' to be equal to '%s'.`, + InvalidFieldKeyForObject: + `The '%s' configuration value is invalid. The value contained an invalid key '%s'. Valid keys for this object are: %s`, + ConfigStringValueMustMatchPattern: `The '%s' configuration value '%s' must match the pattern: /%s/`, diff --git a/packages/code-analyzer-eslint-engine/src/workspace.ts b/packages/code-analyzer-eslint-engine/src/workspace.ts index 0e4504ee..c4ee92c3 100644 --- a/packages/code-analyzer-eslint-engine/src/workspace.ts +++ b/packages/code-analyzer-eslint-engine/src/workspace.ts @@ -67,8 +67,8 @@ export class MissingESLintWorkspace implements ESLintWorkspace { async getCandidateFilesForBaseConfig(_filterFcn: AsyncFilterFnc): Promise { return createPlaceholderCandidateFiles([ - ... this.config.javascript_file_extensions, - ... this.config.typescript_file_extensions], + ... this.config.file_extensions.javascript, + ... this.config.file_extensions.typescript], this.config.config_root ) } @@ -92,7 +92,6 @@ type FilesOfInterest = { export class PresentESLintWorkspace implements ESLintWorkspace { private readonly delegateWorkspace: Workspace; private readonly config: ESLintEngineConfig; - private workspaceRoot?: string; private filesOfInterest?: FilesOfInterest; private cachedUserConfigInfo?: UserConfigInfo; @@ -114,11 +113,11 @@ export class PresentESLintWorkspace implements ESLintWorkspace { const filesOfInterest: FilesOfInterest = await this.getFilesOfInterest(filterFcn); let candidateFiles: string[] = []; if (filesOfInterest.javascriptFiles.length > 0) { - candidateFiles = createPlaceholderCandidateFiles(this.config.javascript_file_extensions, this.getWorkspaceRoot()); + candidateFiles = createPlaceholderCandidateFiles(this.config.file_extensions.javascript, this.getWorkspaceRoot()); } if (filesOfInterest.typescriptFiles.length > 0) { candidateFiles = candidateFiles.concat( - createPlaceholderCandidateFiles(this.config.typescript_file_extensions, this.getWorkspaceRoot())); + createPlaceholderCandidateFiles(this.config.file_extensions.typescript, this.getWorkspaceRoot())); } return candidateFiles; } @@ -144,9 +143,9 @@ export class PresentESLintWorkspace implements ESLintWorkspace { this.filesOfInterest = { javascriptFiles: [], typescriptFiles: [] }; for (const file of await this.delegateWorkspace.getExpandedFiles()) { const fileExt = path.extname(file).toLowerCase(); - if (this.config.javascript_file_extensions.includes(fileExt)) { + if (this.config.file_extensions.javascript.includes(fileExt)) { this.filesOfInterest.javascriptFiles.push(file); - } else if (this.config.typescript_file_extensions.includes(fileExt)) { + } else if (this.config.file_extensions.typescript.includes(fileExt)) { this.filesOfInterest.typescriptFiles.push(file); } } diff --git a/packages/code-analyzer-eslint-engine/test/engine.test.ts b/packages/code-analyzer-eslint-engine/test/engine.test.ts index ca8e511b..df336ee0 100644 --- a/packages/code-analyzer-eslint-engine/test/engine.test.ts +++ b/packages/code-analyzer-eslint-engine/test/engine.test.ts @@ -268,26 +268,34 @@ describe('Tests for the describeRules method of ESLintEngine', () => { expectRulesToMatchLegacyExpectationFile(ruleDescriptions, 'rules_OnlyCustomConfigModifyingExistingRules.goldfile.json'); }); - it('When javascript_file_extensions is empty, then javascript rules do not get picked up', async () => { + it('When file_extensions.javascript is empty, then javascript rules do not get picked up', async () => { const engine: ESLintEngine = new ESLintEngine({...DEFAULT_CONFIG, - javascript_file_extensions: [] + file_extensions: { + ... DEFAULT_CONFIG.file_extensions, + javascript: [] + } }); const ruleDescriptions: RuleDescription[] = await engine.describeRules({}); expectRulesToMatchLegacyExpectationFile(ruleDescriptions, 'rules_DefaultConfig_NoJavascriptFilesInWorkspace.goldfile.json'); }); - it('When javascript_file_extensions is empty, then javascript rules do not get picked up', async () => { + it('When file_extensions.javascript is empty, then javascript rules do not get picked up', async () => { const engine: ESLintEngine = new ESLintEngine({...DEFAULT_CONFIG, - typescript_file_extensions: [] + file_extensions: { + ... DEFAULT_CONFIG.file_extensions, + typescript: [] + } }); const ruleDescriptions: RuleDescription[] = await engine.describeRules({}); expectRulesToMatchLegacyExpectationFile(ruleDescriptions, 'rules_DefaultConfig_NoTypescriptFilesInWorkspace.goldfile.json'); }); - it('When javascript_file_extensions and typescript_file_extensions are both empty, then no rules are returned', async () => { + it('When file_extensions.javascript and file_extensions.typescript are both empty, then no rules are returned', async () => { const engine: ESLintEngine = new ESLintEngine({...DEFAULT_CONFIG, - javascript_file_extensions: [], - typescript_file_extensions: [] + file_extensions: { + javascript: [], + typescript: [] + } }); const ruleDescriptions: RuleDescription[] = await engine.describeRules({}); expect(ruleDescriptions).toHaveLength(0); diff --git a/packages/code-analyzer-eslint-engine/test/plugin.test.ts b/packages/code-analyzer-eslint-engine/test/plugin.test.ts index a2e1c1ed..78e81eff 100644 --- a/packages/code-analyzer-eslint-engine/test/plugin.test.ts +++ b/packages/code-analyzer-eslint-engine/test/plugin.test.ts @@ -172,48 +172,73 @@ describe('Tests for the ESLintEnginePlugin', () => { 'engines.eslint.disable_typescript_base_config', 'boolean', 'string')); }); - it('When a valid javascript_file_extensions value is passed to createEngineConfig, then it is set on the config', async () => { + it('When an file_extensions value contains an invalid language, then createEngineConfig errors', async () => { const userProvidedOverrides: ConfigObject = { - javascript_file_extensions: ['.js', '.jsx', '.js'] + file_extensions: { + oops: ['.js', '.jsx', '.js'] + } + }; + await expect(callCreateEngineConfig(plugin, userProvidedOverrides)).rejects.toThrow( + getMessage('InvalidFieldKeyForObject', 'engines.eslint.file_extensions', 'oops', 'javascript, typescript')); + }); + + it('When a valid file_extensions.javascript value is passed to createEngineConfig, then it is set on the config', async () => { + const userProvidedOverrides: ConfigObject = { + file_extensions: { + javascript: ['.js', '.jsx', '.js'] + } }; const resolvedConfig: ConfigObject = await callCreateEngineConfig(plugin, userProvidedOverrides); - expect(resolvedConfig['javascript_file_extensions']).toEqual(['.js', '.jsx']); // Also checks that duplicates are removed + expect(resolvedConfig['file_extensions']).toEqual({ + ...DEFAULT_CONFIG.file_extensions, + javascript: ['.js', '.jsx']}); // Also checks that duplicates are removed }); - it('When javascript_file_extensions is invalid, then createEngineConfig errors', async () => { + it('When file_extensions.javascript is invalid, then createEngineConfig errors', async () => { const userProvidedOverrides: ConfigObject = { - javascript_file_extensions: [3, '.js'] + file_extensions: { + javascript: [3, '.js'] + } }; await expect(callCreateEngineConfig(plugin, userProvidedOverrides)).rejects.toThrow( getMessageFromCatalog(SHARED_MESSAGE_CATALOG, 'ConfigValueMustBeOfType', - 'engines.eslint.javascript_file_extensions[0]', 'string', 'number')); + 'engines.eslint.file_extensions.javascript[0]', 'string', 'number')); }); - it('When a valid typescript_file_extensions value is passed to createEngineConfig, then it is set on the config', async () => { + it('When a valid file_extensions.typescript value is passed to createEngineConfig, then it is set on the config', async () => { const userProvidedOverrides: ConfigObject = { - typescript_file_extensions: ['.ts', '.tsx'] + file_extensions: { + typescript: ['.ts', '.tsx'] + } }; const resolvedConfig: ConfigObject = await callCreateEngineConfig(plugin, userProvidedOverrides); - expect(resolvedConfig['typescript_file_extensions']).toEqual(['.ts', '.tsx']); + expect(resolvedConfig['file_extensions']).toEqual({ + ... DEFAULT_CONFIG.file_extensions, + typescript: ['.ts', '.tsx'] + }); }); - it('When typescript_file_extensions is invalid, then createEngineConfig errors', async () => { + it('When file_extensions.typescript is invalid, then createEngineConfig errors', async () => { const userProvidedOverrides: ConfigObject = { - typescript_file_extensions: ['.ts', 'missingDot'] + file_extensions: { + typescript: ['.ts', 'missingDot'] + } }; await expect(callCreateEngineConfig(plugin, userProvidedOverrides)).rejects.toThrow( getMessage('ConfigStringValueMustMatchPattern', - 'engines.eslint.typescript_file_extensions[1]', 'missingDot', '^[.][a-zA-Z0-9]+$')); + 'engines.eslint.file_extensions.typescript[1]', 'missingDot', '^[.][a-zA-Z0-9]+$')); }); it('When an extension is listed in more than one *_file_extensions field, then createEngineConfig errors', async () => { const userProvidedOverrides: ConfigObject = { - typescript_file_extensions: ['.ts', '.js'] + file_extensions: { + typescript: ['.ts', '.js'] + } }; await expect(callCreateEngineConfig(plugin, userProvidedOverrides)).rejects.toThrow( getMessage('ConfigStringArrayValuesMustNotShareElements', - ` engines.eslint.javascript_file_extensions: [".js",".cjs",".mjs"]\n` + - ` engines.eslint.typescript_file_extensions: [".ts",".js"]`)); + ` engines.eslint.file_extensions.javascript: [".js",".cjs",".mjs"]\n` + + ` engines.eslint.file_extensions.typescript: [".ts",".js"]`)); }); it('When createEngine is passed an invalid engine name, then an error is thrown', async () => { diff --git a/packages/code-analyzer-pmd-engine/src/messages.ts b/packages/code-analyzer-pmd-engine/src/messages.ts index 970c8d73..375454a2 100644 --- a/packages/code-analyzer-pmd-engine/src/messages.ts +++ b/packages/code-analyzer-pmd-engine/src/messages.ts @@ -92,7 +92,7 @@ const MESSAGE_CATALOG : { [key: string]: string } = { `The '%s' configuration value is invalid. The value must be a positive integer.`, InvalidFieldKeyForObject: - `The '%s' configure value is invalid. The value contained an invalid key '%s'. Valid keys for this object are: %s` + `The '%s' configuration value is invalid. The value contained an invalid key '%s'. Valid keys for this object are: %s` } /**