Skip to content

Commit 60a1b1d

Browse files
authored
Proposal: If there’s a package.json, only auto-import things in it, more or less (microsoft#31893)
* Move package.json related utils to utilities * Add failing test * Make first test pass * Don’t filter when there’s no package.json, fix scoped package imports * Use type acquisition as a heuristic for whether a JS project is using node core * Make same fix in getCompletionDetails * Fix re-exporting * Change JS node core module heuristic to same-file utilization * Remove unused method * Remove other unused method * Remove unused triple-slash ref * Update comment * Refactor findAlias to forEachAlias to reduce iterations * Really fix re-exporting * Use getModuleSpecifier instead of custom hack * Fix offering auto imports to paths within node modules * Rename things and make comments better * Add another reexport test * Inline `symbolHasBeenSeen` * Simplify forEachAlias to findAlias * Add note that symbols is mutated * Symbol order doesn’t matter here * Style nits * Add test with nested package.jsons * Fix and add tests for export * re-exports
1 parent 71bec5b commit 60a1b1d

18 files changed

+751
-97
lines changed

src/compiler/utilities.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -7468,7 +7468,7 @@ namespace ts {
74687468
export function getDirectoryPath(path: Path): Path;
74697469
/**
74707470
* Returns the path except for its basename. Semantics align with NodeJS's `path.dirname`
7471-
* except that we support URL's as well.
7471+
* except that we support URLs as well.
74727472
*
74737473
* ```ts
74747474
* getDirectoryPath("/path/to/file.ext") === "/path/to"

src/harness/fourslash.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -798,7 +798,7 @@ namespace FourSlash {
798798
const name = typeof include === "string" ? include : include.name;
799799
const found = nameToEntries.get(name);
800800
if (!found) throw this.raiseError(`No completion ${name} found`);
801-
assert(found.length === 1); // Must use 'exact' for multiple completions with same name
801+
assert(found.length === 1, `Must use 'exact' for multiple completions with same name: '${name}'`);
802802
this.verifyCompletionEntry(ts.first(found), include);
803803
}
804804
}

src/services/codefixes/importFixes.ts

+118-6
Original file line numberDiff line numberDiff line change
@@ -283,13 +283,25 @@ namespace ts.codefix {
283283
preferences: UserPreferences,
284284
): ReadonlyArray<FixAddNewImport | FixUseImportType> {
285285
const isJs = isSourceFileJS(sourceFile);
286+
const { allowsImporting } = createLazyPackageJsonDependencyReader(sourceFile, host);
286287
const choicesForEachExportingModule = flatMap(moduleSymbols, ({ moduleSymbol, importKind, exportedSymbolIsTypeOnly }) =>
287288
moduleSpecifiers.getModuleSpecifiers(moduleSymbol, program.getCompilerOptions(), sourceFile, host, program.getSourceFiles(), preferences, program.redirectTargetsMap)
288289
.map((moduleSpecifier): FixAddNewImport | FixUseImportType =>
289290
// `position` should only be undefined at a missing jsx namespace, in which case we shouldn't be looking for pure types.
290291
exportedSymbolIsTypeOnly && isJs ? { kind: ImportFixKind.ImportType, moduleSpecifier, position: Debug.assertDefined(position) } : { kind: ImportFixKind.AddNew, moduleSpecifier, importKind }));
291-
// Sort to keep the shortest paths first
292-
return sort(choicesForEachExportingModule, (a, b) => a.moduleSpecifier.length - b.moduleSpecifier.length);
292+
293+
// Sort by presence in package.json, then shortest paths first
294+
return sort(choicesForEachExportingModule, (a, b) => {
295+
const allowsImportingA = allowsImporting(a.moduleSpecifier);
296+
const allowsImportingB = allowsImporting(b.moduleSpecifier);
297+
if (allowsImportingA && !allowsImportingB) {
298+
return -1;
299+
}
300+
if (allowsImportingB && !allowsImportingA) {
301+
return 1;
302+
}
303+
return a.moduleSpecifier.length - b.moduleSpecifier.length;
304+
});
293305
}
294306

295307
function getFixesForAddImport(
@@ -380,7 +392,8 @@ namespace ts.codefix {
380392
// "default" is a keyword and not a legal identifier for the import, so we don't expect it here
381393
Debug.assert(symbolName !== InternalSymbolName.Default);
382394

383-
const fixes = arrayFrom(flatMapIterator(getExportInfos(symbolName, getMeaningFromLocation(symbolToken), cancellationToken, sourceFile, checker, program).entries(), ([_, exportInfos]) =>
395+
const exportInfos = getExportInfos(symbolName, getMeaningFromLocation(symbolToken), cancellationToken, sourceFile, checker, program, preferences, host);
396+
const fixes = arrayFrom(flatMapIterator(exportInfos.entries(), ([_, exportInfos]) =>
384397
getFixForImport(exportInfos, symbolName, symbolToken.getStart(sourceFile), program, sourceFile, host, preferences)));
385398
return { fixes, symbolName };
386399
}
@@ -393,14 +406,16 @@ namespace ts.codefix {
393406
sourceFile: SourceFile,
394407
checker: TypeChecker,
395408
program: Program,
409+
preferences: UserPreferences,
410+
host: LanguageServiceHost
396411
): ReadonlyMap<ReadonlyArray<SymbolExportInfo>> {
397412
// For each original symbol, keep all re-exports of that symbol together so we can call `getCodeActionsForImport` on the whole group at once.
398413
// Maps symbol id to info for modules providing that symbol (original export + re-exports).
399414
const originalSymbolToExportInfos = createMultiMap<SymbolExportInfo>();
400415
function addSymbol(moduleSymbol: Symbol, exportedSymbol: Symbol, importKind: ImportKind): void {
401416
originalSymbolToExportInfos.add(getUniqueSymbolId(exportedSymbol, checker).toString(), { moduleSymbol, importKind, exportedSymbolIsTypeOnly: isTypeOnlySymbol(exportedSymbol, checker) });
402417
}
403-
forEachExternalModuleToImportFrom(checker, sourceFile, program.getSourceFiles(), moduleSymbol => {
418+
forEachExternalModuleToImportFrom(checker, host, preferences, program.redirectTargetsMap, sourceFile, program.getSourceFiles(), moduleSymbol => {
404419
cancellationToken.throwIfCancellationRequested();
405420

406421
const defaultInfo = getDefaultLikeExportInfo(moduleSymbol, checker, program.getCompilerOptions());
@@ -561,12 +576,44 @@ namespace ts.codefix {
561576
return some(declarations, decl => !!(getMeaningFromDeclaration(decl) & meaning));
562577
}
563578

564-
export function forEachExternalModuleToImportFrom(checker: TypeChecker, from: SourceFile, allSourceFiles: ReadonlyArray<SourceFile>, cb: (module: Symbol) => void) {
579+
export function forEachExternalModuleToImportFrom(checker: TypeChecker, host: LanguageServiceHost, preferences: UserPreferences, redirectTargetsMap: RedirectTargetsMap, from: SourceFile, allSourceFiles: ReadonlyArray<SourceFile>, cb: (module: Symbol) => void) {
580+
const { allowsImporting } = createLazyPackageJsonDependencyReader(from, host);
581+
const compilerOptions = host.getCompilationSettings();
582+
const getCanonicalFileName = hostGetCanonicalFileName(host);
565583
forEachExternalModule(checker, allSourceFiles, (module, sourceFile) => {
566-
if (sourceFile === undefined || sourceFile !== from && isImportablePath(from.fileName, sourceFile.fileName)) {
584+
if (sourceFile === undefined && allowsImporting(stripQuotes(module.getName()))) {
567585
cb(module);
568586
}
587+
else if (sourceFile && sourceFile !== from && isImportablePath(from.fileName, sourceFile.fileName)) {
588+
const moduleSpecifier = getNodeModulesPackageNameFromFileName(sourceFile.fileName);
589+
if (!moduleSpecifier || allowsImporting(moduleSpecifier)) {
590+
cb(module);
591+
}
592+
}
569593
});
594+
595+
function getNodeModulesPackageNameFromFileName(importedFileName: string): string | undefined {
596+
const specifier = moduleSpecifiers.getModuleSpecifier(
597+
compilerOptions,
598+
from,
599+
toPath(from.fileName, /*basePath*/ undefined, getCanonicalFileName),
600+
importedFileName,
601+
host,
602+
allSourceFiles,
603+
preferences,
604+
redirectTargetsMap);
605+
606+
// Paths here are not node_modules, so we don’t care about them;
607+
// returning anything will trigger a lookup in package.json.
608+
if (!pathIsRelative(specifier) && !isRootedDiskPath(specifier)) {
609+
const components = getPathComponents(getPackageNameFromTypesPackageName(specifier)).slice(1);
610+
// Scoped packages
611+
if (startsWith(components[0], "@")) {
612+
return `${components[0]}/${components[1]}`;
613+
}
614+
return components[0];
615+
}
616+
}
570617
}
571618

572619
function forEachExternalModule(checker: TypeChecker, allSourceFiles: ReadonlyArray<SourceFile>, cb: (module: Symbol, sourceFile: SourceFile | undefined) => void) {
@@ -620,4 +667,69 @@ namespace ts.codefix {
620667
// Need `|| "_"` to ensure result isn't empty.
621668
return !isStringANonContextualKeyword(res) ? res || "_" : `_${res}`;
622669
}
670+
671+
function createLazyPackageJsonDependencyReader(fromFile: SourceFile, host: LanguageServiceHost) {
672+
const packageJsonPaths = findPackageJsons(getDirectoryPath(fromFile.fileName), host);
673+
const dependencyIterator = readPackageJsonDependencies(host, packageJsonPaths);
674+
let seenDeps: Map<true> | undefined;
675+
let usesNodeCoreModules: boolean | undefined;
676+
return { allowsImporting };
677+
678+
function containsDependency(dependency: string) {
679+
if ((seenDeps || (seenDeps = createMap())).has(dependency)) {
680+
return true;
681+
}
682+
let packageName: string | void;
683+
while (packageName = dependencyIterator.next().value) {
684+
seenDeps.set(packageName, true);
685+
if (packageName === dependency) {
686+
return true;
687+
}
688+
}
689+
return false;
690+
}
691+
692+
function allowsImporting(moduleSpecifier: string): boolean {
693+
if (!packageJsonPaths.length) {
694+
return true;
695+
}
696+
697+
// If we’re in JavaScript, it can be difficult to tell whether the user wants to import
698+
// from Node core modules or not. We can start by seeing if the user is actually using
699+
// any node core modules, as opposed to simply having @types/node accidentally as a
700+
// dependency of a dependency.
701+
if (isSourceFileJS(fromFile) && JsTyping.nodeCoreModules.has(moduleSpecifier)) {
702+
if (usesNodeCoreModules === undefined) {
703+
usesNodeCoreModules = consumesNodeCoreModules(fromFile);
704+
}
705+
if (usesNodeCoreModules) {
706+
return true;
707+
}
708+
}
709+
710+
return containsDependency(moduleSpecifier)
711+
|| containsDependency(getTypesPackageName(moduleSpecifier));
712+
}
713+
}
714+
715+
function *readPackageJsonDependencies(host: LanguageServiceHost, packageJsonPaths: string[]) {
716+
type PackageJson = Record<typeof dependencyKeys[number], Record<string, string> | undefined>;
717+
const dependencyKeys = ["dependencies", "devDependencies", "optionalDependencies"] as const;
718+
for (const fileName of packageJsonPaths) {
719+
const content = readJson(fileName, { readFile: host.readFile ? host.readFile.bind(host) : sys.readFile }) as PackageJson;
720+
for (const key of dependencyKeys) {
721+
const dependencies = content[key];
722+
if (!dependencies) {
723+
continue;
724+
}
725+
for (const packageName in dependencies) {
726+
yield packageName;
727+
}
728+
}
729+
}
730+
}
731+
732+
function consumesNodeCoreModules(sourceFile: SourceFile): boolean {
733+
return some(sourceFile.imports, ({ text }) => JsTyping.nodeCoreModules.has(text));
734+
}
623735
}

0 commit comments

Comments
 (0)