Skip to content

Commit

Permalink
Support subpath import with arbitrary extensions (#723)
Browse files Browse the repository at this point in the history
  • Loading branch information
geigerzaehler authored Jul 12, 2024
1 parent d4121d9 commit c35bad7
Show file tree
Hide file tree
Showing 7 changed files with 58 additions and 30 deletions.
Binary file modified bun.lockb
Binary file not shown.
4 changes: 3 additions & 1 deletion packages/knip/fixtures/subpath-patterns/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
{
"name": "my-package",
"imports": {
"#internals/*": "./src/internals/*.ts"
"#internals/*": "./src/internals/*.ts",
"#internals-explicit-ext/*": "./src/internals/*",
"#internals-alias/used.alt": "./src/internals/used.alt"
}
}
2 changes: 2 additions & 0 deletions packages/knip/fixtures/subpath-patterns/src/entry.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
import used from '#internals/used';
import '#internals-explicit-ext/used.ext';
import '#internals-alias/used.alt';
used;
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

1 change: 0 additions & 1 deletion packages/knip/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@
"smol-toml": "^1.1.4",
"strip-json-comments": "5.0.1",
"summary": "2.1.0",
"tsconfig-paths": "^4.2.0",
"zod": "^3.22.4",
"zod-validation-error": "^3.0.3"
},
Expand Down
80 changes: 52 additions & 28 deletions packages/knip/src/typescript/resolveModuleNames.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { existsSync } from 'node:fs';
import { isBuiltin } from 'node:module';
import { createMatchPath } from 'tsconfig-paths';
import ts from 'typescript';
import { DEFAULT_EXTENSIONS } from '../constants.js';
import { timerify } from '../util/Performance.js';
import { sanitizeSpecifier } from '../util/modules.js';
import { dirname, extname, isAbsolute, isInNodeModules, join, toPosix } from '../util/path.js';
import { dirname, extname, isAbsolute, isInNodeModules, join } from '../util/path.js';
import { resolveSync } from '../util/resolve.js';
import type { ToSourceFilePath } from '../util/to-source-path.js';
import { isDeclarationFileExtension } from './ast-helpers.js';
Expand Down Expand Up @@ -35,6 +34,32 @@ export function createCustomModuleResolver(
const customCompilerExtensionsSet = new Set(customCompilerExtensions);
const extensions = [...DEFAULT_EXTENSIONS, ...customCompilerExtensions];

const virtualDeclarationFiles = new Map<string, { path: string; ext: string }>();

const tsSys: ts.System = {
...ts.sys,
// We trick TypeScript into resolving paths with arbitrary extensions. When
// a module "./module.ext" is imported TypeScript only tries to resolve it to
// "./module.d.ext.ts". TypeScript never checks whether "./module.ext" itself exists.
// So, if TypeScript checks whether "./module.d.ext.ts" exists and the file
// does not exist we can assume the compiler wants to resolve `./module.ext`.
// If the latter exists we return true and record this fact in
// `virtualDeclarationFiles`.
fileExists(path: string) {
if (ts.sys.fileExists(path)) {
return true;
}

const original = originalFromDeclarationPath(path);
if (original && ts.sys.fileExists(original.path)) {
virtualDeclarationFiles.set(path, original);
return true;
}

return false;
},
};

function resolveModuleNames(moduleNames: string[], containingFile: string): Array<ts.ResolvedModuleFull | undefined> {
return moduleNames.map(moduleName => {
if (!useCache) return resolveModuleName(moduleName, containingFile);
Expand All @@ -54,13 +79,6 @@ export function createCustomModuleResolver(
});
}

const tsMatchPath = createMatchPath(
// If `baseUrl` is undefined we have already modified `paths` so that all
// entries are absolute. See `mergePaths` in `src/PrincipalFactory.ts`.
compilerOptions.baseUrl ?? '/',
compilerOptions.paths || {}
);

/**
* - Virtual files have built-in or custom compiler, return as JS
* - Foreign files have path resolved verbatim (file manager will return empty source file)
Expand Down Expand Up @@ -97,29 +115,11 @@ export function createCustomModuleResolver(
};
}

const pathMappedFileName = tsMatchPath(
sanitizedSpecifier,
undefined,
undefined,
// Leave extensions empty and let `ts.resolveModuleName()` handle that.
// `tsMatchPath` will strip extensions from the returned specifier which
// means we don’t get the actual file path.
[]
);
if (pathMappedFileName) {
const ext = extname(pathMappedFileName);
return {
resolvedFileName: toPosix(pathMappedFileName),
extension: customCompilerExtensionsSet.has(ext) ? ts.Extension.Js : ext,
isExternalLibraryImport: false,
};
}

const tsResolvedModule = ts.resolveModuleName(
sanitizedSpecifier,
containingFile,
compilerOptions,
ts.sys
tsSys
).resolvedModule;

if (tsResolvedModule) {
Expand All @@ -135,6 +135,14 @@ export function createCustomModuleResolver(
};
}
}
const original = virtualDeclarationFiles.get(tsResolvedModule.resolvedFileName);
if (original) {
return {
...tsResolvedModule,
resolvedFileName: original.path,
extension: customCompilerExtensionsSet.has(original.ext) ? ts.Extension.Js : original.ext,
};
}

return tsResolvedModule;
}
Expand All @@ -147,3 +155,19 @@ export function createCustomModuleResolver(

return timerify(resolveModuleNames);
}

const declarationPathRe = /^(.*)\.d(\.[^.]+)\.ts$/;

/**
* For paths that look like `.../module.d.yyy.ts` returns path `.../module.yyy` and
* ext `yyy`.
*/
function originalFromDeclarationPath(path: string): { path: string; ext: string } | undefined {
const match = declarationPathRe.exec(path);
if (match) {
return {
path: match[1] + match[2],
ext: match[2],
};
}
}

0 comments on commit c35bad7

Please sign in to comment.