Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 62 additions & 10 deletions src/compiler/moduleSpecifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import {
mapDefined,
MapLike,
matchPatternOrExact,
memoizeOne,
min,
ModuleDeclaration,
ModuleKind,
Expand Down Expand Up @@ -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 */
Expand All @@ -144,18 +173,20 @@ 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<ModuleSpecifierResolutionHost, "getDefaultResolutionModeForFile">,
compilerOptions: CompilerOptions,
importingSourceFile: Pick<SourceFile, "fileName" | "impliedNodeFormat">,
oldImportSpecifier?: string,
): ModuleSpecifierPreferences {
const filePreferredEnding = getPreferredEnding();
return {
excludeRegexes: autoImportSpecifierExcludeRegexes,
relativePreference: oldImportSpecifier !== undefined ? (isExternalModuleNameRelative(oldImportSpecifier) ?
RelativePreference.Relative :
RelativePreference.NonRelative) :
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand All @@ -476,7 +515,7 @@ function computeModuleSpecifiers(
preferences,
/*pathsOnly*/ modulePath.isRedirect,
);
if (!local) {
if (!local || forAutoImport && isExcludedByRegex(local, preferences.excludeRegexes)) {
continue;
}
if (modulePath.isRedirect) {
Expand Down Expand Up @@ -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));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wont this keep memoized map forever in tsserver - think you would want to memoize this and keep lifetime same as excludeRegex or something like that ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but I think normally the preference won’t change (or won’t change much) during the life of the server. It might be nice not to have to redo this on every request. Theoretically this could waste memory, but it will only have an impact if the user is changing between thousands of unique strings...

}

interface Info {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions src/compiler/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10251,6 +10251,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.
Expand Down
11 changes: 7 additions & 4 deletions src/services/completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ import {
getEffectiveBaseTypeNode,
getEffectiveModifierFlags,
getEffectiveTypeAnnotationNode,
getEmitModuleResolutionKind,
getEmitScriptTarget,
getEscapedTextOfIdentifierOrLiteral,
getEscapedTextOfJsxAttributeName,
Expand All @@ -106,6 +105,7 @@ import {
getPropertyNameForPropertyNameNode,
getQuotePreference,
getReplacementSpanForContextToken,
getResolvePackageJsonExports,
getRootDeclaration,
getSourceFileOfModule,
getSwitchedType,
Expand Down Expand Up @@ -301,7 +301,6 @@ import {
ModuleDeclaration,
moduleExportNameTextEscaped,
ModuleReference,
moduleResolutionSupportsPackageJsonExportsAndImports,
NamedImportBindings,
newCaseClauseTracker,
Node,
Expand Down Expand Up @@ -629,12 +628,16 @@ function resolvingModuleSpecifiers<TReturn>(
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;
Expand Down
1 change: 1 addition & 0 deletions tests/baselines/reference/api/typescript.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8240,6 +8240,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.
Expand Down
61 changes: 61 additions & 0 deletions tests/cases/fourslash/autoImportSpecifierExcludeRegexes1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/// <reference path="fourslash.ts" />

// @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"]
},
})
25 changes: 25 additions & 0 deletions tests/cases/fourslash/autoImportSpecifierExcludeRegexes2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/// <reference path="fourslash.ts" />

// @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"] });
25 changes: 25 additions & 0 deletions tests/cases/fourslash/autoImportSpecifierExcludeRegexes3.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/// <reference path="fourslash.ts" />

// @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$"] });
1 change: 1 addition & 0 deletions tests/cases/fourslash/fourslash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down