Skip to content

Commit

Permalink
feat: more complete support for imports with extensions
Browse files Browse the repository at this point in the history
  • Loading branch information
sorccu committed Jan 17, 2025
1 parent 23f4942 commit 779eb33
Show file tree
Hide file tree
Showing 18 changed files with 272 additions and 80 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const value = 'hello'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const value = 'world'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const value = 3
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { } from './dep1'
import { } from './dep2.js'
import { } from './dep3.js'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const value = 'hello'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const value = 'world'
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { } from './dep1'
import { } from './dep1.ts'
import { } from './dep1.js'
import { } from './dep2'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const value1 = 'value1'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const value2 = 'value2'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const value3 = 'value3'
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { value1 } from './dep1.ts'
export { value2 } from './dep2.js'
export { value3 } from './dep3'
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "nodenext",
"esModuleInterop": true,
"baseUrl": ".",
"noEmit": true,
"allowImportingTsExtensions": true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,51 @@ describe('dependency-parser - parser()', () => {
expect(dependencies.map(d => d.filePath).sort()).toEqual([])
})

it('should support importing ts extensions if allowed', () => {
const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'tsconfig-allow-importing-ts-extensions', ...filepath)
const parser = new Parser({
supportedNpmModules: defaultNpmModules,
})
const { dependencies } = parser.parse(toAbsolutePath('src', 'entrypoint.ts'))
expect(dependencies.map(d => d.filePath).sort()).toEqual([
toAbsolutePath('src', 'dep1.ts'),
toAbsolutePath('src', 'dep2.ts'),
toAbsolutePath('src', 'dep3.ts'),
])
})

it('should not import TS files from a JS file', () => {
const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'no-import-ts-from-js', ...filepath)
const parser = new Parser({
supportedNpmModules: defaultNpmModules,
})
try {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { dependencies } = parser.parse(toAbsolutePath('entrypoint.js'))
} catch (err) {
expect(err).toMatchObject({
missingFiles: [
toAbsolutePath('dep1'),
toAbsolutePath('dep1.ts'),
toAbsolutePath('dep1.js'),
],
})
}
})

it('should import JS files from a TS file', () => {
const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'import-js-from-ts', ...filepath)
const parser = new Parser({
supportedNpmModules: defaultNpmModules,
})
const { dependencies } = parser.parse(toAbsolutePath('entrypoint.ts'))
expect(dependencies.map(d => d.filePath).sort()).toEqual([
toAbsolutePath('dep1.js'),
toAbsolutePath('dep2.js'),
toAbsolutePath('dep3.ts'),
])
})

it('should handle ES Modules', () => {
const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'esmodules-example', ...filepath)
const parser = new Parser({
Expand Down
76 changes: 76 additions & 0 deletions packages/cli/src/services/check-parser/package-files/extension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import path from 'node:path'

export type CoreExtension = '.js' | '.mjs' | '.cjs' | '.json'

export const CoreExtensions: CoreExtension[] = ['.js', '.mjs', '.cjs', '.json']

type CoreExtensionMapping = {
[key in CoreExtension]: string[]
}

/**
* @see https://www.typescriptlang.org/docs/handbook/modules/reference.html#file-extension-substitution
*/
export const tsCoreExtensionLookupOrder: CoreExtensionMapping = {
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
'.cjs': ['.cts', '.cjs'],
'.json': ['.json'],
}

export type TSExtension = '.ts' | '.mts' | '.tsx'

export const TSExtensions: TSExtension[] = ['.ts', '.mts', '.tsx']

type TSExtensionMapping = {
[key in TSExtension]: string[]
}

export const tsExtensionLookupOrder: TSExtensionMapping = {
'.ts': ['.ts'],
'.mts': ['.mts'],
'.tsx': ['.tsx'],
}

function stripKnownSuffix (value: string, suffix: string): string {
return value.substring(0, value.length - suffix.length)
}

export class FileExtPath {
filePath: string
ext: string

private constructor (filePath: string, ext: string) {
this.filePath = filePath
this.ext = ext
}

static fromFilePath (filePath: string) {
const ext = path.extname(filePath)
return new FileExtPath(filePath, ext)
}

hasCoreExtension () {
return this.ext in tsCoreExtensionLookupOrder
}

hasTypeScriptExtension () {
return this.ext in tsExtensionLookupOrder
}

replaceExt (ext: string) {
return stripKnownSuffix(this.filePath, this.ext) + ext
}

appendExt (ext: string) {
return this.filePath + ext
}

resolve (...paths: string[]) {
return path.resolve(this.filePath, ...paths)
}

self () {
return this.filePath
}
}
78 changes: 78 additions & 0 deletions packages/cli/src/services/check-parser/package-files/lookup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { PackageJsonFile } from './package-json-file'
import { CoreExtension, FileExtPath, tsCoreExtensionLookupOrder, TSExtension, tsExtensionLookupOrder } from './extension'

type Options = {
plainJs?: boolean
allowImportingTsExtensions?: boolean
}

export class LookupContext {
#plainJs: boolean
#allowImportingTsExtensions: boolean

constructor (options: Options) {
this.#plainJs = options.plainJs ?? false
this.#allowImportingTsExtensions = options.allowImportingTsExtensions ?? false
}

static forFilePath (filePath: string, options?: Options) {
return new LookupContext({
plainJs: FileExtPath.fromFilePath(filePath).hasCoreExtension(),
allowImportingTsExtensions: options?.allowImportingTsExtensions,
})
}

switch (options: Options) {
return new LookupContext({
plainJs: options.plainJs ?? this.#plainJs,
allowImportingTsExtensions: options.allowImportingTsExtensions ?? this.#allowImportingTsExtensions,
})
}

collectLookupPaths (filePath: string): string[] {
const extPath = FileExtPath.fromFilePath(filePath)

if (this.#plainJs) {
if (extPath.hasCoreExtension()) {
return [extPath.self()]
}

return this.extlessCoreLookupPaths(extPath)
}

if (extPath.hasCoreExtension()) {
const extensions = tsCoreExtensionLookupOrder[extPath.ext as CoreExtension]
return extensions.map(ext => extPath.replaceExt(ext))
}

if (this.#allowImportingTsExtensions) {
if (extPath.hasTypeScriptExtension()) {
const extensions = tsExtensionLookupOrder[extPath.ext as TSExtension]
return extensions.map(ext => extPath.replaceExt(ext))
}
}

return this.extlessTSLookupPaths(extPath)
}

private extlessCoreLookupPaths (extPath: FileExtPath): string[] {
return [
extPath.appendExt('.js'),
extPath.appendExt('.mjs'),
extPath.appendExt('.cjs'),
extPath.appendExt('.json'),
extPath.resolve(PackageJsonFile.FILENAME),
extPath.resolve('index.js'),
extPath.resolve('index.mjs'),
extPath.resolve('index.cjs'),
]
}

private extlessTSLookupPaths (extPath: FileExtPath): string[] {
return this.extlessCoreLookupPaths(extPath).flatMap(filePath => {
const extPath = FileExtPath.fromFilePath(filePath)
const extensions = tsCoreExtensionLookupOrder[extPath.ext as CoreExtension]
return extensions.map(ext => extPath.replaceExt(ext))
})
}
}
48 changes: 29 additions & 19 deletions packages/cli/src/services/check-parser/package-files/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { JSConfigFile } from './jsconfig-json-file'
import { isLocalPath, PathResult } from './paths'
import { FileLoader, LoadFile } from './loader'
import { JsonSourceFile } from './json-source-file'
import { LookupContext } from './lookup'

class PackageFilesCache {
#sourceFileCache = new FileLoader(SourceFile.loadFromFilePath)
Expand All @@ -33,11 +34,9 @@ class PackageFilesCache {
#tsconfigJsonCache = new FileLoader(this.#jsonFileLoader(TSConfigFile.loadFromJsonSourceFile))
#jsconfigJsonCache = new FileLoader(this.#jsonFileLoader(JSConfigFile.loadFromJsonSourceFile))

sourceFile (filePath: string, suffixes?: string[]) {
for (const suffix of ['', ...suffixes ?? []]) {
const suffixFilePath = filePath + suffix

const sourceFile = this.#sourceFileCache.load(suffixFilePath)
sourceFile (filePath: string, context: LookupContext) {
for (const lookupPath of context.collectLookupPaths(filePath)) {
const sourceFile = this.#sourceFileCache.load(lookupPath)
if (sourceFile === undefined) {
continue
}
Expand Down Expand Up @@ -185,7 +184,7 @@ export class PackageFilesResolver {
return files
}

private resolveSourceFile (sourceFile: SourceFile): SourceFile[] {
private resolveSourceFile (sourceFile: SourceFile, context: LookupContext): SourceFile[] {
if (sourceFile.meta.basename === PackageJsonFile.FILENAME) {
const packageJson = this.cache.packageJson(sourceFile.meta.filePath)
if (packageJson === undefined) {
Expand All @@ -208,9 +207,13 @@ export class PackageFilesResolver {
continue
}

const candidatePaths = configJson.collectLookupPaths(mainPath)
const candidatePaths = configJson.collectLookupPaths(mainPath).flatMap(filePath => {
return context.collectLookupPaths(filePath)
})
for (const candidatePath of candidatePaths) {
const mainSourceFile = this.cache.sourceFile(candidatePath)
const mainSourceFile = this.cache.sourceFile(candidatePath, context.switch({
allowImportingTsExtensions: configJson.allowImportingTsExtensions,
}))
if (mainSourceFile === undefined) {
continue
}
Expand All @@ -221,7 +224,7 @@ export class PackageFilesResolver {
}
}

const mainSourceFile = this.cache.sourceFile(mainPath)
const mainSourceFile = this.cache.sourceFile(mainPath, context)
if (mainSourceFile === undefined) {
continue
}
Expand All @@ -239,7 +242,6 @@ export class PackageFilesResolver {
resolveDependenciesForFilePath (
filePath: string,
dependencies: string[],
suffixes: string[],
): Dependencies {
const resolved: Dependencies = {
external: [],
Expand All @@ -250,14 +252,19 @@ export class PackageFilesResolver {
const dirname = path.dirname(filePath)

const { packageJson, tsconfigJson, jsconfigJson } = this.loadPackageFiles(filePath)
const mainConfigJson = tsconfigJson ?? jsconfigJson

const context = LookupContext.forFilePath(filePath, {
allowImportingTsExtensions: mainConfigJson?.allowImportingTsExtensions,
})

resolve:
for (const importPath of dependencies) {
if (isLocalPath(importPath)) {
const relativeDepPath = path.resolve(dirname, importPath)
const sourceFile = this.cache.sourceFile(relativeDepPath, suffixes)
const sourceFile = this.cache.sourceFile(relativeDepPath, context)
if (sourceFile !== undefined) {
const resolvedFiles = this.resolveSourceFile(sourceFile)
const resolvedFiles = this.resolveSourceFile(sourceFile, context)
let found = false
for (const resolvedFile of resolvedFiles) {
resolved.local.push({
Expand All @@ -278,20 +285,23 @@ export class PackageFilesResolver {
continue resolve
}

// TODO: Prefer jsconfig.json when dealing with a JavaScript file.
for (const configJson of [tsconfigJson, jsconfigJson]) {
if (configJson === undefined) {
continue
}

const configContext = context.switch({
allowImportingTsExtensions: configJson.allowImportingTsExtensions,
})

const resolvedPaths = configJson.resolvePath(importPath)
if (resolvedPaths.length > 0) {
let found = false
for (const { source, target } of resolvedPaths) {
const relativePath = path.resolve(configJson.basePath, target.path)
const sourceFile = this.cache.sourceFile(relativePath, suffixes)
const sourceFile = this.cache.sourceFile(relativePath, configContext)
if (sourceFile !== undefined) {
const resolvedFiles = this.resolveSourceFile(sourceFile)
const resolvedFiles = this.resolveSourceFile(sourceFile, configContext)
for (const resolvedFile of resolvedFiles) {
configJson.registerRelatedSourceFile(resolvedFile)
resolved.local.push({
Expand Down Expand Up @@ -326,9 +336,9 @@ export class PackageFilesResolver {

if (configJson.baseUrl !== undefined) {
const relativePath = path.resolve(configJson.basePath, configJson.baseUrl, importPath)
const sourceFile = this.cache.sourceFile(relativePath, suffixes)
const sourceFile = this.cache.sourceFile(relativePath, configContext)
if (sourceFile !== undefined) {
const resolvedFiles = this.resolveSourceFile(sourceFile)
const resolvedFiles = this.resolveSourceFile(sourceFile, configContext)
let found = false
for (const resolvedFile of resolvedFiles) {
configJson.registerRelatedSourceFile(resolvedFile)
Expand Down Expand Up @@ -356,9 +366,9 @@ export class PackageFilesResolver {
if (packageJson !== undefined) {
if (packageJson.supportsPackageRelativePaths()) {
const relativePath = path.resolve(packageJson.basePath, importPath)
const sourceFile = this.cache.sourceFile(relativePath, suffixes)
const sourceFile = this.cache.sourceFile(relativePath, context)
if (sourceFile !== undefined) {
const resolvedFiles = this.resolveSourceFile(sourceFile)
const resolvedFiles = this.resolveSourceFile(sourceFile, context)
let found = false
for (const resolvedFile of resolvedFiles) {
resolved.local.push({
Expand Down
Loading

0 comments on commit 779eb33

Please sign in to comment.