diff --git a/package.json b/package.json index 99a7b9b3fc..43719fce90 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@salesforce/kit": "^1.9.2", "@salesforce/ts-types": "^1.7.2", "archiver": "^5.3.1", + "fast-levenshtein": "^3.0.0", "fast-xml-parser": "^4.1.4", "got": "^11.8.6", "graceful-fs": "^4.2.11", @@ -47,6 +48,7 @@ "@salesforce/ts-sinon": "^1.4.6", "@types/archiver": "^5.3.1", "@types/deep-equal-in-any-order": "^1.0.1", + "@types/fast-levenshtein": "^0.0.2", "@types/mime": "2.0.3", "@types/minimatch": "^5.1.2", "@types/proxy-from-env": "^1.0.1", @@ -187,4 +189,4 @@ "output": [] } } -} \ No newline at end of file +} diff --git a/src/registry/registryAccess.ts b/src/registry/registryAccess.ts index ce98853200..02f7c415a9 100644 --- a/src/registry/registryAccess.ts +++ b/src/registry/registryAccess.ts @@ -5,6 +5,7 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import { Messages, SfError } from '@salesforce/core'; +import * as Levenshtein from 'fast-levenshtein'; import { registry as defaultRegistry } from './registry'; import { MetadataRegistry, MetadataType } from './types'; @@ -60,12 +61,41 @@ export class RegistryAccess { * @returns The corresponding metadata type object */ public getTypeBySuffix(suffix: string): MetadataType | undefined { - if (this.registry.suffixes?.[suffix]) { + if (this.registry.suffixes[suffix]) { const typeId = this.registry.suffixes[suffix]; return this.getTypeByName(typeId); } } + /** + * Find similar metadata type matches by its file suffix + * + * @param suffix - File suffix of the metadata type + * @returns An array of similar suffix and metadata type matches + */ + public guessTypeBySuffix( + suffix: string + ): Array<{ suffixGuess: string; metadataTypeGuess: MetadataType }> | undefined { + const registryKeys = Object.keys(this.registry.suffixes); + + const scores = registryKeys.map((registryKey) => ({ registryKey, score: Levenshtein.get(suffix, registryKey) })); + const sortedScores = scores.sort((a, b) => a.score - b.score); + const lowestScore = sortedScores[0].score; + // Levenshtein uses positive integers for scores, find all scores that match the lowest score + const guesses = sortedScores.filter((score) => score.score === lowestScore); + + if (guesses.length > 0) { + return guesses.map((guess) => { + const typeId = this.registry.suffixes[guess.registryKey]; + const metadataType = this.getTypeByName(typeId); + return { + suffixGuess: guess.registryKey, + metadataTypeGuess: metadataType, + }; + }); + } + } + /** * Searches for the first metadata type in the registry that returns `true` * for the given predicate function. diff --git a/src/registry/types.ts b/src/registry/types.ts index e7714946d5..506dede45d 100644 --- a/src/registry/types.ts +++ b/src/registry/types.ts @@ -11,7 +11,7 @@ */ export interface MetadataRegistry { types: TypeIndex; - suffixes?: SuffixIndex; + suffixes: SuffixIndex; strictDirectoryNames: { [directoryName: string]: string; }; diff --git a/src/resolve/metadataResolver.ts b/src/resolve/metadataResolver.ts index c41f3ab1e5..4077a7a9ce 100644 --- a/src/resolve/metadataResolver.ts +++ b/src/resolve/metadataResolver.ts @@ -5,7 +5,7 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import { basename, dirname, join, sep } from 'path'; -import { Lifecycle, Messages, SfError } from '@salesforce/core'; +import { Lifecycle, Messages, SfError, Logger } from '@salesforce/core'; import { extName, parentName, parseMetadataXml } from '../utils'; import { MetadataType, RegistryAccess } from '../registry'; import { ComponentSet } from '../collections'; @@ -25,6 +25,7 @@ const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sd */ export class MetadataResolver { public forceIgnoredPaths: Set; + protected logger: Logger; private forceIgnore?: ForceIgnore; private sourceAdapterFactory: SourceAdapterFactory; private folderContentTypeDirNames?: string[]; @@ -38,6 +39,7 @@ export class MetadataResolver { private tree: TreeContainer = new NodeFSTreeContainer(), private useFsForceIgnore = true ) { + this.logger = Logger.childFromRoot(this.constructor.name); this.sourceAdapterFactory = new SourceAdapterFactory(this.registry, tree); this.forceIgnoredPaths = new Set(); } @@ -145,7 +147,19 @@ export class MetadataResolver { function: 'resolveComponent', path: fsPath, }); - throw new SfError(messages.getMessage('error_could_not_infer_type', [fsPath]), 'TypeInferenceError'); + + // If a file ends with .xml and is not a metadata type, it is likely a package manifest + // In the past, these were "resolved" as EmailServicesFunction. See note on "attempt 3" in resolveType() below. + if (fsPath.endsWith('.xml') && !fsPath.endsWith(META_XML_SUFFIX)) { + this.logger.debug(`Could not resolve type for ${fsPath}. It is likely a package manifest. Moving on.`); + return undefined; + } + + // The metadata type could not be inferred + // Attempt to guess the type and throw an error with actions + const actions = this.getSuggestionsForUnresolvedTypes(fsPath); + + throw new SfError(messages.getMessage('error_could_not_infer_type', [fsPath]), 'TypeInferenceError', actions); } private resolveTypeFromStrictFolder(fsPath: string): MetadataType | undefined { @@ -202,6 +216,15 @@ export class MetadataResolver { // attempt 3 - try treating the file extension name as a suffix if (!resolvedType) { resolvedType = this.registry.getTypeBySuffix(extName(fsPath)); + + // Metadata types with `strictDirectoryName` should have been caught in "attempt 1". + // If the metadata returned from this lookup has a `strictDirectoryName`, something is wrong. + // It is likely that the metadata file is misspelled or has the wrong suffix. + // A common occurrence is that a misspelled metadata file will fall back to + // `EmailServicesFunction` because that is the default for the `.xml` suffix + if (resolvedType?.strictDirectoryName === true) { + resolvedType = undefined; + } } // attempt 4 - try treating the content as metadata @@ -215,6 +238,66 @@ export class MetadataResolver { return resolvedType; } + /** + * Attempt to find similar types for types that could not be inferred + * To be used after executing the resolveType() method + * + * @param fsPath + * @returns an array of suggestions + */ + private getSuggestionsForUnresolvedTypes(fsPath: string): string[] { + const actions = []; + + const parsedMetaXml = parseMetadataXml(fsPath); + + // Analogous to "attempt 2" above + // Attempt to guess the metadata suffix by finding a close match + if (parsedMetaXml?.suffix) { + const results = this.registry.guessTypeBySuffix(parsedMetaXml.suffix); + + if (results) { + actions.push( + `A search for the ".${parsedMetaXml.suffix}-meta.xml" metadata suffix found the following close match${ + results.length > 1 ? 'es' : '' + }:` + ); + results.forEach((result) => { + actions.push( + `- Did you mean ".${result.suffixGuess}-meta.xml" instead for the "${result.metadataTypeGuess.name}" metadata type?` + ); + }); + // check the file name, check the extension, check the folder and here is the registry + } + } + + // Analogous to "attempt 3" above + // Attempt to guess the filename suffix by finding a close match + if (actions.length === 0 && !fsPath.includes('-meta.xml')) { + const fileExtension = extName(fsPath); + const results = this.registry.guessTypeBySuffix(fileExtension); + if (results) { + actions.push( + `A search for the ".${fileExtension}" filename suffix found the following close match${ + results.length > 1 ? 'es' : '' + }:` + ); + results.forEach((result) => { + actions.push( + `- Did you mean ".${result.suffixGuess}" instead for the "${result.metadataTypeGuess.name}" metadata type?` + ); + }); + } + } + + if (actions.length > 0) { + actions.push( + '\nAdditional suggestions:\nConfirm the file name, extension, and directory names are correct. Validate against the registry at:\nhttps://github.com/forcedotcom/source-deploy-retrieve/blob/main/src/registry/metadataRegistry.json' + ); + } + + return actions; + } + /** * Whether or not a directory that represents a single component should be resolved as one, * or if it should be walked for additional components. diff --git a/test/nuts/suggestType/suggestType.nut.ts b/test/nuts/suggestType/suggestType.nut.ts new file mode 100644 index 0000000000..983dfcb5bc --- /dev/null +++ b/test/nuts/suggestType/suggestType.nut.ts @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2020, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import * as path from 'path'; +import { TestSession } from '@salesforce/cli-plugins-testkit'; +import { expect } from 'chai'; +import { SfError } from '@salesforce/core'; +import { ComponentSetBuilder } from '../../../src'; + +describe('suggest types', () => { + let session: TestSession; + + before(async () => { + session = await TestSession.create({ + project: { + sourceDir: path.join('test', 'nuts', 'suggestType', 'testProj'), + }, + devhubAuthStrategy: 'NONE', + }); + }); + + after(async () => { + await session?.clean(); + }); + + it('it offers a suggestions on an invalid type', async () => { + try { + await ComponentSetBuilder.build({ + sourcepath: [path.join(session.project.dir, 'force-app', 'main', 'default', 'objects')], + }); + throw new Error('This test should have thrown'); + } catch (err) { + const error = err as SfError; + expect(error.name).to.equal('TypeInferenceError'); + expect(error.actions).to.include( + 'A search for the ".objct-meta.xml" metadata suffix found the following close match:' + ); + expect(error.actions).to.include( + '- Did you mean ".object-meta.xml" instead for the "CustomObject" metadata type?' + ); + } + }); + + it('it offers a suggestions on a incorrect casing', async () => { + try { + await ComponentSetBuilder.build({ + sourcepath: [path.join(session.project.dir, 'force-app', 'main', 'default', 'layouts')], + }); + throw new Error('This test should have thrown'); + } catch (err) { + const error = err as SfError; + expect(error.name).to.equal('TypeInferenceError'); + expect(error.actions).to.include( + 'A search for the ".Layout-meta.xml" metadata suffix found the following close match:' + ); + expect(error.actions).to.include('- Did you mean ".layout-meta.xml" instead for the "Layout" metadata type?'); + } + }); + + it('it offers multiple suggestions if Levenshtein distance is the same', async () => { + try { + await ComponentSetBuilder.build({ + sourcepath: [path.join(session.project.dir, 'force-app', 'main', 'default', 'tabs')], + }); + throw new Error('This test should have thrown'); + } catch (err) { + const error = err as SfError; + expect(error.name).to.equal('TypeInferenceError'); + expect(error.actions).to.include( + 'A search for the ".tabsss-meta.xml" metadata suffix found the following close matches:' + ); + expect(error.actions).to.include( + '- Did you mean ".labels-meta.xml" instead for the "CustomLabels" metadata type?' + ); + expect(error.actions).to.include('- Did you mean ".tab-meta.xml" instead for the "CustomTab" metadata type?'); + } + }); + + it('it ignores package manifest files', async () => { + const cs = await ComponentSetBuilder.build({ sourcepath: [path.join(session.project.dir, 'package-manifest')] }); + expect(cs['components'].size).to.equal(0); + }); +}); diff --git a/test/nuts/suggestType/testProj/config/project-scratch-def.json b/test/nuts/suggestType/testProj/config/project-scratch-def.json new file mode 100644 index 0000000000..c8b8be34c3 --- /dev/null +++ b/test/nuts/suggestType/testProj/config/project-scratch-def.json @@ -0,0 +1,13 @@ +{ + "orgName": "ewillhoit company", + "edition": "Developer", + "features": ["EnableSetPasswordInApi"], + "settings": { + "lightningExperienceSettings": { + "enableS1DesktopEnabled": true + }, + "mobileSettings": { + "enableS1EncryptedStoragePref2": false + } + } +} diff --git a/test/nuts/suggestType/testProj/force-app/main/default/layouts/MyTestObject__c-MyTestObject Layout.Layout-meta.xml b/test/nuts/suggestType/testProj/force-app/main/default/layouts/MyTestObject__c-MyTestObject Layout.Layout-meta.xml new file mode 100644 index 0000000000..a4c98b2f54 --- /dev/null +++ b/test/nuts/suggestType/testProj/force-app/main/default/layouts/MyTestObject__c-MyTestObject Layout.Layout-meta.xml @@ -0,0 +1,55 @@ + + + + false + false + true + + + + Required + Name + + + + + Edit + OwnerId + + + + + + false + false + true + + + + Readonly + CreatedById + + + + + Readonly + LastModifiedById + + + + + + false + false + true + + + + + + false + false + false + false + false + diff --git a/test/nuts/suggestType/testProj/force-app/main/default/objects/MyTestObject__c/MyTestObject__c.objct-meta.xml b/test/nuts/suggestType/testProj/force-app/main/default/objects/MyTestObject__c/MyTestObject__c.objct-meta.xml new file mode 100644 index 0000000000..28db778254 --- /dev/null +++ b/test/nuts/suggestType/testProj/force-app/main/default/objects/MyTestObject__c/MyTestObject__c.objct-meta.xml @@ -0,0 +1,165 @@ + + + + Accept + Default + + + Accept + Large + Default + + + Accept + Small + Default + + + CancelEdit + Default + + + CancelEdit + Large + Default + + + CancelEdit + Small + Default + + + Clone + Default + + + Clone + Large + Default + + + Clone + Small + Default + + + Delete + Default + + + Delete + Large + Default + + + Delete + Small + Default + + + Edit + Default + + + Edit + Large + Default + + + Edit + Small + Default + + + List + Default + + + List + Large + Default + + + List + Small + Default + + + New + Default + + + New + Large + Default + + + New + Small + Default + + + SaveEdit + Default + + + SaveEdit + Large + Default + + + SaveEdit + Small + Default + + + Tab + Default + + + Tab + Large + Default + + + Tab + Small + Default + + + View + Default + + + View + Large + Default + + + View + Small + Default + + false + SYSTEM + Deployed + false + true + false + false + false + false + false + true + true + Private + + + + Text + + MyTestObject + + ReadWrite + Public + diff --git a/test/nuts/suggestType/testProj/force-app/main/default/tabs/Settings.tabsss-meta.xml b/test/nuts/suggestType/testProj/force-app/main/default/tabs/Settings.tabsss-meta.xml new file mode 100644 index 0000000000..e93d3c8faf --- /dev/null +++ b/test/nuts/suggestType/testProj/force-app/main/default/tabs/Settings.tabsss-meta.xml @@ -0,0 +1,7 @@ + + + Created by Lightning App Builder + Settings + + Custom19: Wrench + diff --git a/test/nuts/suggestType/testProj/package-manifest/package.xml b/test/nuts/suggestType/testProj/package-manifest/package.xml new file mode 100644 index 0000000000..e4f345f282 --- /dev/null +++ b/test/nuts/suggestType/testProj/package-manifest/package.xml @@ -0,0 +1,8 @@ + + + + label_one + CustomLabel + + 55.0 + \ No newline at end of file diff --git a/test/nuts/suggestType/testProj/sfdx-project.json b/test/nuts/suggestType/testProj/sfdx-project.json new file mode 100644 index 0000000000..0cb02e4e82 --- /dev/null +++ b/test/nuts/suggestType/testProj/sfdx-project.json @@ -0,0 +1,12 @@ +{ + "packageDirectories": [ + { + "path": "force-app", + "default": true + } + ], + "name": "testProj", + "namespace": "", + "sfdcLoginUrl": "https://login.salesforce.com", + "sourceApiVersion": "57.0" +} diff --git a/yarn.lock b/yarn.lock index 9df0e38364..e22ae4ce9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1126,6 +1126,11 @@ resolved "https://registry.yarnpkg.com/@types/expect/-/expect-1.20.4.tgz#8288e51737bf7e3ab5d7c77bfa695883745264e5" integrity sha512-Q5Vn3yjTDyCMV50TB6VRIbQNxSE4OmZR86VSbGaNpfUolm0iePBB4KdEEHmxoY5sT2+2DIvXW0rvMDP2nHZ4Mg== +"@types/fast-levenshtein@^0.0.2": + version "0.0.2" + resolved "https://registry.yarnpkg.com/@types/fast-levenshtein/-/fast-levenshtein-0.0.2.tgz#9f618cff4469da8df46c9ee91b1c95b9af1d8f6a" + integrity sha512-h9AGeNlFimLtFUlEZgk+hb3LUT4tNHu8y0jzCUeTdi1BM4e86sBQs/nQYgHk70ksNyNbuLwpymFAXkb0GAehmw== + "@types/glob@~7.2.0": version "7.2.0" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb"