Skip to content

Commit 5e2098e

Browse files
NEW(pmd): @W-17386449@: Add file_extensions config field for both cpd and pmd engines
1 parent 5d13f7a commit 5e2098e

File tree

16 files changed

+335
-87
lines changed

16 files changed

+335
-87
lines changed

packages/code-analyzer-core/test/config.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ describe("Tests for creating and accessing configuration values", () => {
171171
it("When top level config has an unknown key, then we error", () => {
172172
expect(() => CodeAnalyzerConfig.fromObject({doesNotExist: 3})).toThrow(
173173
getMessageFromCatalog(SHARED_MESSAGE_CATALOG,'ConfigObjectContainsInvalidKey','<TopLevel>', 'doesNotExist',
174-
'["config_root","log_folder","rules","engines"]'));
174+
'["config_root","engines","log_folder","rules"]'));
175175
});
176176

177177
it("When engines value is not an object then we throw an error", () => {

packages/code-analyzer-engine-api/src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export class ConfigValueExtractor {
7474
for (const key of actualKeys) {
7575
if (!lowercasePublicKeys.includes(key.toLowerCase()) && !this.keysThatBypassValidation.includes(key)) {
7676
throw new Error(getMessage('ConfigObjectContainsInvalidKey',
77-
this.fieldPathRoot || '<TopLevel>', key, JSON.stringify(keys)))
77+
this.fieldPathRoot || '<TopLevel>', key, JSON.stringify(keys.sort())))
7878
}
7979
}
8080
}

packages/code-analyzer-engine-api/test/config.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ describe("Tests for ConfigValueExtractor", () => {
237237
it("When validateContainsOnlySpecifiedKeys is called on an object that has extra keys, then error", () => {
238238
const topLevelExtractor: ConfigValueExtractor = new ConfigValueExtractor({a: 3, b: 2, c: 3}, '', __dirname);
239239
expect(() => topLevelExtractor.validateContainsOnlySpecifiedKeys(['a','C'])).toThrow(
240-
Error(getMessage('ConfigObjectContainsInvalidKey','<TopLevel>', 'b', '["a","C"]')));
240+
Error(getMessage('ConfigObjectContainsInvalidKey','<TopLevel>', 'b', '["C","a"]')));
241241
const subExtractor: ConfigValueExtractor = new ConfigValueExtractor({d: 5, E: 6}, 'some.other[3]', __dirname);
242242
subExtractor.addKeysThatBypassValidation(['hidden_keys']); // Sanity check that these don't show up in the error messages
243243
expect(() => subExtractor.validateContainsOnlySpecifiedKeys(['d'])).toThrow(

packages/code-analyzer-eslint-engine/src/config.ts

Lines changed: 23 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -158,54 +158,35 @@ class ESLintEngineConfigValueExtractor {
158158
}
159159

160160
extractFileExtensionsValue(): FileExtensionsObject {
161-
if (!this.delegateExtractor.hasValueDefinedFor('file_extensions')) {
162-
return DEFAULT_CONFIG.file_extensions;
163-
}
164-
165-
const fileExtsObjExtractor: ConfigValueExtractor = this.delegateExtractor.extractObjectAsExtractor('file_extensions');
166-
167-
// Validate languages
168-
const validLanguages: string[] = Object.keys(DEFAULT_CONFIG.file_extensions);
169-
for (const key of fileExtsObjExtractor.getKeys()) {
170-
// Note: In the future we may want to make the languages case-insensitive. Right now it is a little tricky
171-
// because the extract* methods (like extractArray) look for the exact key name.
172-
if (!(validLanguages.includes(key))) {
173-
throw new Error(getMessage('InvalidFieldKeyForObject', fileExtsObjExtractor.getFieldPath(), key, validLanguages.join(', ')))
161+
const fileExtensionsExtractor: ConfigValueExtractor = this.delegateExtractor.extractObjectAsExtractor(
162+
'file_extensions', DEFAULT_CONFIG.file_extensions);
163+
fileExtensionsExtractor.validateContainsOnlySpecifiedKeys(Object.keys(DEFAULT_CONFIG.file_extensions));
164+
165+
const extToLangMap: Map<string, string> = new Map(); // To keep track if file extension shows up with more than one language
166+
const fileExtensionsMap: FileExtensionsObject = {... DEFAULT_CONFIG.file_extensions}; // Start with copy
167+
for (const language of Object.keys(fileExtensionsMap)) {
168+
const fileExts: string[] = makeUnique(fileExtensionsExtractor.extractArray(language,
169+
(element, elementFieldPath) => ValueValidator.validateString(element,
170+
elementFieldPath, ESLintEngineConfigValueExtractor.FILE_EXT_PATTERN),
171+
DEFAULT_CONFIG.file_extensions[language as keyof FileExtensionsObject]
172+
)!).map(fileExt => fileExt.toLowerCase());
173+
174+
// Validate that none of the file extensions already exist in another language
175+
for (const fileExt of fileExts) {
176+
if (extToLangMap.has(fileExt) && extToLangMap.get(fileExt) !== language) {
177+
throw new Error(getMessage('InvalidFileExtensionDueToItBeingListedTwice',
178+
fileExtensionsExtractor.getFieldPath(), fileExt,
179+
JSON.stringify([extToLangMap.get(fileExt), language])));
180+
}
181+
extToLangMap.set(fileExt, language);
174182
}
175-
}
176183

177-
// Validate file extension patterns
178-
const extractExtensionsValue = function (fieldName: string, defaultValue: string[]): string[] {
179-
const fileExts: string[] = fileExtsObjExtractor.extractArray(fieldName, ValueValidator.validateString, defaultValue)!;
180-
fileExts.map((fileExt, i) => validateStringMatches(
181-
ESLintEngineConfigValueExtractor.FILE_EXT_PATTERN, fileExt, `${fileExtsObjExtractor.getFieldPath(fieldName)}[${i}]`));
182-
return makeUnique(fileExts);
183-
}
184-
const fileExtsObj: FileExtensionsObject = {
185-
javascript: extractExtensionsValue('javascript', DEFAULT_CONFIG.file_extensions.javascript),
186-
typescript: extractExtensionsValue('typescript', DEFAULT_CONFIG.file_extensions.typescript)
184+
fileExtensionsMap[language as keyof FileExtensionsObject] = fileExts;
187185
}
188-
189-
// Validate that there is no file extension listed with multiple languages
190-
const allExts: string[] = fileExtsObj.javascript.concat(fileExtsObj.typescript);
191-
if (allExts.length != (new Set(allExts)).size) {
192-
const currentValuesString: string =
193-
` ${fileExtsObjExtractor.getFieldPath('javascript')}: ${JSON.stringify(fileExtsObj.javascript)}\n` +
194-
` ${fileExtsObjExtractor.getFieldPath('typescript')}: ${JSON.stringify(fileExtsObj.typescript)}`;
195-
throw new Error(getMessage('ConfigStringArrayValuesMustNotShareElements', currentValuesString));
196-
}
197-
198-
return fileExtsObj;
186+
return fileExtensionsMap;
199187
}
200188

201189
extractBooleanValue(field_name: string): boolean {
202190
return this.delegateExtractor.extractBoolean(field_name, DEFAULT_CONFIG[field_name as keyof ESLintEngineConfig] as boolean)!;
203191
}
204-
}
205-
206-
function validateStringMatches(pattern: RegExp, value: string, fieldName: string): string {
207-
if (!pattern.test(value)) {
208-
throw new Error(getMessage('ConfigStringValueMustMatchPattern', fieldName, value, pattern.source));
209-
}
210-
return value;
211192
}

packages/code-analyzer-eslint-engine/src/messages.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,8 @@ const MESSAGE_CATALOG : { [key: string]: string } = {
4444
InvalidLegacyIgnoreFileName:
4545
`The '%s' configuration value is invalid. Expected the file name '%s' to be equal to '%s'.`,
4646

47-
InvalidFieldKeyForObject:
48-
`The '%s' configuration value is invalid. The value contained an invalid key '%s'. Valid keys for this object are: %s`,
49-
50-
ConfigStringValueMustMatchPattern:
51-
`The '%s' configuration value '%s' must match the pattern: /%s/`,
52-
53-
ConfigStringArrayValuesMustNotShareElements:
54-
`The following configuration values must not share any common array elements between them:\n%s`,
47+
InvalidFileExtensionDueToItBeingListedTwice:
48+
`The '%s' configuration object is invalid. The file extension '%s' is currently listed under more than one language: %s`,
5549

5650
ESLintErroredWhenScanningFile:
5751
`When scanning file '%s' with the eslint engine, ESLint gave the following error:\n%s`,

packages/code-analyzer-eslint-engine/test/plugin.test.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,8 @@ describe('Tests for the ESLintEnginePlugin', () => {
4747
const userProvidedOverrides: ConfigObject = {dummy: 3};
4848
await expect(callCreateEngineConfig(plugin, userProvidedOverrides)).rejects.toThrow(
4949
getMessageFromCatalog(SHARED_MESSAGE_CATALOG, 'ConfigObjectContainsInvalidKey', 'engines.eslint', 'dummy',
50-
'["eslint_config_file","eslint_ignore_file","auto_discover_eslint_config",' +
51-
'"disable_javascript_base_config","disable_lwc_base_config","disable_typescript_base_config",' +
52-
'"file_extensions"]'));
50+
'["auto_discover_eslint_config","disable_javascript_base_config","disable_lwc_base_config",' +
51+
'"disable_typescript_base_config","eslint_config_file","eslint_ignore_file","file_extensions"]'));
5352
});
5453

5554
it('When a valid eslint_config_file is passed to createEngineConfig, then it is set on the config', async () => {
@@ -176,14 +175,15 @@ describe('Tests for the ESLintEnginePlugin', () => {
176175
'engines.eslint.disable_typescript_base_config', 'boolean', 'string'));
177176
});
178177

179-
it('When an file_extensions value contains an invalid language, then createEngineConfig errors', async () => {
178+
it('When a file_extensions value contains an invalid language, then createEngineConfig errors', async () => {
180179
const userProvidedOverrides: ConfigObject = {
181180
file_extensions: {
182-
oops: ['.js', '.jsx', '.js']
181+
oops: ['.js', '.jsx']
183182
}
184183
};
185184
await expect(callCreateEngineConfig(plugin, userProvidedOverrides)).rejects.toThrow(
186-
getMessage('InvalidFieldKeyForObject', 'engines.eslint.file_extensions', 'oops', 'javascript, typescript'));
185+
getMessageFromCatalog(SHARED_MESSAGE_CATALOG, 'ConfigObjectContainsInvalidKey', 'engines.eslint.file_extensions',
186+
'oops', '["javascript","typescript"]'));
187187
});
188188

189189
it('When a valid file_extensions.javascript value is passed to createEngineConfig, then it is set on the config', async () => {
@@ -212,7 +212,7 @@ describe('Tests for the ESLintEnginePlugin', () => {
212212
it('When a valid file_extensions.typescript value is passed to createEngineConfig, then it is set on the config', async () => {
213213
const userProvidedOverrides: ConfigObject = {
214214
file_extensions: {
215-
typescript: ['.ts', '.tsx']
215+
typescript: ['.tS', '.tsX'] // Also confirm we normalize the extensions to lowercase
216216
}
217217
};
218218
const resolvedConfig: ConfigObject = await callCreateEngineConfig(plugin, userProvidedOverrides);
@@ -229,20 +229,19 @@ describe('Tests for the ESLintEnginePlugin', () => {
229229
}
230230
};
231231
await expect(callCreateEngineConfig(plugin, userProvidedOverrides)).rejects.toThrow(
232-
getMessage('ConfigStringValueMustMatchPattern',
233-
'engines.eslint.file_extensions.typescript[1]', 'missingDot', '^[.][a-zA-Z0-9]+$'));
232+
getMessageFromCatalog(SHARED_MESSAGE_CATALOG, 'ConfigValueMustMatchRegExp',
233+
'engines.eslint.file_extensions.typescript[1]', '/^[.][a-zA-Z0-9]+$/'));
234234
});
235235

236-
it('When an extension is listed in more than one *_file_extensions field, then createEngineConfig errors', async () => {
236+
it('When an extension is listed under more than one language within the file_extensions object, then createEngineConfig errors', async () => {
237237
const userProvidedOverrides: ConfigObject = {
238238
file_extensions: {
239239
typescript: ['.ts', '.js']
240240
}
241241
};
242242
await expect(callCreateEngineConfig(plugin, userProvidedOverrides)).rejects.toThrow(
243-
getMessage('ConfigStringArrayValuesMustNotShareElements',
244-
` engines.eslint.file_extensions.javascript: [".js",".cjs",".mjs"]\n` +
245-
` engines.eslint.file_extensions.typescript: [".ts",".js"]`));
243+
getMessage('InvalidFileExtensionDueToItBeingListedTwice',
244+
'engines.eslint.file_extensions', '.js', '["javascript","typescript"]'));
246245
});
247246

248247
it('When createEngine is passed an invalid engine name, then an error is thrown', async () => {

packages/code-analyzer-pmd-engine/pmd-cpd-wrappers/src/main/java/com/salesforce/sfca/cpdwrapper/CpdRunner.java

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import net.sourceforge.pmd.cpd.Mark;
1010
import net.sourceforge.pmd.cpd.Match;
1111
import net.sourceforge.pmd.lang.Language;
12+
import net.sourceforge.pmd.lang.LanguageVersion;
1213
import net.sourceforge.pmd.reporting.Report;
1314
import net.sourceforge.pmd.util.log.PmdReporter;
1415
import org.slf4j.event.Level;
@@ -53,16 +54,23 @@ private CpdLanguageRunResults runLanguage(String language, List<Path> pathsToSca
5354
System.out.println("Running CPD for language '" + language + "' with " + pathsToScan.size() +
5455
" file(s) to scan using minimumTokens=" + minimumTokens + " and skipDuplicateFiles=" + skipDuplicateFiles + ".");
5556

56-
// Note that the name "minimumTokens" comes from the public facing documentation and the cli but
57-
// behind the scenes, it maps to MinimumTileSize. To learn more about the mappings to the config, see:
58-
// https://github.com/pmd/pmd/blob/main/pmd-cli/src/main/java/net/sourceforge/pmd/cli/commands/internal/CpdCommand.java
5957
CPDConfiguration config = new CPDConfiguration();
58+
59+
// Force the language so that pmd doesn't look at file extensions. Note: we already associated the files based
60+
// on their file extensions to the correct languages the typescript side.
6061
Language cpdLanguageId = config.getLanguageRegistry().getLanguageById(language);
6162
if (cpdLanguageId == null) {
6263
throw new RuntimeException("The language \"" + language + "\" is not recognized by CPD.");
6364
}
64-
config.setOnlyRecognizeLanguage(cpdLanguageId);
65+
LanguageVersion forcedLangVer = config.getLanguageVersionDiscoverer()
66+
.getDefaultLanguageVersion(cpdLanguageId);
67+
config.setForceLanguageVersion(forcedLangVer);
68+
69+
// Note that the name "minimumTokens" comes from the public facing documentation and the cli but
70+
// behind the scenes, it maps to MinimumTileSize. To learn more about the mappings to the config, see:
71+
// https://github.com/pmd/pmd/blob/main/pmd-cli/src/main/java/net/sourceforge/pmd/cli/commands/internal/CpdCommand.java
6572
config.setMinimumTileSize(minimumTokens);
73+
6674
config.setInputPathList(pathsToScan);
6775
config.setSkipDuplicates(skipDuplicateFiles);
6876
CpdErrorListener errorListener = new CpdErrorListener();

0 commit comments

Comments
 (0)