From d652e2c3b66329d1c186342923b1988d21c61f56 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Fri, 17 Jun 2022 13:48:32 -0700 Subject: [PATCH 1/7] Support path completions for exports wildcards --- src/compiler/moduleNameResolver.ts | 2 +- src/services/stringCompletions.ts | 53 ++++++++++++------- ...hCompletionsPackageJsonExportsWildcard1.ts | 46 ++++++++++++++++ ...hCompletionsPackageJsonExportsWildcard2.ts | 28 ++++++++++ 4 files changed, 110 insertions(+), 19 deletions(-) create mode 100644 tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard1.ts create mode 100644 tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard2.ts diff --git a/src/compiler/moduleNameResolver.ts b/src/compiler/moduleNameResolver.ts index be1a09548fa6a..8fcd2f051db9f 100644 --- a/src/compiler/moduleNameResolver.ts +++ b/src/compiler/moduleNameResolver.ts @@ -2361,7 +2361,7 @@ namespace ts { } /* @internal */ - export function isApplicableVersionedTypesKey(conditions: string[], key: string) { + export function isApplicableVersionedTypesKey(conditions: readonly string[], key: string) { if (conditions.indexOf("types") === -1) return false; // only apply versioned types conditions if the types condition is applied if (!startsWith(key, "types@")) return false; const range = VersionRange.tryParse(key.substring("types@".length)); diff --git a/src/services/stringCompletions.ts b/src/services/stringCompletions.ts index 39ae23fe4b46d..b4f54fa72b1a7 100644 --- a/src/services/stringCompletions.ts +++ b/src/services/stringCompletions.ts @@ -343,13 +343,14 @@ namespace ts.Completions.StringCompletions { function getStringLiteralCompletionsFromModuleNamesWorker(sourceFile: SourceFile, node: LiteralExpression, compilerOptions: CompilerOptions, host: LanguageServiceHost, typeChecker: TypeChecker, preferences: UserPreferences): readonly NameAndKind[] { const literalValue = normalizeSlashes(node.text); + const mode = isStringLiteralLike(node) ? getModeForUsageLocation(sourceFile, node) : undefined; const scriptPath = sourceFile.path; const scriptDirectory = getDirectoryPath(scriptPath); return isPathRelativeToScript(literalValue) || !compilerOptions.baseUrl && (isRootedDiskPath(literalValue) || isUrl(literalValue)) ? getCompletionEntriesForRelativeModules(literalValue, scriptDirectory, compilerOptions, host, scriptPath, getIncludeExtensionOption()) - : getCompletionEntriesForNonRelativeModules(literalValue, scriptDirectory, compilerOptions, host, typeChecker); + : getCompletionEntriesForNonRelativeModules(literalValue, scriptDirectory, mode, compilerOptions, host, typeChecker); function getIncludeExtensionOption() { const mode = isStringLiteralLike(node) ? getModeForUsageLocation(sourceFile, node) : undefined; @@ -577,7 +578,7 @@ namespace ts.Completions.StringCompletions { * Modules from node_modules (i.e. those listed in package.json) * This includes all files that are found in node_modules/moduleName/ with acceptable file extensions */ - function getCompletionEntriesForNonRelativeModules(fragment: string, scriptPath: string, compilerOptions: CompilerOptions, host: LanguageServiceHost, typeChecker: TypeChecker): readonly NameAndKind[] { + function getCompletionEntriesForNonRelativeModules(fragment: string, scriptPath: string, mode: SourceFile["impliedNodeFormat"], compilerOptions: CompilerOptions, host: LanguageServiceHost, typeChecker: TypeChecker): readonly NameAndKind[] { const { baseUrl, paths } = compilerOptions; const result: NameAndKind[] = []; @@ -644,26 +645,27 @@ namespace ts.Completions.StringCompletions { } const keys = getOwnKeys(exports); const fragmentSubpath = components.join("/"); - const processedKeys = mapDefined(keys, k => { - if (k === ".") return undefined; - if (!startsWith(k, "./")) return undefined; + for (const k of keys) { + if (k === ".") continue; + if (!startsWith(k, "./")) continue; const subpath = k.substring(2); - if (!startsWith(subpath, fragmentSubpath)) return undefined; + if (!startsWith(subpath, fragmentSubpath)) continue; // subpath is a valid export (barring conditions, which we don't currently check here) if (!stringContains(subpath, "*")) { - return subpath; - } - // pattern export - only return everything up to the `*`, so the user can autocomplete, then - // keep filling in the pattern (we could speculatively return a list of options by hitting disk, - // but conditions will make that somewhat awkward, as each condition may have a different set of possible - // options for the `*`. - return subpath.slice(0, subpath.indexOf("*")); - }); - forEach(processedKeys, k => { - if (k) { result.push(nameAndKind(k, ScriptElementKind.externalModuleName, /*extension*/ undefined)); + continue; + } + // pattern export + const pattern = getPatternFromFirstMatchingCondition(exports[k], mode === ModuleKind.ESNext ? ["node", "import", "types"] : ["node", "require", "types"]); + if (pattern) { + result.push(...getCompletionsForPathMapping(k, [pattern], fragmentSubpath, getDirectoryPath(packageFile), extensionOptions.extensions, host)); + continue; + } + + if (subpath.indexOf("*") > 0) { + result.push(nameAndKind(subpath.slice(0, subpath.indexOf("*")), ScriptElementKind.externalModuleName, /*extension*/ undefined)); } - }); + } return; } } @@ -677,6 +679,20 @@ namespace ts.Completions.StringCompletions { return result; } + function getPatternFromFirstMatchingCondition(target: unknown, conditions: readonly string[]): string | undefined { + if (typeof target === "string") { + return target; + } + if (target && typeof target === "object" && !isArray(target)) { + for (const condition in target) { + if (condition === "default" || conditions.indexOf(condition) > -1 || isApplicableVersionedTypesKey(conditions, condition)) { + const pattern = (target as MapLike)[condition]; + return typeof pattern === "string" ? pattern : undefined; + } + } + } + } + function getFragmentDirectory(fragment: string): string | undefined { return containsSlash(fragment) ? hasTrailingDirectorySeparator(fragment) ? fragment : getDirectoryPath(fragment) : undefined; } @@ -689,6 +705,7 @@ namespace ts.Completions.StringCompletions { return !stringContains(path, "*") ? justPathMappingName(path) : emptyArray; } + path = path.replace(/^\.\//, ""); // remove leading "./" const pathPrefix = path.slice(0, path.length - 1); const remainingFragment = tryRemovePrefix(fragment, pathPrefix); if (remainingFragment === undefined) { @@ -738,7 +755,7 @@ namespace ts.Completions.StringCompletions { const matches = mapDefined(tryReadDirectory(host, baseDirectory, fileExtensions, /*exclude*/ undefined, [includeGlob]), match => { const extension = tryGetExtensionFromPath(match); const name = trimPrefixAndSuffix(match); - return name === undefined ? undefined : nameAndKind(removeFileExtension(name), ScriptElementKind.scriptElement, extension); + return name === undefined ? undefined : nameAndKind(removeFileExtension(name), ScriptElementKind.externalModuleName, extension); }); const directories = mapDefined(tryGetDirectories(host, baseDirectory).map(d => combinePaths(baseDirectory, d)), dir => { const name = trimPrefixAndSuffix(dir); diff --git a/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard1.ts b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard1.ts new file mode 100644 index 0000000000000..fcc1ab9171eab --- /dev/null +++ b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard1.ts @@ -0,0 +1,46 @@ +/// + +// @module: nodenext + +// @Filename: /node_modules/foo/package.json +//// { +//// "name": "foo", +//// "main": "dist/index.js", +//// "module": "dist/index.mjs", +//// "types": "dist/index.d.ts", +//// "exports": { +//// ".": { +//// "types": "./dist/index.d.ts", +//// "import": "./dist/index.mjs", +//// "default": "./dist/index.js" +//// }, +//// "./*": { +//// "types": "./dist/*.d.ts", +//// "import": "./dist/*.mjs", +//// "default": "./dist/*.js" +//// }, +//// "./arguments": { +//// "types": "./dist/arguments/index.d.ts", +//// "import": "./dist/arguments/index.mjs", +//// "default": "./dist/arguments/index.js" +//// } +//// } +//// } + +// @Filename: /node_modules/foo/dist/index.d.ts +//// export const index = 0; + +// @Filename: /node_modules/foo/dist/blah.d.ts +//// export const blah = 0; + +// @Filename: /node_modules/foo/dist/arguments/index.d.ts +//// export const arguments = 0; + +// @Filename: /index.mts +//// import { } from "foo//**/"; + +verify.completions({ + marker: "", + isNewIdentifierLocation: true, + exact: ["blah", "index", "arguments/index", "arguments"] +}); diff --git a/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard2.ts b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard2.ts new file mode 100644 index 0000000000000..355bdd4b5c398 --- /dev/null +++ b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard2.ts @@ -0,0 +1,28 @@ +/// + +// @module: nodenext + +// @Filename: /node_modules/salesforce-pageobjects/package.json +//// { +//// "name": "salesforce-pageobjects", +//// "version": "1.0.0", +//// "exports": { +//// "./*": { +//// "types": "./dist/*.d.ts", +//// "import": "./dist/*.mjs", +//// "default": "./dist/*.js" +//// } +//// } +//// } + +// @Filename: /node_modules/salesforce-pageobjects/dist/action/pageObjects/actionRenderer.d.ts +//// export const actionRenderer = 0; + +// @Filename: /index.mts +//// import { } from "salesforce-pageobjects//**/"; + +verify.completions({ + marker: "", + isNewIdentifierLocation: true, + exact: ["action/pageObjects/actionRenderer"] +}); From 6fd9c2b15fb12dac6f44aa383976864513fe1331 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Tue, 21 Jun 2022 14:23:35 -0700 Subject: [PATCH 2/7] Break up results by directory --- src/services/stringCompletions.ts | 81 +++++++++---------- .../fourslash/completionsPaths_pathMapping.ts | 6 +- ...etionsPaths_pathMapping_parentDirectory.ts | 2 +- ...mpletionsPaths_pathMapping_relativePath.ts | 2 +- ...hCompletionsPackageJsonExportsWildcard1.ts | 2 +- ...hCompletionsPackageJsonExportsWildcard2.ts | 16 +++- 6 files changed, 61 insertions(+), 48 deletions(-) diff --git a/src/services/stringCompletions.ts b/src/services/stringCompletions.ts index b4f54fa72b1a7..49b17935e5ae1 100644 --- a/src/services/stringCompletions.ts +++ b/src/services/stringCompletions.ts @@ -372,7 +372,7 @@ namespace ts.Completions.StringCompletions { compilerOptions.rootDirs, literalValue, scriptDirectory, extensionOptions, compilerOptions, host, scriptPath); } else { - return getCompletionEntriesForDirectoryFragment(literalValue, scriptDirectory, extensionOptions, host, scriptPath); + return arrayFrom(getCompletionEntriesForDirectoryFragment(literalValue, scriptDirectory, extensionOptions, host, scriptPath).values()); } } @@ -417,7 +417,7 @@ namespace ts.Completions.StringCompletions { const basePath = compilerOptions.project || host.getCurrentDirectory(); const ignoreCase = !(host.useCaseSensitiveFileNames && host.useCaseSensitiveFileNames()); const baseDirectories = getBaseDirectoriesFromRootDirs(rootDirs, basePath, scriptDirectory, ignoreCase); - return flatMap(baseDirectories, baseDirectory => getCompletionEntriesForDirectoryFragment(fragment, baseDirectory, extensionOptions, host, exclude)); + return flatMap(baseDirectories, baseDirectory => arrayFrom(getCompletionEntriesForDirectoryFragment(fragment, baseDirectory, extensionOptions, host, exclude).values())); } const enum IncludeExtensionsOption { @@ -428,7 +428,14 @@ namespace ts.Completions.StringCompletions { /** * Given a path ending at a directory, gets the completions for the path, and filters for those entries containing the basename. */ - function getCompletionEntriesForDirectoryFragment(fragment: string, scriptPath: string, { extensions, includeExtensionsOption }: ExtensionOptions, host: LanguageServiceHost, exclude?: string, result: NameAndKind[] = []): NameAndKind[] { + function getCompletionEntriesForDirectoryFragment( + fragment: string, + scriptPath: string, + { extensions, includeExtensionsOption }: ExtensionOptions, + host: LanguageServiceHost, + exclude?: string, + result = createNameAndKindSet() + ): Set { if (fragment === undefined) { fragment = ""; } @@ -508,7 +515,7 @@ namespace ts.Completions.StringCompletions { } foundFiles.forEach((ext, foundFile) => { - result.push(nameAndKind(foundFile, ScriptElementKind.scriptElement, ext)); + result.add(nameAndKind(foundFile, ScriptElementKind.scriptElement, ext)); }); } @@ -519,7 +526,7 @@ namespace ts.Completions.StringCompletions { for (const directory of directories) { const directoryName = getBaseFileName(normalizePath(directory)); if (directoryName !== "@types") { - result.push(directoryResult(directoryName)); + result.add(directoryResult(directoryName)); } } } @@ -528,7 +535,7 @@ namespace ts.Completions.StringCompletions { } /** @returns whether `fragment` was a match for any `paths` (which should indicate whether any other path completions should be offered) */ - function addCompletionEntriesFromPaths(result: NameAndKind[], fragment: string, baseDirectory: string, fileExtensions: readonly string[], paths: MapLike, host: LanguageServiceHost) { + function addCompletionEntriesFromPaths(result: Set, fragment: string, baseDirectory: string, fileExtensions: readonly string[], paths: MapLike, host: LanguageServiceHost) { let pathResults: { results: NameAndKind[], matchedPattern: boolean }[] = []; let matchedPathPrefixLength = -1; for (const path in paths) { @@ -564,13 +571,14 @@ namespace ts.Completions.StringCompletions { } } - const equatePaths = host.useCaseSensitiveFileNames?.() ? equateStringsCaseSensitive : equateStringsCaseInsensitive; - const equateResults: EqualityComparer = (a, b) => equatePaths(a.name, b.name); - pathResults.forEach(pathResult => pathResult.results.forEach(pathResult => pushIfUnique(result, pathResult, equateResults))); - + pathResults.forEach(pathResult => pathResult.results.forEach(r => result.add(r))); return matchedPathPrefixLength > -1; } + function createNameAndKindSet() { + return createSet(element => element.name.charCodeAt(0), (a, b) => a.name === b.name); + } + /** * Check all of the declared modules and those in node modules. Possible sources of modules: * Modules that are found by the type checker @@ -581,8 +589,7 @@ namespace ts.Completions.StringCompletions { function getCompletionEntriesForNonRelativeModules(fragment: string, scriptPath: string, mode: SourceFile["impliedNodeFormat"], compilerOptions: CompilerOptions, host: LanguageServiceHost, typeChecker: TypeChecker): readonly NameAndKind[] { const { baseUrl, paths } = compilerOptions; - const result: NameAndKind[] = []; - + const result = createNameAndKindSet(); const extensionOptions = getExtensionOptions(compilerOptions); if (baseUrl) { const projectDir = compilerOptions.project || host.getCurrentDirectory(); @@ -595,7 +602,7 @@ namespace ts.Completions.StringCompletions { const fragmentDirectory = getFragmentDirectory(fragment); for (const ambientName of getAmbientModuleCompletions(fragment, fragmentDirectory, typeChecker)) { - result.push(nameAndKind(ambientName, ScriptElementKind.externalModuleName, /*extension*/ undefined)); + result.add(nameAndKind(ambientName, ScriptElementKind.externalModuleName, /*extension*/ undefined)); } getCompletionEntriesFromTypings(host, compilerOptions, scriptPath, fragmentDirectory, extensionOptions, result); @@ -606,9 +613,10 @@ namespace ts.Completions.StringCompletions { let foundGlobal = false; if (fragmentDirectory === undefined) { for (const moduleName of enumerateNodeModulesVisibleToScript(host, scriptPath)) { - if (!result.some(entry => entry.name === moduleName)) { + const moduleResult = nameAndKind(moduleName, ScriptElementKind.externalModuleName, /*extension*/ undefined); + if (!result.has(moduleResult)) { foundGlobal = true; - result.push(nameAndKind(moduleName, ScriptElementKind.externalModuleName, /*extension*/ undefined)); + result.add(moduleResult); } } } @@ -637,34 +645,23 @@ namespace ts.Completions.StringCompletions { } const packageFile = combinePaths(ancestor, "node_modules", packagePath, "package.json"); if (tryFileExists(host, packageFile)) { - const packageJson = readJson(packageFile, host as { readFile: (filename: string) => string | undefined }); + const packageJson = readJson(packageFile, host); const exports = (packageJson as any).exports; if (exports) { if (typeof exports !== "object" || exports === null) { // eslint-disable-line no-null/no-null return; // null exports or entrypoint only, no sub-modules available } const keys = getOwnKeys(exports); - const fragmentSubpath = components.join("/"); + const fragmentSubpath = components.join("/") + (components.length && hasTrailingDirectorySeparator(fragment) ? "/" : ""); for (const k of keys) { if (k === ".") continue; if (!startsWith(k, "./")) continue; - const subpath = k.substring(2); - if (!startsWith(subpath, fragmentSubpath)) continue; - // subpath is a valid export (barring conditions, which we don't currently check here) - if (!stringContains(subpath, "*")) { - result.push(nameAndKind(k, ScriptElementKind.externalModuleName, /*extension*/ undefined)); - continue; - } - // pattern export const pattern = getPatternFromFirstMatchingCondition(exports[k], mode === ModuleKind.ESNext ? ["node", "import", "types"] : ["node", "require", "types"]); if (pattern) { - result.push(...getCompletionsForPathMapping(k, [pattern], fragmentSubpath, getDirectoryPath(packageFile), extensionOptions.extensions, host)); + getCompletionsForPathMapping(k, [pattern], fragmentSubpath, getDirectoryPath(packageFile), extensionOptions.extensions, host) + .forEach(r => result.add(r)); continue; } - - if (subpath.indexOf("*") > 0) { - result.push(nameAndKind(subpath.slice(0, subpath.indexOf("*")), ScriptElementKind.externalModuleName, /*extension*/ undefined)); - } } return; } @@ -676,7 +673,7 @@ namespace ts.Completions.StringCompletions { } } - return result; + return arrayFrom(result.values()); } function getPatternFromFirstMatchingCondition(target: unknown, conditions: readonly string[]): string | undefined { @@ -700,12 +697,12 @@ namespace ts.Completions.StringCompletions { function getCompletionsForPathMapping( path: string, patterns: readonly string[], fragment: string, baseUrl: string, fileExtensions: readonly string[], host: LanguageServiceHost, ): readonly NameAndKind[] { + path = path.replace(/^\.\//, ""); // remove leading "./" if (!endsWith(path, "*")) { // For a path mapping "foo": ["/x/y/z.ts"], add "foo" itself as a completion. return !stringContains(path, "*") ? justPathMappingName(path) : emptyArray; } - path = path.replace(/^\.\//, ""); // remove leading "./" const pathPrefix = path.slice(0, path.length - 1); const remainingFragment = tryRemovePrefix(fragment, pathPrefix); if (remainingFragment === undefined) { @@ -750,17 +747,19 @@ namespace ts.Completions.StringCompletions { // If we have a suffix, then we need to read the directory all the way down. We could create a glob // that encodes the suffix, but we would have to escape the character "?" which readDirectory // doesn't support. For now, this is safer but slower - const includeGlob = normalizedSuffix ? "**/*" : "./*"; + const includeGlob = normalizedSuffix && containsSlash(normalizedSuffix) ? "**/*" : "./*"; const matches = mapDefined(tryReadDirectory(host, baseDirectory, fileExtensions, /*exclude*/ undefined, [includeGlob]), match => { const extension = tryGetExtensionFromPath(match); const name = trimPrefixAndSuffix(match); - return name === undefined ? undefined : nameAndKind(removeFileExtension(name), ScriptElementKind.externalModuleName, extension); - }); - const directories = mapDefined(tryGetDirectories(host, baseDirectory).map(d => combinePaths(baseDirectory, d)), dir => { - const name = trimPrefixAndSuffix(dir); - return name === undefined ? undefined : directoryResult(name); + if (name) { + if (containsSlash(name)) { + return directoryResult(getPathComponents(name)[0]); + } + return nameAndKind(removeFileExtension(name), ScriptElementKind.externalModuleName, extension); + } }); + const directories = mapDefined(tryGetDirectories(host, baseDirectory), dir => dir === "node_modules" ? undefined : directoryResult(dir)); return [...matches, ...directories]; function trimPrefixAndSuffix(path: string): string | undefined { @@ -810,10 +809,10 @@ namespace ts.Completions.StringCompletions { const names = kind === "path" ? getCompletionEntriesForDirectoryFragment(toComplete, scriptPath, getExtensionOptions(compilerOptions, IncludeExtensionsOption.Include), host, sourceFile.path) : kind === "types" ? getCompletionEntriesFromTypings(host, compilerOptions, scriptPath, getFragmentDirectory(toComplete), getExtensionOptions(compilerOptions)) : Debug.fail(); - return addReplacementSpans(toComplete, range.pos + prefix.length, names); + return addReplacementSpans(toComplete, range.pos + prefix.length, arrayFrom(names.values())); } - function getCompletionEntriesFromTypings(host: LanguageServiceHost, options: CompilerOptions, scriptPath: string, fragmentDirectory: string | undefined, extensionOptions: ExtensionOptions, result: NameAndKind[] = []): readonly NameAndKind[] { + function getCompletionEntriesFromTypings(host: LanguageServiceHost, options: CompilerOptions, scriptPath: string, fragmentDirectory: string | undefined, extensionOptions: ExtensionOptions, result = createNameAndKindSet()): Set { // Check for typings specified in compiler options const seen = new Map(); @@ -840,7 +839,7 @@ namespace ts.Completions.StringCompletions { if (fragmentDirectory === undefined) { if (!seen.has(packageName)) { - result.push(nameAndKind(packageName, ScriptElementKind.externalModuleName, /*extension*/ undefined)); + result.add(nameAndKind(packageName, ScriptElementKind.externalModuleName, /*extension*/ undefined)); seen.set(packageName, true); } } diff --git a/tests/cases/fourslash/completionsPaths_pathMapping.ts b/tests/cases/fourslash/completionsPaths_pathMapping.ts index 381fe5b7d38e0..9de48b565dbc1 100644 --- a/tests/cases/fourslash/completionsPaths_pathMapping.ts +++ b/tests/cases/fourslash/completionsPaths_pathMapping.ts @@ -25,15 +25,15 @@ verify.completions( { marker: "0", exact: [ - { name: "a", kind: "script", kindModifiers: ".ts" }, - { name: "b", kind: "script", kindModifiers: ".ts" }, + { name: "a", kind: "external module name", kindModifiers: ".ts" }, + { name: "b", kind: "external module name", kindModifiers: ".ts" }, { name: "dir", kind: "directory" }, ], isNewIdentifierLocation: true, }, { marker: "1", - exact: { name: "x", kind: "script", kindModifiers: ".ts" }, + exact: { name: "x", kind: "external module name", kindModifiers: ".ts" }, isNewIdentifierLocation: true, }, ); diff --git a/tests/cases/fourslash/completionsPaths_pathMapping_parentDirectory.ts b/tests/cases/fourslash/completionsPaths_pathMapping_parentDirectory.ts index c34f6e5db88ee..fe022dbe68cb0 100644 --- a/tests/cases/fourslash/completionsPaths_pathMapping_parentDirectory.ts +++ b/tests/cases/fourslash/completionsPaths_pathMapping_parentDirectory.ts @@ -18,6 +18,6 @@ verify.completions({ marker: "", - exact: { name: "x", kind: "script", kindModifiers: ".ts" }, + exact: { name: "x", kind: "external module name", kindModifiers: ".ts" }, isNewIdentifierLocation: true, }); diff --git a/tests/cases/fourslash/completionsPaths_pathMapping_relativePath.ts b/tests/cases/fourslash/completionsPaths_pathMapping_relativePath.ts index b059a977eb9a7..b48e7ffc35845 100644 --- a/tests/cases/fourslash/completionsPaths_pathMapping_relativePath.ts +++ b/tests/cases/fourslash/completionsPaths_pathMapping_relativePath.ts @@ -21,6 +21,6 @@ verify.completions({ marker: "", - exact: ["a", "b"].map(name => ({ name, kind: "script", kindModifiers: ".ts" })), + exact: ["a", "b"].map(name => ({ name, kind: "external module name", kindModifiers: ".ts" })), isNewIdentifierLocation: true, }); diff --git a/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard1.ts b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard1.ts index fcc1ab9171eab..ea2cf5782a34a 100644 --- a/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard1.ts +++ b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard1.ts @@ -42,5 +42,5 @@ verify.completions({ marker: "", isNewIdentifierLocation: true, - exact: ["blah", "index", "arguments/index", "arguments"] + exact: ["blah", "index", "arguments"] }); diff --git a/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard2.ts b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard2.ts index 355bdd4b5c398..42259c0619815 100644 --- a/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard2.ts +++ b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard2.ts @@ -24,5 +24,19 @@ verify.completions({ marker: "", isNewIdentifierLocation: true, - exact: ["action/pageObjects/actionRenderer"] + exact: ["action"] +}); + +edit.insert("action/"); + +verify.completions({ + isNewIdentifierLocation: true, + exact: ["pageObjects"], +}); + +edit.insert("pageObjects/"); + +verify.completions({ + isNewIdentifierLocation: true, + exact: ["actionRenderer"], }); From f531be7d2c6eaaa2de88b058634437fd36ff5904 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Wed, 22 Jun 2022 13:00:05 -0700 Subject: [PATCH 3/7] Share code between typesVersions and exports processing --- src/compiler/moduleNameResolver.ts | 3 +- src/services/stringCompletions.ts | 200 +++++++++++------- ...hCompletionsPackageJsonExportsWildcard3.ts | 41 ++++ ...hCompletionsPackageJsonExportsWildcard4.ts | 52 +++++ 4 files changed, 218 insertions(+), 78 deletions(-) create mode 100644 tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard3.ts create mode 100644 tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard4.ts diff --git a/src/compiler/moduleNameResolver.ts b/src/compiler/moduleNameResolver.ts index 8fcd2f051db9f..68780ef26aab3 100644 --- a/src/compiler/moduleNameResolver.ts +++ b/src/compiler/moduleNameResolver.ts @@ -2092,10 +2092,11 @@ namespace ts { } /** + * @internal * From https://github.com/nodejs/node/blob/8f39f51cbbd3b2de14b9ee896e26421cc5b20121/lib/internal/modules/esm/resolve.js#L722 - * "longest" has some nuance as to what "longest" means in the presence of pattern trailers */ - function comparePatternKeys(a: string, b: string) { + export function comparePatternKeys(a: string, b: string) { const aPatternIndex = a.indexOf("*"); const bPatternIndex = b.indexOf("*"); const baseLenA = aPatternIndex === -1 ? a.length : aPatternIndex + 1; diff --git a/src/services/stringCompletions.ts b/src/services/stringCompletions.ts index 49b17935e5ae1..b62b23ec45e73 100644 --- a/src/services/stringCompletions.ts +++ b/src/services/stringCompletions.ts @@ -350,7 +350,7 @@ namespace ts.Completions.StringCompletions { return isPathRelativeToScript(literalValue) || !compilerOptions.baseUrl && (isRootedDiskPath(literalValue) || isUrl(literalValue)) ? getCompletionEntriesForRelativeModules(literalValue, scriptDirectory, compilerOptions, host, scriptPath, getIncludeExtensionOption()) - : getCompletionEntriesForNonRelativeModules(literalValue, scriptDirectory, mode, compilerOptions, host, typeChecker); + : getCompletionEntriesForNonRelativeModules(literalValue, scriptDirectory, mode, compilerOptions, host, getIncludeExtensionOption(), typeChecker); function getIncludeExtensionOption() { const mode = isStringLiteralLike(node) ? getModeForUsageLocation(sourceFile, node) : undefined; @@ -431,7 +431,7 @@ namespace ts.Completions.StringCompletions { function getCompletionEntriesForDirectoryFragment( fragment: string, scriptPath: string, - { extensions, includeExtensionsOption }: ExtensionOptions, + extensionOptions: ExtensionOptions, host: LanguageServiceHost, exclude?: string, result = createNameAndKindSet() @@ -469,7 +469,7 @@ namespace ts.Completions.StringCompletions { if (versionPaths) { const packageDirectory = getDirectoryPath(packageJsonPath); const pathInPackage = absolutePath.slice(ensureTrailingDirectorySeparator(packageDirectory).length); - if (addCompletionEntriesFromPaths(result, pathInPackage, packageDirectory, extensions, versionPaths, host)) { + if (addCompletionEntriesFromPaths(result, pathInPackage, packageDirectory, extensionOptions, host, versionPaths)) { // A true result means one of the `versionPaths` was matched, which will block relative resolution // to files and folders from here. All reachable paths given the pattern match are already added. return result; @@ -482,41 +482,18 @@ namespace ts.Completions.StringCompletions { if (!tryDirectoryExists(host, baseDirectory)) return result; // Enumerate the available files if possible - const files = tryReadDirectory(host, baseDirectory, extensions, /*exclude*/ undefined, /*include*/ ["./*"]); + const files = tryReadDirectory(host, baseDirectory, extensionOptions.extensions, /*exclude*/ undefined, /*include*/ ["./*"]); if (files) { - /** - * Multiple file entries might map to the same truncated name once we remove extensions - * (happens iff includeExtensionsOption === includeExtensionsOption.Exclude) so we use a set-like data structure. Eg: - * - * both foo.ts and foo.tsx become foo - */ - const foundFiles = new Map(); // maps file to its extension for (let filePath of files) { filePath = normalizePath(filePath); if (exclude && comparePaths(filePath, exclude, scriptPath, ignoreCase) === Comparison.EqualTo) { continue; } - let foundFileName: string; - const outputExtension = moduleSpecifiers.tryGetJSExtensionForFile(filePath, host.getCompilationSettings()); - if (includeExtensionsOption === IncludeExtensionsOption.Exclude && !fileExtensionIsOneOf(filePath, [Extension.Json, Extension.Mts, Extension.Cts, Extension.Dmts, Extension.Dcts, Extension.Mjs, Extension.Cjs])) { - foundFileName = removeFileExtension(getBaseFileName(filePath)); - foundFiles.set(foundFileName, tryGetExtensionFromPath(filePath)); - } - else if ((fileExtensionIsOneOf(filePath, [Extension.Mts, Extension.Cts, Extension.Dmts, Extension.Dcts, Extension.Mjs, Extension.Cjs]) || includeExtensionsOption === IncludeExtensionsOption.ModuleSpecifierCompletion) && outputExtension) { - foundFileName = changeExtension(getBaseFileName(filePath), outputExtension); - foundFiles.set(foundFileName, outputExtension); - } - else { - foundFileName = getBaseFileName(filePath); - foundFiles.set(foundFileName, tryGetExtensionFromPath(filePath)); - } + const { name, extension } = getFilenameWithExtensionOption(getBaseFileName(filePath), host.getCompilationSettings(), extensionOptions.includeExtensionsOption); + result.add(nameAndKind(name, ScriptElementKind.externalModuleName, extension)); } - - foundFiles.forEach((ext, foundFile) => { - result.add(nameAndKind(foundFile, ScriptElementKind.scriptElement, ext)); - }); } // If possible, get folder completion as well @@ -534,18 +511,61 @@ namespace ts.Completions.StringCompletions { return result; } + function getFilenameWithExtensionOption(name: string, compilerOptions: CompilerOptions, includeExtensionsOption: IncludeExtensionsOption): { name: string, extension: Extension | undefined } { + const outputExtension = moduleSpecifiers.tryGetJSExtensionForFile(name, compilerOptions); + if (includeExtensionsOption === IncludeExtensionsOption.Exclude && !fileExtensionIsOneOf(name, [Extension.Json, Extension.Mts, Extension.Cts, Extension.Dmts, Extension.Dcts, Extension.Mjs, Extension.Cjs])) { + return { name: removeFileExtension(name), extension: tryGetExtensionFromPath(name) }; + } + else if ((fileExtensionIsOneOf(name, [Extension.Mts, Extension.Cts, Extension.Dmts, Extension.Dcts, Extension.Mjs, Extension.Cjs]) || includeExtensionsOption === IncludeExtensionsOption.ModuleSpecifierCompletion) && outputExtension) { + return { name: changeExtension(name, outputExtension), extension: outputExtension }; + } + else { + return { name, extension: tryGetExtensionFromPath(name) }; + } + } + + /** @returns whether `fragment` was a match for any `paths` (which should indicate whether any other path completions should be offered) */ + function addCompletionEntriesFromPaths( + result: Set, + fragment: string, + baseDirectory: string, + extensionOptions: ExtensionOptions, + host: LanguageServiceHost, + paths: MapLike + ) { + const getPatternsForKey = (key: string) => paths[key]; + const comparePaths = (a: string, b: string): Comparison => { + const patternA = tryParsePattern(a); + const patternB = tryParsePattern(b); + const lengthA = typeof patternA === "object" ? patternA.prefix.length : a.length; + const lengthB = typeof patternB === "object" ? patternB.prefix.length : b.length; + return compareValues(lengthB, lengthA); + }; + return addCompletionEntriesFromPathsOrExports(result, fragment, baseDirectory, extensionOptions, host, getOwnKeys(paths), getPatternsForKey, comparePaths); + } + /** @returns whether `fragment` was a match for any `paths` (which should indicate whether any other path completions should be offered) */ - function addCompletionEntriesFromPaths(result: Set, fragment: string, baseDirectory: string, fileExtensions: readonly string[], paths: MapLike, host: LanguageServiceHost) { + function addCompletionEntriesFromPathsOrExports( + result: Set, + fragment: string, + baseDirectory: string, + extensionOptions: ExtensionOptions, + host: LanguageServiceHost, + keys: readonly string[], + getPatternsForKey: (key: string) => string[] | undefined, + comparePaths: (a: string, b: string) => Comparison, + ) { let pathResults: { results: NameAndKind[], matchedPattern: boolean }[] = []; - let matchedPathPrefixLength = -1; - for (const path in paths) { - if (!hasProperty(paths, path)) continue; - const patterns = paths[path]; + let matchedPath: string | undefined; + for (const key of keys) { + if (key === ".") continue; + const keyWithoutLeadingDotSlash = key.replace(/^\.\//, ""); // remove leading "./" + const patterns = getPatternsForKey(key); if (patterns) { - const pathPattern = tryParsePattern(path); + const pathPattern = tryParsePattern(keyWithoutLeadingDotSlash); if (!pathPattern) continue; const isMatch = typeof pathPattern === "object" && isPatternMatch(pathPattern, fragment); - const isLongestMatch = isMatch && (matchedPathPrefixLength === undefined || pathPattern.prefix.length > matchedPathPrefixLength); + const isLongestMatch = isMatch && (matchedPath === undefined || comparePaths(key, matchedPath) === Comparison.LessThan); if (isLongestMatch) { // If this is a higher priority match than anything we've seen so far, previous results from matches are invalid, e.g. // for `import {} from "some-package/|"` with a typesVersions: @@ -558,13 +578,13 @@ namespace ts.Completions.StringCompletions { // added by the '*' match, after typing `"some-package/foo/|"` we would get file results from both // ./dist/foo and ./foo, when only the latter will actually be resolvable. // See pathCompletionsTypesVersionsWildcard6.ts. - matchedPathPrefixLength = pathPattern.prefix.length; + matchedPath = key; pathResults = pathResults.filter(r => !r.matchedPattern); } - if (typeof pathPattern === "string" || matchedPathPrefixLength === undefined || pathPattern.prefix.length >= matchedPathPrefixLength) { + if (typeof pathPattern === "string" || matchedPath === undefined || comparePaths(key, matchedPath) !== Comparison.GreaterThan) { pathResults.push({ matchedPattern: isMatch, - results: getCompletionsForPathMapping(path, patterns, fragment, baseDirectory, fileExtensions, host) + results: getCompletionsForPathMapping(keyWithoutLeadingDotSlash, patterns, fragment, baseDirectory, extensionOptions, host) .map(({ name, kind, extension }) => nameAndKind(name, kind, extension)), }); } @@ -572,11 +592,11 @@ namespace ts.Completions.StringCompletions { } pathResults.forEach(pathResult => pathResult.results.forEach(r => result.add(r))); - return matchedPathPrefixLength > -1; + return matchedPath !== undefined; } function createNameAndKindSet() { - return createSet(element => element.name.charCodeAt(0), (a, b) => a.name === b.name); + return createSet(element => +generateDjb2Hash(element.name), (a, b) => a.name === b.name); } /** @@ -586,17 +606,25 @@ namespace ts.Completions.StringCompletions { * Modules from node_modules (i.e. those listed in package.json) * This includes all files that are found in node_modules/moduleName/ with acceptable file extensions */ - function getCompletionEntriesForNonRelativeModules(fragment: string, scriptPath: string, mode: SourceFile["impliedNodeFormat"], compilerOptions: CompilerOptions, host: LanguageServiceHost, typeChecker: TypeChecker): readonly NameAndKind[] { + function getCompletionEntriesForNonRelativeModules( + fragment: string, + scriptPath: string, + mode: SourceFile["impliedNodeFormat"], + compilerOptions: CompilerOptions, + host: LanguageServiceHost, + includeExtensionsOption: IncludeExtensionsOption, + typeChecker: TypeChecker, + ): readonly NameAndKind[] { const { baseUrl, paths } = compilerOptions; const result = createNameAndKindSet(); - const extensionOptions = getExtensionOptions(compilerOptions); + const extensionOptions = getExtensionOptions(compilerOptions, includeExtensionsOption); if (baseUrl) { const projectDir = compilerOptions.project || host.getCurrentDirectory(); const absolute = normalizePath(combinePaths(projectDir, baseUrl)); getCompletionEntriesForDirectoryFragment(fragment, absolute, extensionOptions, host, /*exclude*/ undefined, result); if (paths) { - addCompletionEntriesFromPaths(result, fragment, absolute, extensionOptions.extensions, paths, host); + addCompletionEntriesFromPaths(result, fragment, absolute, extensionOptions, host, paths); } } @@ -643,7 +671,8 @@ namespace ts.Completions.StringCompletions { } packagePath = combinePaths(packagePath, subName); } - const packageFile = combinePaths(ancestor, "node_modules", packagePath, "package.json"); + const packageDirectory = combinePaths(ancestor, "node_modules", packagePath); + const packageFile = combinePaths(packageDirectory, "package.json"); if (tryFileExists(host, packageFile)) { const packageJson = readJson(packageFile, host); const exports = (packageJson as any).exports; @@ -653,16 +682,16 @@ namespace ts.Completions.StringCompletions { } const keys = getOwnKeys(exports); const fragmentSubpath = components.join("/") + (components.length && hasTrailingDirectorySeparator(fragment) ? "/" : ""); - for (const k of keys) { - if (k === ".") continue; - if (!startsWith(k, "./")) continue; - const pattern = getPatternFromFirstMatchingCondition(exports[k], mode === ModuleKind.ESNext ? ["node", "import", "types"] : ["node", "require", "types"]); - if (pattern) { - getCompletionsForPathMapping(k, [pattern], fragmentSubpath, getDirectoryPath(packageFile), extensionOptions.extensions, host) - .forEach(r => result.add(r)); - continue; - } - } + const conditions = mode === ModuleKind.ESNext ? ["node", "import", "types"] : ["node", "require", "types"]; + addCompletionEntriesFromPathsOrExports( + result, + fragmentSubpath, + packageDirectory, + extensionOptions, + host, + keys, + key => getPatternFromFirstMatchingCondition(exports[key], conditions), + comparePatternKeys); return; } } @@ -676,15 +705,15 @@ namespace ts.Completions.StringCompletions { return arrayFrom(result.values()); } - function getPatternFromFirstMatchingCondition(target: unknown, conditions: readonly string[]): string | undefined { + function getPatternFromFirstMatchingCondition(target: unknown, conditions: readonly string[]): [string] | undefined { if (typeof target === "string") { - return target; + return [target]; } if (target && typeof target === "object" && !isArray(target)) { for (const condition in target) { if (condition === "default" || conditions.indexOf(condition) > -1 || isApplicableVersionedTypesKey(conditions, condition)) { const pattern = (target as MapLike)[condition]; - return typeof pattern === "string" ? pattern : undefined; + return typeof pattern === "string" ? [pattern] : undefined; } } } @@ -695,9 +724,13 @@ namespace ts.Completions.StringCompletions { } function getCompletionsForPathMapping( - path: string, patterns: readonly string[], fragment: string, baseUrl: string, fileExtensions: readonly string[], host: LanguageServiceHost, + path: string, + patterns: readonly string[], + fragment: string, + packageDirectory: string, + extensionOptions: ExtensionOptions, + host: LanguageServiceHost, ): readonly NameAndKind[] { - path = path.replace(/^\.\//, ""); // remove leading "./" if (!endsWith(path, "*")) { // For a path mapping "foo": ["/x/y/z.ts"], add "foo" itself as a completion. return !stringContains(path, "*") ? justPathMappingName(path) : emptyArray; @@ -708,16 +741,22 @@ namespace ts.Completions.StringCompletions { if (remainingFragment === undefined) { const starIsFullPathComponent = path[path.length - 2] === "/"; return starIsFullPathComponent ? justPathMappingName(pathPrefix) : flatMap(patterns, pattern => - getModulesForPathsPattern("", baseUrl, pattern, fileExtensions, host)?.map(({ name, ...rest }) => ({ name: pathPrefix + name, ...rest }))); + getModulesForPathsPattern("", packageDirectory, pattern, extensionOptions, host)?.map(({ name, ...rest }) => ({ name: pathPrefix + name, ...rest }))); } - return flatMap(patterns, pattern => getModulesForPathsPattern(remainingFragment, baseUrl, pattern, fileExtensions, host)); + return flatMap(patterns, pattern => getModulesForPathsPattern(remainingFragment, packageDirectory, pattern, extensionOptions, host)); function justPathMappingName(name: string): readonly NameAndKind[] { return startsWith(name, fragment) ? [directoryResult(removeTrailingDirectorySeparator(name))] : emptyArray; } } - function getModulesForPathsPattern(fragment: string, baseUrl: string, pattern: string, fileExtensions: readonly string[], host: LanguageServiceHost): readonly NameAndKind[] | undefined { + function getModulesForPathsPattern( + fragment: string, + packageDirectory: string, + pattern: string, + extensionOptions: ExtensionOptions, + host: LanguageServiceHost, + ): readonly NameAndKind[] | undefined { if (!host.readDirectory) { return undefined; } @@ -741,25 +780,32 @@ namespace ts.Completions.StringCompletions { const normalizedSuffix = normalizePath(parsed.suffix); // Need to normalize after combining: If we combinePaths("a", "../b"), we want "b" and not "a/../b". - const baseDirectory = normalizePath(combinePaths(baseUrl, expandedPrefixDirectory)); + const baseDirectory = normalizePath(combinePaths(packageDirectory, expandedPrefixDirectory)); const completePrefix = fragmentHasPath ? baseDirectory : ensureTrailingDirectorySeparator(baseDirectory) + normalizedPrefixBase; - // If we have a suffix, then we need to read the directory all the way down. We could create a glob - // that encodes the suffix, but we would have to escape the character "?" which readDirectory - // doesn't support. For now, this is safer but slower - const includeGlob = normalizedSuffix && containsSlash(normalizedSuffix) ? "**/*" : "./*"; - - const matches = mapDefined(tryReadDirectory(host, baseDirectory, fileExtensions, /*exclude*/ undefined, [includeGlob]), match => { - const extension = tryGetExtensionFromPath(match); - const name = trimPrefixAndSuffix(match); - if (name) { - if (containsSlash(name)) { - return directoryResult(getPathComponents(name)[0]); + // If we have a suffix of at least a whole filename (i.e., not just an extension), then we need + // to read the directory all the way down to see which directories contain a file matching that suffix. + const suffixHasFilename = normalizedSuffix && containsSlash(normalizedSuffix); + const includeGlob = suffixHasFilename ? "**/*" : "./*"; + + const matches = mapDefined(tryReadDirectory(host, baseDirectory, extensionOptions.extensions, /*exclude*/ undefined, [includeGlob]), match => { + const basename = trimPrefixAndSuffix(match); + if (basename) { + if (containsSlash(basename)) { + return directoryResult(getPathComponents(basename)[0]); } - return nameAndKind(removeFileExtension(name), ScriptElementKind.externalModuleName, extension); + const { name, extension } = getFilenameWithExtensionOption(basename, host.getCompilationSettings(), extensionOptions.includeExtensionsOption); + return nameAndKind(name, ScriptElementKind.externalModuleName, extension); } }); - const directories = mapDefined(tryGetDirectories(host, baseDirectory), dir => dir === "node_modules" ? undefined : directoryResult(dir)); + + // If the suffix had a whole filename in it, we already recursively searched for all possible files + // that could match it and returned the directories that could possibly lead to matches. Otherwise, + // assume any directory could have something valid to import (not a great assumption - we could + // consider doing a whole glob more often and caching the results?) + const directories = suffixHasFilename + ? emptyArray + : mapDefined(tryGetDirectories(host, baseDirectory), dir => dir === "node_modules" ? undefined : directoryResult(dir)); return [...matches, ...directories]; function trimPrefixAndSuffix(path: string): string | undefined { diff --git a/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard3.ts b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard3.ts new file mode 100644 index 0000000000000..272fe84ef14d3 --- /dev/null +++ b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard3.ts @@ -0,0 +1,41 @@ +/// + +// @module: nodenext + +// @Filename: /node_modules/foo/package.json +//// { +//// "types": "index.d.ts", +//// "exports": { +//// "./component-*": { +//// "types@>=4.3.5": "types/components/*.d.ts" +//// } +//// } +//// } + +// @Filename: /node_modules/foo/nope.d.ts +//// export const nope = 0; + +// @Filename: /node_modules/foo/types/components/index.d.ts +//// export const index = 0; + +// @Filename: /node_modules/foo/types/components/blah.d.ts +//// export const blah = 0; + +// @Filename: /node_modules/foo/types/components/subfolder/one.d.ts +//// export const one = 0; + +// @Filename: /a.ts +//// import { } from "foo//**/"; + +verify.completions({ + marker: "", + isNewIdentifierLocation: true, + exact: ["component-blah", "component-index", "component-subfolder"], +}); + +edit.insert("component-subfolder/"); + +verify.completions({ + isNewIdentifierLocation: true, + exact: ["one"], +}); diff --git a/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard4.ts b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard4.ts new file mode 100644 index 0000000000000..4b595d2641e88 --- /dev/null +++ b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard4.ts @@ -0,0 +1,52 @@ +/// + +// @module: nodenext + +// @Filename: /node_modules/foo/package.json +//// { +//// "types": "index.d.ts", +//// "exports": { +//// "./*": "dist/*", +//// "./foo/*": "dist/*", +//// "./bar/*": "dist/*", +//// "./exact-match": "dist/index.d.ts" +//// } +//// } + +// @Filename: /node_modules/foo/nope.d.ts +//// export const nope = 0; + +// @Filename: /node_modules/foo/dist/index.d.ts +//// export const index = 0; + +// @Filename: /node_modules/foo/dist/blah.d.ts +//// export const blah = 0; + +// @Filename: /node_modules/foo/dist/foo/onlyInFooFolder.d.ts +//// export const foo = 0; + +// @Filename: /node_modules/foo/dist/subfolder/one.d.ts +//// export const one = 0; + +// @Filename: /a.mts +//// import { } from "foo//**/"; + +verify.completions({ + marker: "", + isNewIdentifierLocation: true, + exact: ["blah.js", "index.js", "foo", "subfolder", "bar", "exact-match"], +}); + +edit.insert("foo/"); + +verify.completions({ + isNewIdentifierLocation: true, + exact: ["blah.js", "index.js", "foo", "subfolder"], +}); + +edit.insert("foo/"); + +verify.completions({ + isNewIdentifierLocation: true, + exact: ["onlyInFooFolder.js"], +}); From 426545c13546cc70b01dbb14ad740f79eb762a2d Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Wed, 22 Jun 2022 13:27:17 -0700 Subject: [PATCH 4/7] Revert completion kind change --- src/services/stringCompletions.ts | 4 ++-- tests/cases/fourslash/completionsPaths_pathMapping.ts | 6 +++--- .../completionsPaths_pathMapping_parentDirectory.ts | 2 +- .../fourslash/completionsPaths_pathMapping_relativePath.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/services/stringCompletions.ts b/src/services/stringCompletions.ts index b62b23ec45e73..52a72ce88ecdc 100644 --- a/src/services/stringCompletions.ts +++ b/src/services/stringCompletions.ts @@ -492,7 +492,7 @@ namespace ts.Completions.StringCompletions { } const { name, extension } = getFilenameWithExtensionOption(getBaseFileName(filePath), host.getCompilationSettings(), extensionOptions.includeExtensionsOption); - result.add(nameAndKind(name, ScriptElementKind.externalModuleName, extension)); + result.add(nameAndKind(name, ScriptElementKind.scriptElement, extension)); } } @@ -795,7 +795,7 @@ namespace ts.Completions.StringCompletions { return directoryResult(getPathComponents(basename)[0]); } const { name, extension } = getFilenameWithExtensionOption(basename, host.getCompilationSettings(), extensionOptions.includeExtensionsOption); - return nameAndKind(name, ScriptElementKind.externalModuleName, extension); + return nameAndKind(name, ScriptElementKind.scriptElement, extension); } }); diff --git a/tests/cases/fourslash/completionsPaths_pathMapping.ts b/tests/cases/fourslash/completionsPaths_pathMapping.ts index 9de48b565dbc1..381fe5b7d38e0 100644 --- a/tests/cases/fourslash/completionsPaths_pathMapping.ts +++ b/tests/cases/fourslash/completionsPaths_pathMapping.ts @@ -25,15 +25,15 @@ verify.completions( { marker: "0", exact: [ - { name: "a", kind: "external module name", kindModifiers: ".ts" }, - { name: "b", kind: "external module name", kindModifiers: ".ts" }, + { name: "a", kind: "script", kindModifiers: ".ts" }, + { name: "b", kind: "script", kindModifiers: ".ts" }, { name: "dir", kind: "directory" }, ], isNewIdentifierLocation: true, }, { marker: "1", - exact: { name: "x", kind: "external module name", kindModifiers: ".ts" }, + exact: { name: "x", kind: "script", kindModifiers: ".ts" }, isNewIdentifierLocation: true, }, ); diff --git a/tests/cases/fourslash/completionsPaths_pathMapping_parentDirectory.ts b/tests/cases/fourslash/completionsPaths_pathMapping_parentDirectory.ts index fe022dbe68cb0..c34f6e5db88ee 100644 --- a/tests/cases/fourslash/completionsPaths_pathMapping_parentDirectory.ts +++ b/tests/cases/fourslash/completionsPaths_pathMapping_parentDirectory.ts @@ -18,6 +18,6 @@ verify.completions({ marker: "", - exact: { name: "x", kind: "external module name", kindModifiers: ".ts" }, + exact: { name: "x", kind: "script", kindModifiers: ".ts" }, isNewIdentifierLocation: true, }); diff --git a/tests/cases/fourslash/completionsPaths_pathMapping_relativePath.ts b/tests/cases/fourslash/completionsPaths_pathMapping_relativePath.ts index b48e7ffc35845..b059a977eb9a7 100644 --- a/tests/cases/fourslash/completionsPaths_pathMapping_relativePath.ts +++ b/tests/cases/fourslash/completionsPaths_pathMapping_relativePath.ts @@ -21,6 +21,6 @@ verify.completions({ marker: "", - exact: ["a", "b"].map(name => ({ name, kind: "external module name", kindModifiers: ".ts" })), + exact: ["a", "b"].map(name => ({ name, kind: "script", kindModifiers: ".ts" })), isNewIdentifierLocation: true, }); From 179d1cb744a4e9cd343c1778003c7edb9c0ac347 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Wed, 22 Jun 2022 14:33:00 -0700 Subject: [PATCH 5/7] Add kinds to tests --- src/services/stringCompletions.ts | 57 +++++++++++++------ ...hCompletionsPackageJsonExportsWildcard1.ts | 6 +- ...hCompletionsPackageJsonExportsWildcard2.ts | 6 +- ...hCompletionsPackageJsonExportsWildcard3.ts | 8 ++- ...hCompletionsPackageJsonExportsWildcard4.ts | 18 +++++- 5 files changed, 68 insertions(+), 27 deletions(-) diff --git a/src/services/stringCompletions.ts b/src/services/stringCompletions.ts index 52a72ce88ecdc..eb5af1eb9365a 100644 --- a/src/services/stringCompletions.ts +++ b/src/services/stringCompletions.ts @@ -1,5 +1,30 @@ /* @internal */ namespace ts.Completions.StringCompletions { + interface NameAndKindSet { + add(value: NameAndKind): void; + has(name: string): boolean; + values(): Iterator; + } + const kindPrecedence = { + [ScriptElementKind.directory]: 0, + [ScriptElementKind.scriptElement]: 1, + [ScriptElementKind.externalModuleName]: 2, + }; + function createNameAndKindSet(): NameAndKindSet { + const map = new Map(); + function add(value: NameAndKind) { + const existing = map.get(value.name); + if (!existing || kindPrecedence[existing.kind] < kindPrecedence[value.kind]) { + map.set(value.name, value); + } + } + return { + add, + has: map.has.bind(map), + values: map.values.bind(map), + }; + } + export function getStringLiteralCompletions( sourceFile: SourceFile, position: number, @@ -435,7 +460,7 @@ namespace ts.Completions.StringCompletions { host: LanguageServiceHost, exclude?: string, result = createNameAndKindSet() - ): Set { + ): NameAndKindSet { if (fragment === undefined) { fragment = ""; } @@ -526,7 +551,7 @@ namespace ts.Completions.StringCompletions { /** @returns whether `fragment` was a match for any `paths` (which should indicate whether any other path completions should be offered) */ function addCompletionEntriesFromPaths( - result: Set, + result: NameAndKindSet, fragment: string, baseDirectory: string, extensionOptions: ExtensionOptions, @@ -546,7 +571,7 @@ namespace ts.Completions.StringCompletions { /** @returns whether `fragment` was a match for any `paths` (which should indicate whether any other path completions should be offered) */ function addCompletionEntriesFromPathsOrExports( - result: Set, + result: NameAndKindSet, fragment: string, baseDirectory: string, extensionOptions: ExtensionOptions, @@ -595,10 +620,6 @@ namespace ts.Completions.StringCompletions { return matchedPath !== undefined; } - function createNameAndKindSet() { - return createSet(element => +generateDjb2Hash(element.name), (a, b) => a.name === b.name); - } - /** * Check all of the declared modules and those in node modules. Possible sources of modules: * Modules that are found by the type checker @@ -642,7 +663,7 @@ namespace ts.Completions.StringCompletions { if (fragmentDirectory === undefined) { for (const moduleName of enumerateNodeModulesVisibleToScript(host, scriptPath)) { const moduleResult = nameAndKind(moduleName, ScriptElementKind.externalModuleName, /*extension*/ undefined); - if (!result.has(moduleResult)) { + if (!result.has(moduleResult.name)) { foundGlobal = true; result.add(moduleResult); } @@ -733,20 +754,20 @@ namespace ts.Completions.StringCompletions { ): readonly NameAndKind[] { if (!endsWith(path, "*")) { // For a path mapping "foo": ["/x/y/z.ts"], add "foo" itself as a completion. - return !stringContains(path, "*") ? justPathMappingName(path) : emptyArray; + return !stringContains(path, "*") ? justPathMappingName(path, ScriptElementKind.scriptElement) : emptyArray; } const pathPrefix = path.slice(0, path.length - 1); const remainingFragment = tryRemovePrefix(fragment, pathPrefix); if (remainingFragment === undefined) { const starIsFullPathComponent = path[path.length - 2] === "/"; - return starIsFullPathComponent ? justPathMappingName(pathPrefix) : flatMap(patterns, pattern => + return starIsFullPathComponent ? justPathMappingName(pathPrefix, ScriptElementKind.directory) : flatMap(patterns, pattern => getModulesForPathsPattern("", packageDirectory, pattern, extensionOptions, host)?.map(({ name, ...rest }) => ({ name: pathPrefix + name, ...rest }))); } return flatMap(patterns, pattern => getModulesForPathsPattern(remainingFragment, packageDirectory, pattern, extensionOptions, host)); - function justPathMappingName(name: string): readonly NameAndKind[] { - return startsWith(name, fragment) ? [directoryResult(removeTrailingDirectorySeparator(name))] : emptyArray; + function justPathMappingName(name: string, kind: ScriptElementKind.directory | ScriptElementKind.scriptElement): readonly NameAndKind[] { + return startsWith(name, fragment) ? [{ name: removeTrailingDirectorySeparator(name), kind, extension: undefined }] : emptyArray; } } @@ -789,12 +810,12 @@ namespace ts.Completions.StringCompletions { const includeGlob = suffixHasFilename ? "**/*" : "./*"; const matches = mapDefined(tryReadDirectory(host, baseDirectory, extensionOptions.extensions, /*exclude*/ undefined, [includeGlob]), match => { - const basename = trimPrefixAndSuffix(match); - if (basename) { - if (containsSlash(basename)) { - return directoryResult(getPathComponents(basename)[0]); + const trimmedWithPattern = trimPrefixAndSuffix(match); + if (trimmedWithPattern) { + if (containsSlash(trimmedWithPattern)) { + return directoryResult(getPathComponents(trimmedWithPattern)[0]); } - const { name, extension } = getFilenameWithExtensionOption(basename, host.getCompilationSettings(), extensionOptions.includeExtensionsOption); + const { name, extension } = getFilenameWithExtensionOption(trimmedWithPattern, host.getCompilationSettings(), extensionOptions.includeExtensionsOption); return nameAndKind(name, ScriptElementKind.scriptElement, extension); } }); @@ -858,7 +879,7 @@ namespace ts.Completions.StringCompletions { return addReplacementSpans(toComplete, range.pos + prefix.length, arrayFrom(names.values())); } - function getCompletionEntriesFromTypings(host: LanguageServiceHost, options: CompilerOptions, scriptPath: string, fragmentDirectory: string | undefined, extensionOptions: ExtensionOptions, result = createNameAndKindSet()): Set { + function getCompletionEntriesFromTypings(host: LanguageServiceHost, options: CompilerOptions, scriptPath: string, fragmentDirectory: string | undefined, extensionOptions: ExtensionOptions, result = createNameAndKindSet()): NameAndKindSet { // Check for typings specified in compiler options const seen = new Map(); diff --git a/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard1.ts b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard1.ts index ea2cf5782a34a..ea1c267ff7d90 100644 --- a/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard1.ts +++ b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard1.ts @@ -42,5 +42,9 @@ verify.completions({ marker: "", isNewIdentifierLocation: true, - exact: ["blah", "index", "arguments"] + exact: [ + { name: "blah", kind: "script", kindModifiers: "" }, + { name: "index", kind: "script", kindModifiers: "" }, + { name: "arguments", kind: "script", kindModifiers: "" }, + ] }); diff --git a/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard2.ts b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard2.ts index 42259c0619815..af7963a7e2c28 100644 --- a/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard2.ts +++ b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard2.ts @@ -24,19 +24,19 @@ verify.completions({ marker: "", isNewIdentifierLocation: true, - exact: ["action"] + exact: [{ name: "action", kind: "directory" }] }); edit.insert("action/"); verify.completions({ isNewIdentifierLocation: true, - exact: ["pageObjects"], + exact: [{ name: "pageObjects", kind: "directory" }], }); edit.insert("pageObjects/"); verify.completions({ isNewIdentifierLocation: true, - exact: ["actionRenderer"], + exact: [{ name: "actionRenderer", kind: "script" }], }); diff --git a/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard3.ts b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard3.ts index 272fe84ef14d3..4ac89dfebc8b1 100644 --- a/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard3.ts +++ b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard3.ts @@ -30,12 +30,16 @@ verify.completions({ marker: "", isNewIdentifierLocation: true, - exact: ["component-blah", "component-index", "component-subfolder"], + exact: [ + { name: "component-blah", kind: "script" }, + { name: "component-index", kind: "script" }, + { name: "component-subfolder", kind: "directory" }, + ], }); edit.insert("component-subfolder/"); verify.completions({ isNewIdentifierLocation: true, - exact: ["one"], + exact: [{ name: "one", kind: "script" }], }); diff --git a/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard4.ts b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard4.ts index 4b595d2641e88..852b77b360659 100644 --- a/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard4.ts +++ b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard4.ts @@ -34,19 +34,31 @@ verify.completions({ marker: "", isNewIdentifierLocation: true, - exact: ["blah.js", "index.js", "foo", "subfolder", "bar", "exact-match"], + exact: [ + { name: "blah.js", kind: "script", kindModifiers: ".js" }, + { name: "index.js", kind: "script", kindModifiers: ".js" }, + { name: "foo", kind: "directory" }, + { name: "subfolder", kind: "directory" }, + { name: "bar", kind: "directory" }, + { name: "exact-match", kind: "script" }, + ], }); edit.insert("foo/"); verify.completions({ isNewIdentifierLocation: true, - exact: ["blah.js", "index.js", "foo", "subfolder"], + exact: [ + { name: "blah.js", kind: "script", kindModifiers: ".js" }, + { name: "index.js", kind: "script", kindModifiers: ".js" }, + { name: "foo", kind: "directory" }, + { name: "subfolder", kind: "directory" }, + ], }); edit.insert("foo/"); verify.completions({ isNewIdentifierLocation: true, - exact: ["onlyInFooFolder.js"], + exact: [{ name: "onlyInFooFolder.js", kind: "script", kindModifiers: ".js" }], }); From ed77c7c16d3f0e3d0d6a40482314567fa5779ede Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Wed, 22 Jun 2022 14:54:54 -0700 Subject: [PATCH 6/7] Update existing test --- tests/cases/fourslash/server/nodeNextPathCompletions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cases/fourslash/server/nodeNextPathCompletions.ts b/tests/cases/fourslash/server/nodeNextPathCompletions.ts index 800e7d0a98aa5..12b072d63a815 100644 --- a/tests/cases/fourslash/server/nodeNextPathCompletions.ts +++ b/tests/cases/fourslash/server/nodeNextPathCompletions.ts @@ -38,6 +38,6 @@ verify.baselineCompletions(); edit.insert("dependency/"); -verify.completions({ exact: ["lol", "dir/"], isNewIdentifierLocation: true }); +verify.completions({ exact: ["lol", "dir"], isNewIdentifierLocation: true }); edit.insert("l"); verify.completions({ exact: ["lol"], isNewIdentifierLocation: true }); From 74800240a2b904542a2a12d0f33aca5df56aaf41 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Tue, 28 Jun 2022 14:40:07 -0700 Subject: [PATCH 7/7] Support nested conditions, improve recursive globbing --- src/services/stringCompletions.ts | 32 +++++---- ...hCompletionsPackageJsonExportsWildcard5.ts | 66 +++++++++++++++++++ ...hCompletionsPackageJsonExportsWildcard6.ts | 31 +++++++++ 3 files changed, 115 insertions(+), 14 deletions(-) create mode 100644 tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard5.ts create mode 100644 tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard6.ts diff --git a/src/services/stringCompletions.ts b/src/services/stringCompletions.ts index eb5af1eb9365a..a32022f9aee92 100644 --- a/src/services/stringCompletions.ts +++ b/src/services/stringCompletions.ts @@ -711,7 +711,7 @@ namespace ts.Completions.StringCompletions { extensionOptions, host, keys, - key => getPatternFromFirstMatchingCondition(exports[key], conditions), + key => singleElementArray(getPatternFromFirstMatchingCondition(exports[key], conditions)), comparePatternKeys); return; } @@ -726,15 +726,15 @@ namespace ts.Completions.StringCompletions { return arrayFrom(result.values()); } - function getPatternFromFirstMatchingCondition(target: unknown, conditions: readonly string[]): [string] | undefined { + function getPatternFromFirstMatchingCondition(target: unknown, conditions: readonly string[]): string | undefined { if (typeof target === "string") { - return [target]; + return target; } if (target && typeof target === "object" && !isArray(target)) { for (const condition in target) { if (condition === "default" || conditions.indexOf(condition) > -1 || isApplicableVersionedTypesKey(conditions, condition)) { const pattern = (target as MapLike)[condition]; - return typeof pattern === "string" ? [pattern] : undefined; + return getPatternFromFirstMatchingCondition(pattern, conditions); } } } @@ -804,27 +804,31 @@ namespace ts.Completions.StringCompletions { const baseDirectory = normalizePath(combinePaths(packageDirectory, expandedPrefixDirectory)); const completePrefix = fragmentHasPath ? baseDirectory : ensureTrailingDirectorySeparator(baseDirectory) + normalizedPrefixBase; - // If we have a suffix of at least a whole filename (i.e., not just an extension), then we need - // to read the directory all the way down to see which directories contain a file matching that suffix. - const suffixHasFilename = normalizedSuffix && containsSlash(normalizedSuffix); - const includeGlob = suffixHasFilename ? "**/*" : "./*"; + // If we have a suffix, then we read the directory all the way down to avoid returning completions for + // directories that don't contain files that would match the suffix. A previous comment here was concerned + // about the case where `normalizedSuffix` includes a `?` character, which should be interpreted literally, + // but will match any single character as part of the `include` pattern in `tryReadDirectory`. This is not + // a problem, because (in the extremely unusual circumstance where the suffix has a `?` in it) a `?` + // interpreted as "any character" can only return *too many* results as compared to the literal + // interpretation, so we can filter those superfluous results out via `trimPrefixAndSuffix` as we've always + // done. + const includeGlob = normalizedSuffix ? "**/*" + normalizedSuffix : "./*"; const matches = mapDefined(tryReadDirectory(host, baseDirectory, extensionOptions.extensions, /*exclude*/ undefined, [includeGlob]), match => { const trimmedWithPattern = trimPrefixAndSuffix(match); if (trimmedWithPattern) { if (containsSlash(trimmedWithPattern)) { - return directoryResult(getPathComponents(trimmedWithPattern)[0]); + return directoryResult(getPathComponents(removeLeadingDirectorySeparator(trimmedWithPattern))[1]); } const { name, extension } = getFilenameWithExtensionOption(trimmedWithPattern, host.getCompilationSettings(), extensionOptions.includeExtensionsOption); return nameAndKind(name, ScriptElementKind.scriptElement, extension); } }); - // If the suffix had a whole filename in it, we already recursively searched for all possible files - // that could match it and returned the directories that could possibly lead to matches. Otherwise, - // assume any directory could have something valid to import (not a great assumption - we could - // consider doing a whole glob more often and caching the results?) - const directories = suffixHasFilename + // If we had a suffix, we already recursively searched for all possible files that could match + // it and returned the directories leading to those files. Otherwise, assume any directory could + // have something valid to import. + const directories = normalizedSuffix ? emptyArray : mapDefined(tryGetDirectories(host, baseDirectory), dir => dir === "node_modules" ? undefined : directoryResult(dir)); return [...matches, ...directories]; diff --git a/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard5.ts b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard5.ts new file mode 100644 index 0000000000000..86694b2e0c62b --- /dev/null +++ b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard5.ts @@ -0,0 +1,66 @@ +/// + +// @module: nodenext + +// @Filename: /node_modules/foo/package.json +//// { +//// "name": "foo", +//// "main": "dist/index.js", +//// "module": "dist/index.mjs", +//// "types": "dist/index.d.ts", +//// "exports": { +//// ".": { +//// "import": { +//// "types": "./dist/types/index.d.mts", +//// "default": "./dist/esm/index.mjs" +//// }, +//// "default": { +//// "types": "./dist/types/index.d.ts", +//// "default": "./dist/cjs/index.js" +//// } +//// }, +//// "./*": { +//// "import": { +//// "types": "./dist/types/*.d.mts", +//// "default": "./dist/esm/*.mjs" +//// }, +//// "default": { +//// "types": "./dist/types/*.d.ts", +//// "default": "./dist/cjs/*.js" +//// } +//// }, +//// "./only-in-cjs": { +//// "require": { +//// "types": "./dist/types/only-in-cjs/index.d.ts", +//// "default": "./dist/cjs/only-in-cjs/index.js" +//// } +//// } +//// } +//// } + +// @Filename: /node_modules/foo/dist/types/index.d.mts +//// export const index = 0; + +// @Filename: /node_modules/foo/dist/types/index.d.ts +//// export const index = 0; + +// @Filename: /node_modules/foo/dist/types/blah.d.mts +//// export const blah = 0; + +// @Filename: /node_modules/foo/dist/types/blah.d.ts +//// export const blah = 0; + +// @Filename: /node_modules/foo/dist/types/only-in-cjs/index.d.ts +//// export const onlyInCjs = 0; + +// @Filename: /index.mts +//// import { } from "foo//**/"; + +verify.completions({ + marker: "", + isNewIdentifierLocation: true, + exact: [ + { name: "blah", kind: "script", kindModifiers: "" }, + { name: "index", kind: "script", kindModifiers: "" }, + ] +}); diff --git a/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard6.ts b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard6.ts new file mode 100644 index 0000000000000..1fb17f1c81c3a --- /dev/null +++ b/tests/cases/fourslash/pathCompletionsPackageJsonExportsWildcard6.ts @@ -0,0 +1,31 @@ +/// + +// @module: nodenext + +// @Filename: /node_modules/foo/package.json +//// { +//// "name": "foo", +//// "main": "dist/index.js", +//// "module": "dist/index.mjs", +//// "types": "dist/index.d.ts", +//// "exports": { +//// "./*": "./dist/*?.d.ts" +//// } +//// } + +// @Filename: /node_modules/foo/dist/index.d.ts +//// export const index = 0; + +// @Filename: /node_modules/foo/dist/blah?.d.ts +//// export const blah = 0; + +// @Filename: /index.mts +//// import { } from "foo//**/"; + +verify.completions({ + marker: "", + isNewIdentifierLocation: true, + exact: [ + { name: "blah", kind: "script", kindModifiers: "" }, + ] +});