From 09caaf60aa4101813bedc41f21ad84318aadd29a Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Fri, 9 Aug 2024 11:12:18 -0700 Subject: [PATCH] Add `autoImportSpecifierExcludeRegexes` preference (#59543) --- src/compiler/moduleSpecifiers.ts | 72 ++++++++++++++++--- src/compiler/types.ts | 1 + src/services/completions.ts | 11 +-- tests/baselines/reference/api/typescript.d.ts | 1 + .../autoImportSpecifierExcludeRegexes1.ts | 61 ++++++++++++++++ .../autoImportSpecifierExcludeRegexes2.ts | 25 +++++++ .../autoImportSpecifierExcludeRegexes3.ts | 25 +++++++ tests/cases/fourslash/fourslash.ts | 1 + 8 files changed, 183 insertions(+), 14 deletions(-) create mode 100644 tests/cases/fourslash/autoImportSpecifierExcludeRegexes1.ts create mode 100644 tests/cases/fourslash/autoImportSpecifierExcludeRegexes2.ts create mode 100644 tests/cases/fourslash/autoImportSpecifierExcludeRegexes3.ts diff --git a/src/compiler/moduleSpecifiers.ts b/src/compiler/moduleSpecifiers.ts index f380ff56a02..f193aeb6784 100644 --- a/src/compiler/moduleSpecifiers.ts +++ b/src/compiler/moduleSpecifiers.ts @@ -83,6 +83,7 @@ import { mapDefined, MapLike, matchPatternOrExact, + memoizeOne, min, ModuleDeclaration, ModuleKind, @@ -127,6 +128,34 @@ import { UserPreferences, } from "./_namespaces/ts.js"; +const stringToRegex = memoizeOne((pattern: string) => { + try { + let slash = pattern.indexOf("/"); + if (slash !== 0) { + // No leading slash, treat as a pattern + return new RegExp(pattern); + } + const lastSlash = pattern.lastIndexOf("/"); + if (slash === lastSlash) { + // Only one slash, treat as a pattern + return new RegExp(pattern); + } + while ((slash = pattern.indexOf("/", slash + 1)) !== lastSlash) { + if (pattern[slash - 1] !== "\\") { + // Unescaped middle slash, treat as a pattern + return new RegExp(pattern); + } + } + // Only case-insensitive and unicode flags make sense + const flags = pattern.substring(lastSlash + 1).replace(/[^iu]/g, ""); + pattern = pattern.substring(1, lastSlash); + return new RegExp(pattern, flags); + } + catch { + return undefined; + } +}); + // Used by importFixes, getEditsForFileRename, and declaration emit to synthesize import module specifiers. /** @internal */ @@ -144,11 +173,12 @@ export interface ModuleSpecifierPreferences { * @param syntaxImpliedNodeFormat Used when the import syntax implies ESM or CJS irrespective of the mode of the file. */ getAllowedEndingsInPreferredOrder(syntaxImpliedNodeFormat?: ResolutionMode): ModuleSpecifierEnding[]; + readonly excludeRegexes?: readonly string[]; } /** @internal */ export function getModuleSpecifierPreferences( - { importModuleSpecifierPreference, importModuleSpecifierEnding }: UserPreferences, + { importModuleSpecifierPreference, importModuleSpecifierEnding, autoImportSpecifierExcludeRegexes }: UserPreferences, host: Pick, compilerOptions: CompilerOptions, importingSourceFile: Pick, @@ -156,6 +186,7 @@ export function getModuleSpecifierPreferences( ): ModuleSpecifierPreferences { const filePreferredEnding = getPreferredEnding(); return { + excludeRegexes: autoImportSpecifierExcludeRegexes, relativePreference: oldImportSpecifier !== undefined ? (isExternalModuleNameRelative(oldImportSpecifier) ? RelativePreference.Relative : RelativePreference.NonRelative) : @@ -362,7 +393,13 @@ export function getModuleSpecifiersWithCacheInfo( ): ModuleSpecifierResult { let computedWithoutCache = false; const ambient = tryGetModuleNameFromAmbientModule(moduleSymbol, checker); - if (ambient) return { kind: "ambient", moduleSpecifiers: [ambient], computedWithoutCache }; + if (ambient) { + return { + kind: "ambient", + moduleSpecifiers: !(forAutoImport && isExcludedByRegex(ambient, userPreferences.autoImportSpecifierExcludeRegexes)) ? [ambient] : emptyArray, + computedWithoutCache, + }; + } // eslint-disable-next-line prefer-const let [kind, specifiers, moduleSourceFile, modulePaths, cache] = tryGetModuleSpecifiersFromCacheWorker( @@ -459,11 +496,13 @@ function computeModuleSpecifiers( const specifier = modulePath.isInNodeModules ? tryGetModuleNameAsNodeModule(modulePath, info, importingSourceFile, host, compilerOptions, userPreferences, /*packageNameOnly*/ undefined, options.overrideImportMode) : undefined; - nodeModulesSpecifiers = append(nodeModulesSpecifiers, specifier); - if (specifier && modulePath.isRedirect) { - // If we got a specifier for a redirect, it was a bare package specifier (e.g. "@foo/bar", - // not "@foo/bar/path/to/file"). No other specifier will be this good, so stop looking. - return { kind: "node_modules", moduleSpecifiers: nodeModulesSpecifiers!, computedWithoutCache: true }; + if (specifier && !(forAutoImport && isExcludedByRegex(specifier, preferences.excludeRegexes))) { + nodeModulesSpecifiers = append(nodeModulesSpecifiers, specifier); + if (modulePath.isRedirect) { + // If we got a specifier for a redirect, it was a bare package specifier (e.g. "@foo/bar", + // not "@foo/bar/path/to/file"). No other specifier will be this good, so stop looking. + return { kind: "node_modules", moduleSpecifiers: nodeModulesSpecifiers, computedWithoutCache: true }; + } } if (!specifier) { @@ -476,7 +515,7 @@ function computeModuleSpecifiers( preferences, /*pathsOnly*/ modulePath.isRedirect, ); - if (!local) { + if (!local || forAutoImport && isExcludedByRegex(local, preferences.excludeRegexes)) { continue; } if (modulePath.isRedirect) { @@ -512,7 +551,11 @@ function computeModuleSpecifiers( return pathsSpecifiers?.length ? { kind: "paths", moduleSpecifiers: pathsSpecifiers, computedWithoutCache: true } : redirectPathsSpecifiers?.length ? { kind: "redirect", moduleSpecifiers: redirectPathsSpecifiers, computedWithoutCache: true } : nodeModulesSpecifiers?.length ? { kind: "node_modules", moduleSpecifiers: nodeModulesSpecifiers, computedWithoutCache: true } : - { kind: "relative", moduleSpecifiers: Debug.checkDefined(relativeSpecifiers), computedWithoutCache: true }; + { kind: "relative", moduleSpecifiers: relativeSpecifiers ?? emptyArray, computedWithoutCache: true }; +} + +function isExcludedByRegex(moduleSpecifier: string, excludeRegexes: readonly string[] | undefined): boolean { + return some(excludeRegexes, pattern => !!stringToRegex(pattern)?.test(moduleSpecifier)); } interface Info { @@ -536,7 +579,7 @@ function getInfo(importingSourceFileName: string, host: ModuleSpecifierResolutio function getLocalModuleSpecifier(moduleFileName: string, info: Info, compilerOptions: CompilerOptions, host: ModuleSpecifierResolutionHost, importMode: ResolutionMode, preferences: ModuleSpecifierPreferences): string; function getLocalModuleSpecifier(moduleFileName: string, info: Info, compilerOptions: CompilerOptions, host: ModuleSpecifierResolutionHost, importMode: ResolutionMode, preferences: ModuleSpecifierPreferences, pathsOnly?: boolean): string | undefined; -function getLocalModuleSpecifier(moduleFileName: string, info: Info, compilerOptions: CompilerOptions, host: ModuleSpecifierResolutionHost, importMode: ResolutionMode, { getAllowedEndingsInPreferredOrder: getAllowedEndingsInPrefererredOrder, relativePreference }: ModuleSpecifierPreferences, pathsOnly?: boolean): string | undefined { +function getLocalModuleSpecifier(moduleFileName: string, info: Info, compilerOptions: CompilerOptions, host: ModuleSpecifierResolutionHost, importMode: ResolutionMode, { getAllowedEndingsInPreferredOrder: getAllowedEndingsInPrefererredOrder, relativePreference, excludeRegexes }: ModuleSpecifierPreferences, pathsOnly?: boolean): string | undefined { const { baseUrl, paths, rootDirs } = compilerOptions; if (pathsOnly && !paths) { return undefined; @@ -568,6 +611,15 @@ function getLocalModuleSpecifier(moduleFileName: string, info: Info, compilerOpt return relativePath; } + const relativeIsExcluded = isExcludedByRegex(relativePath, excludeRegexes); + const nonRelativeIsExcluded = isExcludedByRegex(maybeNonRelative, excludeRegexes); + if (!relativeIsExcluded && nonRelativeIsExcluded) { + return relativePath; + } + if (relativeIsExcluded && !nonRelativeIsExcluded) { + return maybeNonRelative; + } + if (relativePreference === RelativePreference.NonRelative && !pathIsRelative(maybeNonRelative)) { return maybeNonRelative; } diff --git a/src/compiler/types.ts b/src/compiler/types.ts index 3f5306cec03..fb3022c5500 100644 --- a/src/compiler/types.ts +++ b/src/compiler/types.ts @@ -10260,6 +10260,7 @@ export interface UserPreferences { readonly interactiveInlayHints?: boolean; readonly allowRenameOfImportPath?: boolean; readonly autoImportFileExcludePatterns?: string[]; + readonly autoImportSpecifierExcludeRegexes?: string[]; readonly preferTypeOnlyAutoImports?: boolean; /** * Indicates whether imports should be organized in a case-insensitive manner. diff --git a/src/services/completions.ts b/src/services/completions.ts index ee67c033315..ac88e4bf668 100644 --- a/src/services/completions.ts +++ b/src/services/completions.ts @@ -84,7 +84,6 @@ import { getEffectiveBaseTypeNode, getEffectiveModifierFlags, getEffectiveTypeAnnotationNode, - getEmitModuleResolutionKind, getEmitScriptTarget, getEscapedTextOfIdentifierOrLiteral, getEscapedTextOfJsxAttributeName, @@ -106,6 +105,7 @@ import { getPropertyNameForPropertyNameNode, getQuotePreference, getReplacementSpanForContextToken, + getResolvePackageJsonExports, getRootDeclaration, getSourceFileOfModule, getSwitchedType, @@ -301,7 +301,6 @@ import { ModuleDeclaration, moduleExportNameTextEscaped, ModuleReference, - moduleResolutionSupportsPackageJsonExportsAndImports, NamedImportBindings, newCaseClauseTracker, Node, @@ -629,12 +628,16 @@ function resolvingModuleSpecifiers( cb: (context: ModuleSpecifierResolutionContext) => TReturn, ): TReturn { const start = timestamp(); - // Under `--moduleResolution nodenext`, we have to resolve module specifiers up front, because + // Under `--moduleResolution nodenext` or `bundler`, we have to resolve module specifiers up front, because // package.json exports can mean we *can't* resolve a module specifier (that doesn't include a // relative path into node_modules), and we want to filter those completions out entirely. // Import statement completions always need specifier resolution because the module specifier is // part of their `insertText`, not the `codeActions` creating edits away from the cursor. - const needsFullResolution = isForImportStatementCompletion || moduleResolutionSupportsPackageJsonExportsAndImports(getEmitModuleResolutionKind(program.getCompilerOptions())); + // Finally, `autoImportSpecifierExcludeRegexes` necessitates eagerly resolving module specifiers + // because completion items are being explcitly filtered out by module specifier. + const needsFullResolution = isForImportStatementCompletion + || getResolvePackageJsonExports(program.getCompilerOptions()) + || preferences.autoImportSpecifierExcludeRegexes?.length; let skippedAny = false; let ambientCount = 0; let resolvedCount = 0; diff --git a/tests/baselines/reference/api/typescript.d.ts b/tests/baselines/reference/api/typescript.d.ts index 0e82d5ba678..0a0d0a2f8dd 100644 --- a/tests/baselines/reference/api/typescript.d.ts +++ b/tests/baselines/reference/api/typescript.d.ts @@ -8241,6 +8241,7 @@ declare namespace ts { readonly interactiveInlayHints?: boolean; readonly allowRenameOfImportPath?: boolean; readonly autoImportFileExcludePatterns?: string[]; + readonly autoImportSpecifierExcludeRegexes?: string[]; readonly preferTypeOnlyAutoImports?: boolean; /** * Indicates whether imports should be organized in a case-insensitive manner. diff --git a/tests/cases/fourslash/autoImportSpecifierExcludeRegexes1.ts b/tests/cases/fourslash/autoImportSpecifierExcludeRegexes1.ts new file mode 100644 index 00000000000..f3eff409abf --- /dev/null +++ b/tests/cases/fourslash/autoImportSpecifierExcludeRegexes1.ts @@ -0,0 +1,61 @@ +/// + +// @module: preserve + +// @Filename: /node_modules/lib/index.d.ts +//// declare module "ambient" { +//// export const x: number; +//// } +//// declare module "ambient/utils" { +//// export const x: number; +//// } + +// @Filename: /index.ts +//// x/**/ + +verify.importFixModuleSpecifiers("", ["ambient", "ambient/utils"]); +verify.importFixModuleSpecifiers("", ["ambient"], { autoImportSpecifierExcludeRegexes: ["utils"] }); +// case sensitive, no match +verify.importFixModuleSpecifiers("", ["ambient", "ambient/utils"], { autoImportSpecifierExcludeRegexes: ["/UTILS/"] }); +// case insensitive flag given +verify.importFixModuleSpecifiers("", ["ambient"], { autoImportSpecifierExcludeRegexes: ["/UTILS/i"] }); +// invalid due to unescaped slash, treated as pattern +verify.importFixModuleSpecifiers("", ["ambient", "ambient/utils"], { autoImportSpecifierExcludeRegexes: ["/ambient/utils/"] }); +verify.importFixModuleSpecifiers("", ["ambient"], { autoImportSpecifierExcludeRegexes: ["/ambient\\/utils/"] }); +// no trailing slash, treated as pattern, slash doesn't need to be escaped +verify.importFixModuleSpecifiers("", ["ambient"], { autoImportSpecifierExcludeRegexes: ["/.*?$"]}); +// no leading slash, treated as pattern, slash doesn't need to be escaped +verify.importFixModuleSpecifiers("", ["ambient"], { autoImportSpecifierExcludeRegexes: ["^ambient/"] }); +verify.importFixModuleSpecifiers("", ["ambient/utils"], { autoImportSpecifierExcludeRegexes: ["ambient$"] }); +verify.importFixModuleSpecifiers("", ["ambient", "ambient/utils"], { autoImportSpecifierExcludeRegexes: ["oops("] }); + +verify.completions({ + marker: "", + includes: [{ + name: "x", + source: "ambient", + sourceDisplay: "ambient", + hasAction: true, + sortText: completion.SortText.AutoImportSuggestions + }, { + name: "x", + source: "ambient/utils", + sourceDisplay: "ambient/utils", + hasAction: true, + sortText: completion.SortText.AutoImportSuggestions + }], + preferences: { + includeCompletionsForModuleExports: true, + allowIncompleteCompletions: true + } +}); + +verify.completions({ + marker: "", + excludes: ["ambient/utils"], + preferences: { + includeCompletionsForModuleExports: true, + allowIncompleteCompletions: true, + autoImportSpecifierExcludeRegexes: ["utils"] + }, +}) \ No newline at end of file diff --git a/tests/cases/fourslash/autoImportSpecifierExcludeRegexes2.ts b/tests/cases/fourslash/autoImportSpecifierExcludeRegexes2.ts new file mode 100644 index 00000000000..37ab15c5c36 --- /dev/null +++ b/tests/cases/fourslash/autoImportSpecifierExcludeRegexes2.ts @@ -0,0 +1,25 @@ +/// + +// @Filename: /tsconfig.json +//// { +//// "compilerOptions": { +//// "module": "preserve", +//// "paths": { +//// "@app/*": ["./src/*"] +//// } +//// } +//// } + +// @Filename: /src/utils.ts +//// export function add(a: number, b: number) {} + +// @Filename: /src/index.ts +//// add/**/ + +verify.importFixModuleSpecifiers("", ["./utils"]); +verify.importFixModuleSpecifiers("", ["@app/utils"], { autoImportSpecifierExcludeRegexes: ["^\\./"] }); + +verify.importFixModuleSpecifiers("", ["@app/utils"], { importModuleSpecifierPreference: "non-relative" }); +verify.importFixModuleSpecifiers("", ["./utils"], { importModuleSpecifierPreference: "non-relative", autoImportSpecifierExcludeRegexes: ["^@app/"] }); + +verify.importFixModuleSpecifiers("", [], { autoImportSpecifierExcludeRegexes: ["utils"] }); diff --git a/tests/cases/fourslash/autoImportSpecifierExcludeRegexes3.ts b/tests/cases/fourslash/autoImportSpecifierExcludeRegexes3.ts new file mode 100644 index 00000000000..c4cc27c871b --- /dev/null +++ b/tests/cases/fourslash/autoImportSpecifierExcludeRegexes3.ts @@ -0,0 +1,25 @@ +/// + +// @module: preserve + +// @Filename: /node_modules/pkg/package.json +//// { +//// "name": "pkg", +//// "version": "1.0.0", +//// "exports": { +//// ".": "./index.js", +//// "./utils": "./utils.js" +//// } +//// } + +// @Filename: /node_modules/pkg/utils.d.ts +//// export function add(a: number, b: number) {} + +// @Filename: /node_modules/pkg/index.d.ts +//// export * from "./utils"; + +// @Filename: /src/index.ts +//// add/**/ + +verify.importFixModuleSpecifiers("", ["pkg", "pkg/utils"]); +verify.importFixModuleSpecifiers("", ["pkg/utils"], { autoImportSpecifierExcludeRegexes: ["^pkg$"] }); diff --git a/tests/cases/fourslash/fourslash.ts b/tests/cases/fourslash/fourslash.ts index c13f27a807a..6fa6907a9c0 100644 --- a/tests/cases/fourslash/fourslash.ts +++ b/tests/cases/fourslash/fourslash.ts @@ -686,6 +686,7 @@ declare namespace FourSlashInterface { readonly providePrefixAndSuffixTextForRename?: boolean; readonly allowRenameOfImportPath?: boolean; readonly autoImportFileExcludePatterns?: readonly string[]; + readonly autoImportSpecifierExcludeRegexes?: readonly string[]; readonly preferTypeOnlyAutoImports?: boolean; readonly organizeImportsIgnoreCase?: "auto" | boolean; readonly organizeImportsCollation?: "unicode" | "ordinal";