-
Notifications
You must be signed in to change notification settings - Fork 12.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Offer auto-imports from wildcard exports with AutoImportProvider #54831
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,6 +9,7 @@ import { | |
CommandLineOption, | ||
comparePaths, | ||
Comparison, | ||
CompilerHost, | ||
CompilerOptions, | ||
concatenate, | ||
contains, | ||
|
@@ -35,10 +36,12 @@ import { | |
forEach, | ||
forEachAncestorDirectory, | ||
formatMessage, | ||
getAnyExtensionFromPath, | ||
getBaseFileName, | ||
GetCanonicalFileName, | ||
getCommonSourceDirectory, | ||
getCompilerOptionValue, | ||
getDeclarationEmitExtensionForPath, | ||
getDirectoryPath, | ||
GetEffectiveTypeRootsHost, | ||
getEmitModuleKind, | ||
|
@@ -98,6 +101,7 @@ import { | |
startsWith, | ||
stringContains, | ||
supportedDeclarationExtensions, | ||
supportedJSExtensionsFlat, | ||
supportedTSImplementationExtensions, | ||
toPath, | ||
tryExtractTSExtension, | ||
|
@@ -193,6 +197,15 @@ function formatExtensions(extensions: Extensions) { | |
return result.join(", "); | ||
} | ||
|
||
function extensionsToExtensionsArray(extensions: Extensions) { | ||
const result: Extension[] = []; | ||
if (extensions & Extensions.TypeScript) result.push(...supportedTSImplementationExtensions); | ||
if (extensions & Extensions.JavaScript) result.push(...supportedJSExtensionsFlat); | ||
if (extensions & Extensions.Declaration) result.push(...supportedDeclarationExtensions); | ||
if (extensions & Extensions.Json) result.push(Extension.Json); | ||
return result; | ||
} | ||
|
||
interface PathAndPackageId { | ||
readonly fileName: string; | ||
readonly packageId: PackageId | undefined; | ||
|
@@ -2084,11 +2097,16 @@ function loadNodeModuleFromDirectory(extensions: Extensions, candidate: string, | |
return withPackageId(packageInfo, loadNodeModuleFromDirectoryWorker(extensions, candidate, onlyRecordFailures, state, packageJsonContent, versionPaths)); | ||
} | ||
|
||
/** @internal */ | ||
export interface GetPackageJsonEntrypointsHost extends ModuleResolutionHost { | ||
readDirectory: CompilerHost["readDirectory"]; | ||
} | ||
|
||
/** @internal */ | ||
export function getEntrypointsFromPackageJsonInfo( | ||
packageJsonInfo: PackageJsonInfo, | ||
options: CompilerOptions, | ||
host: ModuleResolutionHost, | ||
host: GetPackageJsonEntrypointsHost, | ||
cache: ModuleResolutionCache | undefined, | ||
resolveJs?: boolean, | ||
): string[] | false { | ||
|
@@ -2119,7 +2137,7 @@ export function getEntrypointsFromPackageJsonInfo( | |
arrayIsEqualTo | ||
); | ||
for (const conditions of conditionSets) { | ||
const loadPackageJsonExportsState = { ...loadPackageJsonMainState, failedLookupLocations: [], conditions }; | ||
const loadPackageJsonExportsState = { ...loadPackageJsonMainState, failedLookupLocations: [], conditions, host }; | ||
const exportResolutions = loadEntrypointsFromExportMap( | ||
packageJsonInfo, | ||
packageJsonInfo.contents.packageJsonContent.exports, | ||
|
@@ -2139,7 +2157,7 @@ export function getEntrypointsFromPackageJsonInfo( | |
function loadEntrypointsFromExportMap( | ||
scope: PackageJsonInfo, | ||
exports: object, | ||
state: ModuleResolutionState, | ||
state: ModuleResolutionState & { host: GetPackageJsonEntrypointsHost }, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was going to ask if this would be better served as a parameter rather than tacked onto the state, but it does seem like this was already happening given what's in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This isn’t adding a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I was just unsure without trying to find this type (which I should have done!) |
||
extensions: Extensions, | ||
): PathAndExtension[] | undefined { | ||
let entrypoints: PathAndExtension[] | undefined; | ||
|
@@ -2160,17 +2178,37 @@ function loadEntrypointsFromExportMap( | |
return entrypoints; | ||
|
||
function loadEntrypointsFromTargetExports(target: unknown): boolean | undefined { | ||
if (typeof target === "string" && startsWith(target, "./") && target.indexOf("*") === -1) { | ||
const partsAfterFirst = getPathComponents(target).slice(2); | ||
if (partsAfterFirst.indexOf("..") >= 0 || partsAfterFirst.indexOf(".") >= 0 || partsAfterFirst.indexOf("node_modules") >= 0) { | ||
return false; | ||
if (typeof target === "string" && startsWith(target, "./")) { | ||
if (target.indexOf("*") >= 0 && state.host.readDirectory) { | ||
if (target.indexOf("*") !== target.lastIndexOf("*")) { | ||
return false; | ||
} | ||
|
||
state.host.readDirectory( | ||
scope.packageDirectory, | ||
extensionsToExtensionsArray(extensions), | ||
/*excludes*/ undefined, | ||
[changeAnyExtension(target.replace("*", "**/*"), getDeclarationEmitExtensionForPath(target))] | ||
andrewbranch marked this conversation as resolved.
Show resolved
Hide resolved
|
||
).forEach(entry => { | ||
entrypoints = appendIfUnique(entrypoints, { | ||
path: entry, | ||
ext: getAnyExtensionFromPath(entry), | ||
resolvedUsingTsExtension: undefined | ||
}); | ||
}); | ||
} | ||
const resolvedTarget = combinePaths(scope.packageDirectory, target); | ||
const finalPath = getNormalizedAbsolutePath(resolvedTarget, state.host.getCurrentDirectory?.()); | ||
const result = loadFileNameFromPackageJsonField(extensions, finalPath, /*onlyRecordFailures*/ false, state); | ||
if (result) { | ||
entrypoints = appendIfUnique(entrypoints, result, (a, b) => a.path === b.path); | ||
return true; | ||
else { | ||
const partsAfterFirst = getPathComponents(target).slice(2); | ||
if (partsAfterFirst.indexOf("..") >= 0 || partsAfterFirst.indexOf(".") >= 0 || partsAfterFirst.indexOf("node_modules") >= 0) { | ||
return false; | ||
} | ||
const resolvedTarget = combinePaths(scope.packageDirectory, target); | ||
const finalPath = getNormalizedAbsolutePath(resolvedTarget, state.host.getCurrentDirectory?.()); | ||
const result = loadFileNameFromPackageJsonField(extensions, finalPath, /*onlyRecordFailures*/ false, state); | ||
if (result) { | ||
entrypoints = appendIfUnique(entrypoints, result, (a, b) => a.path === b.path); | ||
return true; | ||
} | ||
} | ||
} | ||
else if (Array.isArray(target)) { | ||
|
@@ -2675,12 +2713,6 @@ function getLoadModuleFromTargetImportOrExport(extensions: Extensions, state: Mo | |
return ensureTrailingDirectorySeparator(combinePaths(root, dir)); | ||
} | ||
|
||
function useCaseSensitiveFileNames() { | ||
return !state.host.useCaseSensitiveFileNames ? true : | ||
typeof state.host.useCaseSensitiveFileNames === "boolean" ? state.host.useCaseSensitiveFileNames : | ||
state.host.useCaseSensitiveFileNames(); | ||
} | ||
|
||
function tryLoadInputFileForPath(finalPath: string, entry: string, packagePath: string, isImports: boolean) { | ||
// Replace any references to outputs for files in the program with the input files to support package self-names used with outDir | ||
// PROBLEM: We don't know how to calculate the output paths yet, because the "common source directory" we use as the base of the file structure | ||
|
@@ -2690,13 +2722,13 @@ function getLoadModuleFromTargetImportOrExport(extensions: Extensions, state: Mo | |
if (!state.isConfigLookup | ||
&& (state.compilerOptions.declarationDir || state.compilerOptions.outDir) | ||
&& finalPath.indexOf("/node_modules/") === -1 | ||
&& (state.compilerOptions.configFile ? containsPath(scope.packageDirectory, toAbsolutePath(state.compilerOptions.configFile.fileName), !useCaseSensitiveFileNames()) : true) | ||
&& (state.compilerOptions.configFile ? containsPath(scope.packageDirectory, toAbsolutePath(state.compilerOptions.configFile.fileName), !useCaseSensitiveFileNames(state)) : true) | ||
) { | ||
// So that all means we'll only try these guesses for files outside `node_modules` in a directory where the `package.json` and `tsconfig.json` are siblings. | ||
// Even with all that, we still don't know if the root of the output file structure will be (relative to the package file) | ||
// `.`, `./src` or any other deeper directory structure. (If project references are used, it's definitely `.` by fiat, so that should be pretty common.) | ||
|
||
const getCanonicalFileName = hostGetCanonicalFileName({ useCaseSensitiveFileNames }); | ||
const getCanonicalFileName = hostGetCanonicalFileName({ useCaseSensitiveFileNames: () => useCaseSensitiveFileNames(state) }); | ||
const commonSourceDirGuesses: string[] = []; | ||
// A `rootDir` compiler option strongly indicates the root location | ||
// A `composite` project is using project references and has it's common src dir set to `.`, so it shouldn't need to check any other locations | ||
|
@@ -2751,7 +2783,7 @@ function getLoadModuleFromTargetImportOrExport(extensions: Extensions, state: Mo | |
for (const commonSourceDirGuess of commonSourceDirGuesses) { | ||
const candidateDirectories = getOutputDirectoriesForBaseDirectory(commonSourceDirGuess); | ||
for (const candidateDir of candidateDirectories) { | ||
if (containsPath(candidateDir, finalPath, !useCaseSensitiveFileNames())) { | ||
if (containsPath(candidateDir, finalPath, !useCaseSensitiveFileNames(state))) { | ||
// The matched export is looking up something in either the out declaration or js dir, now map the written path back into the source dir and source extension | ||
const pathFragment = finalPath.slice(candidateDir.length + 1); // +1 to also remove directory seperator | ||
const possibleInputBase = combinePaths(commonSourceDirGuess, pathFragment); | ||
|
@@ -2761,7 +2793,7 @@ function getLoadModuleFromTargetImportOrExport(extensions: Extensions, state: Mo | |
const inputExts = getPossibleOriginalInputExtensionForExtension(possibleInputBase); | ||
for (const possibleExt of inputExts) { | ||
if (!extensionIsOk(extensions, possibleExt)) continue; | ||
const possibleInputWithInputExtension = changeAnyExtension(possibleInputBase, possibleExt, ext, !useCaseSensitiveFileNames()); | ||
const possibleInputWithInputExtension = changeAnyExtension(possibleInputBase, possibleExt, ext, !useCaseSensitiveFileNames(state)); | ||
if (state.host.fileExists(possibleInputWithInputExtension)) { | ||
return toSearchResult(withPackageId(scope, loadFileNameFromPackageJsonField(extensions, possibleInputWithInputExtension, /*onlyRecordFailures*/ false, state))); | ||
} | ||
|
@@ -3193,3 +3225,9 @@ function traceIfEnabled(state: ModuleResolutionState, diagnostic: DiagnosticMess | |
trace(state.host, diagnostic, ...args); | ||
} | ||
} | ||
|
||
function useCaseSensitiveFileNames(state: ModuleResolutionState) { | ||
return !state.host.useCaseSensitiveFileNames ? true : | ||
typeof state.host.useCaseSensitiveFileNames === "boolean" ? state.host.useCaseSensitiveFileNames : | ||
state.host.useCaseSensitiveFileNames(); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -60,6 +60,7 @@ import { | |
getEntrypointsFromPackageJsonInfo, | ||
getNormalizedAbsolutePath, | ||
getOrUpdate, | ||
GetPackageJsonEntrypointsHost, | ||
getStringComparer, | ||
HasInvalidatedLibResolutions, | ||
HasInvalidatedResolutions, | ||
|
@@ -2093,7 +2094,7 @@ export abstract class Project implements LanguageServiceHost, ModuleResolutionHo | |
} | ||
|
||
/** @internal */ | ||
getModuleResolutionHostForAutoImportProvider(): ModuleResolutionHost { | ||
getHostForAutoImportProvider(): GetPackageJsonEntrypointsHost { | ||
if (this.program) { | ||
return { | ||
fileExists: this.program.fileExists, | ||
|
@@ -2104,6 +2105,7 @@ export abstract class Project implements LanguageServiceHost, ModuleResolutionHo | |
getDirectories: this.projectService.host.getDirectories.bind(this.projectService.host), | ||
trace: this.projectService.host.trace?.bind(this.projectService.host), | ||
useCaseSensitiveFileNames: this.program.useCaseSensitiveFileNames(), | ||
readDirectory: this.projectService.host.readDirectory.bind(this.projectService.host), | ||
}; | ||
} | ||
return this.projectService.host; | ||
|
@@ -2132,7 +2134,7 @@ export abstract class Project implements LanguageServiceHost, ModuleResolutionHo | |
if (dependencySelection) { | ||
tracing?.push(tracing.Phase.Session, "getPackageJsonAutoImportProvider"); | ||
const start = timestamp(); | ||
this.autoImportProviderHost = AutoImportProviderProject.create(dependencySelection, this, this.getModuleResolutionHostForAutoImportProvider(), this.documentRegistry); | ||
this.autoImportProviderHost = AutoImportProviderProject.create(dependencySelection, this, this.getHostForAutoImportProvider(), this.documentRegistry); | ||
if (this.autoImportProviderHost) { | ||
updateProjectIfDirty(this.autoImportProviderHost); | ||
this.sendPerformanceEvent("CreatePackageJsonAutoImportProvider", timestamp() - start); | ||
|
@@ -2384,7 +2386,7 @@ export class AutoImportProviderProject extends Project { | |
private static readonly maxDependencies = 10; | ||
|
||
/** @internal */ | ||
static getRootFileNames(dependencySelection: PackageJsonAutoImportPreference, hostProject: Project, moduleResolutionHost: ModuleResolutionHost, compilerOptions: CompilerOptions): string[] { | ||
static getRootFileNames(dependencySelection: PackageJsonAutoImportPreference, hostProject: Project, host: GetPackageJsonEntrypointsHost, compilerOptions: CompilerOptions): string[] { | ||
if (!dependencySelection) { | ||
return ts.emptyArray; | ||
} | ||
|
@@ -2422,7 +2424,7 @@ export class AutoImportProviderProject extends Project { | |
name, | ||
hostProject.currentDirectory, | ||
compilerOptions, | ||
moduleResolutionHost, | ||
host, | ||
program.getModuleResolutionCache()); | ||
if (packageJson) { | ||
const entrypoints = getRootNamesFromPackageJson(packageJson, program, symlinkCache); | ||
|
@@ -2441,7 +2443,7 @@ export class AutoImportProviderProject extends Project { | |
`@types/${name}`, | ||
directory, | ||
compilerOptions, | ||
moduleResolutionHost, | ||
host, | ||
program.getModuleResolutionCache()); | ||
if (typesPackageJson) { | ||
const entrypoints = getRootNamesFromPackageJson(typesPackageJson, program, symlinkCache); | ||
|
@@ -2480,11 +2482,11 @@ export class AutoImportProviderProject extends Project { | |
const entrypoints = getEntrypointsFromPackageJsonInfo( | ||
packageJson, | ||
compilerOptions, | ||
moduleResolutionHost, | ||
host, | ||
program.getModuleResolutionCache(), | ||
resolveJs); | ||
if (entrypoints) { | ||
const real = moduleResolutionHost.realpath?.(packageJson.packageDirectory); | ||
const real = host.realpath?.(packageJson.packageDirectory); | ||
const isSymlink = real && real !== packageJson.packageDirectory; | ||
if (isSymlink) { | ||
symlinkCache.setSymlinkedDirectory(packageJson.packageDirectory, { | ||
|
@@ -2514,7 +2516,7 @@ export class AutoImportProviderProject extends Project { | |
}; | ||
|
||
/** @internal */ | ||
static create(dependencySelection: PackageJsonAutoImportPreference, hostProject: Project, moduleResolutionHost: ModuleResolutionHost, documentRegistry: DocumentRegistry): AutoImportProviderProject | undefined { | ||
static create(dependencySelection: PackageJsonAutoImportPreference, hostProject: Project, host: GetPackageJsonEntrypointsHost, documentRegistry: DocumentRegistry): AutoImportProviderProject | undefined { | ||
if (dependencySelection === PackageJsonAutoImportPreference.Off) { | ||
return undefined; | ||
} | ||
|
@@ -2524,7 +2526,7 @@ export class AutoImportProviderProject extends Project { | |
...this.compilerOptionsOverrides, | ||
}; | ||
|
||
const rootNames = this.getRootFileNames(dependencySelection, hostProject, moduleResolutionHost, compilerOptions); | ||
const rootNames = this.getRootFileNames(dependencySelection, hostProject, host, compilerOptions); | ||
if (!rootNames.length) { | ||
return undefined; | ||
} | ||
|
@@ -2573,7 +2575,7 @@ export class AutoImportProviderProject extends Project { | |
rootFileNames = AutoImportProviderProject.getRootFileNames( | ||
this.hostProject.includePackageJsonAutoImports(), | ||
this.hostProject, | ||
this.hostProject.getModuleResolutionHostForAutoImportProvider(), | ||
this.hostProject.getHostForAutoImportProvider(), | ||
this.getCompilationSettings()); | ||
} | ||
|
||
|
@@ -2620,7 +2622,7 @@ export class AutoImportProviderProject extends Project { | |
throw new Error("package.json changes should be notified on an AutoImportProvider's host project"); | ||
} | ||
|
||
override getModuleResolutionHostForAutoImportProvider(): never { | ||
override getHostForAutoImportProvider(): never { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this have been internal to begin with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It’s never been clear to me what parts of TS Server are intended to be public API. It seems like this whole class could be internal. |
||
throw new Error("AutoImportProviderProject cannot provide its own host; use `hostProject.getModuleResolutionHostForAutomImportProvider()` instead."); | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
/// <reference path="../fourslash.ts" /> | ||
|
||
// @Filename: /node_modules/pkg/package.json | ||
//// { | ||
//// "name": "pkg", | ||
//// "version": "1.0.0", | ||
//// "exports": { | ||
//// "./*": "./a/*.js", | ||
//// "./b/*.js": "./b/*.js", | ||
//// "./c/*": "./c/*" | ||
//// } | ||
//// } | ||
|
||
// @Filename: /node_modules/pkg/a/a1.d.ts | ||
//// export const a1: number; | ||
|
||
// @Filename: /node_modules/pkg/b/b1.d.ts | ||
//// export const b1: number; | ||
|
||
// @Filename: /node_modules/pkg/b/b2.d.mts | ||
//// export const NOT_REACHABLE: number; | ||
|
||
// @Filename: /node_modules/pkg/c/c1.d.ts | ||
//// export const c1: number; | ||
|
||
// @Filename: /node_modules/pkg/c/subfolder/c2.d.mts | ||
//// export const c2: number; | ||
|
||
// @Filename: /package.json | ||
//// { | ||
//// "type": "module", | ||
//// "dependencies": { | ||
//// "pkg": "1.0.0" | ||
//// } | ||
//// } | ||
|
||
// @Filename: /tsconfig.json | ||
//// { | ||
//// "compilerOptions": { | ||
//// "module": "nodenext" | ||
//// } | ||
//// } | ||
|
||
// @Filename: /main.ts | ||
//// /**/ | ||
|
||
verify.completions({ | ||
marker: "", | ||
includes: [ | ||
{ | ||
name: "a1", | ||
source: "pkg/a1", | ||
sourceDisplay: "pkg/a1", | ||
hasAction: true, | ||
sortText: completion.SortText.AutoImportSuggestions | ||
}, | ||
{ | ||
name: "b1", | ||
source: "pkg/b/b1.js", | ||
sourceDisplay: "pkg/b/b1.js", | ||
hasAction: true, | ||
sortText: completion.SortText.AutoImportSuggestions | ||
}, | ||
{ | ||
name: "c1", | ||
source: "pkg/c/c1.js", | ||
sourceDisplay: "pkg/c/c1.js", | ||
hasAction: true, | ||
sortText: completion.SortText.AutoImportSuggestions | ||
}, | ||
{ | ||
name: "c2", | ||
source: "pkg/c/subfolder/c2.mjs", | ||
sourceDisplay: "pkg/c/subfolder/c2.mjs", | ||
hasAction: true, | ||
sortText: completion.SortText.AutoImportSuggestions | ||
} | ||
], | ||
preferences: { | ||
includeCompletionsForModuleExports: true, | ||
allowIncompleteCompletions: true | ||
} | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Out of curiosity, how often does this happen? Is it worth caching in some way given the key is just a number?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The number of packages AutoImportProvider will process is capped at 10, so surely not enough times to be make an observable impact.