diff --git a/src/_utils.ts b/src/_utils.ts index bf41760..9b7ccd3 100644 --- a/src/_utils.ts +++ b/src/_utils.ts @@ -43,3 +43,24 @@ export function matchAll(regex, string, addition) { } return matches; } + +export function clearImports(imports: string) { + return (imports || "") + .replace(/(\/\/[^\n]*\n|\/\*.*\*\/)/g, "") + .replace(/\s+/g, " "); +} + +export function getImportNames(cleanedImports: string) { + const topLevelImports = cleanedImports.replace(/{([^}]*)}/, ""); + const namespacedImport = topLevelImports.match(/\* as \s*(\S*)/)?.[1]; + const defaultImport = + topLevelImports + .split(",") + .find((index) => !/[*{}]/.test(index)) + ?.trim() || undefined; + + return { + namespacedImport, + defaultImport, + }; +} diff --git a/src/analyze.ts b/src/analyze.ts index f1a9f00..0b66ba0 100644 --- a/src/analyze.ts +++ b/src/analyze.ts @@ -1,5 +1,5 @@ import { tokenizer } from "acorn"; -import { matchAll } from "./_utils"; +import { matchAll, clearImports, getImportNames } from "./_utils"; import { resolvePath, ResolveOptions } from "./resolve"; import { loadURL } from "./utils"; @@ -27,6 +27,12 @@ export interface DynamicImport extends ESMImport { expression: string; } +export interface TypeImport extends Omit { + type: "type"; + imports: string; + specifier: string; +} + export interface ESMExport { _type?: "declaration" | "named" | "default" | "star"; type: "declaration" | "named" | "default" | "star"; @@ -59,6 +65,8 @@ export const ESM_STATIC_IMPORT_RE = /(?<=\s|^|;)import\s*([\s"']*(?[\p{L}\p{M}\w\t\n\r $*,/{}]+)from\s*)?["']\s*(?(?<="\s*)[^"]*[^\s"](?=\s*")|(?<='\s*)[^']*[^\s'](?=\s*'))\s*["'][\s;]*/gmu; export const DYNAMIC_IMPORT_RE = /import\s*\((?(?:[^()]+|\((?:[^()]+|\([^()]*\))*\))*)\)/gm; +const IMPORT_NAMED_TYPE_RE = + /(?<=\s|^|;)import\s*type\s+([\s"']*(?[\w\t\n\r $*,/{}]+)from\s*)?["']\s*(?(?<="\s*)[^"]*[^\s"](?=\s*")|(?<='\s*)[^']*[^\s'](?=\s*'))\s*["'][\s;]*/gm; export const EXPORT_DECAL_RE = /\bexport\s+(?(async function|function|let|const enum|const|enum|var|class))\s+(?[\w$]+)/g; @@ -83,10 +91,19 @@ export function findDynamicImports(code: string): DynamicImport[] { return matchAll(DYNAMIC_IMPORT_RE, code, { type: "dynamic" }); } -export function parseStaticImport(matched: StaticImport): ParsedStaticImport { - const cleanedImports = (matched.imports || "") - .replace(/(\/\/[^\n]*\n|\/\*.*\*\/)/g, "") - .replace(/\s+/g, " "); +export function findTypeImports(code: string): TypeImport[] { + return [ + ...matchAll(IMPORT_NAMED_TYPE_RE, code, { type: "type" }), + ...matchAll(ESM_STATIC_IMPORT_RE, code, { type: "static" }).filter( + (match) => /[^A-Za-z]type\s/.test(match.imports) + ), + ]; +} + +export function parseStaticImport( + matched: StaticImport | TypeImport +): ParsedStaticImport { + const cleanedImports = clearImports(matched.imports); const namedImports = {}; for (const namedImport of cleanedImports @@ -98,13 +115,41 @@ export function parseStaticImport(matched: StaticImport): ParsedStaticImport { namedImports[source] = importName; } } - const topLevelImports = cleanedImports.replace(/{([^}]*)}/, ""); - const namespacedImport = topLevelImports.match(/\* as \s*(\S*)/)?.[1]; - const defaultImport = - topLevelImports - .split(",") - .find((index) => !/[*{}]/.test(index)) - ?.trim() || undefined; + const { namespacedImport, defaultImport } = getImportNames(cleanedImports); + + return { + ...matched, + defaultImport, + namespacedImport, + namedImports, + } as ParsedStaticImport; +} + +export function parseTypeImport( + matched: TypeImport | StaticImport +): ParsedStaticImport { + if (matched.type === "type") { + return parseStaticImport(matched); + } + + const cleanedImports = clearImports(matched.imports); + + const namedImports = {}; + for (const namedImport of cleanedImports + .match(/{([^}]*)}/)?.[1] + ?.split(",") || []) { + const [, source = namedImport.trim(), importName = source] = (() => { + return /\s+as\s+/.test(namedImport) + ? namedImport.match(/^\s*type\s+(\S*) as (\S*)\s*$/) || [] + : namedImport.match(/^\s*type\s+(\S*)\s*$/) || []; + })(); + + if (source && TYPE_RE.test(namedImport)) { + namedImports[source] = importName; + } + } + + const { namespacedImport, defaultImport } = getImportNames(cleanedImports); return { ...matched, diff --git a/src/utils.ts b/src/utils.ts index 75b8e0c..9a6dd8f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,7 @@ import { fileURLToPath as _fileURLToPath } from "node:url"; import { promises as fsp } from "node:fs"; import { normalizeSlash, BUILTIN_MODULES } from "./_utils"; +import { StaticImport, TypeImport } from "./analyze"; export function fileURLToPath(id: string): string { if (typeof id === "string" && !id.startsWith("file://")) { diff --git a/test/imports.test.ts b/test/imports.test.ts index 7512ea0..7269736 100644 --- a/test/imports.test.ts +++ b/test/imports.test.ts @@ -3,6 +3,8 @@ import { findDynamicImports, findStaticImports, parseStaticImport, + findTypeImports, + parseTypeImport, } from "../src"; // -- Static import -- @@ -153,6 +155,57 @@ const dynamicTests = { }, }; +const TypeTests = { + 'import { type Foo, Bar } from "module-name";': { + specifier: "module-name", + namedImports: { + Foo: "Foo", + }, + type: "static", + }, + 'import { member,/* hello */ type Foo as Baz, Bar } from "module-name";': { + specifier: "module-name", + namedImports: { + Foo: "Baz", + }, + type: "static", + }, + 'import type { Foo, Bar } from "module-name";': { + specifier: "module-name", + namedImports: { + Foo: "Foo", + Bar: "Bar", + }, + type: "type", + }, + 'import type Foo from "module-name";': { + specifier: "module-name", + defaultImport: "Foo", + type: "type", + }, + 'import type { Foo as Baz, Bar } from "module-name";': { + specifier: "module-name", + namedImports: { + Foo: "Baz", + Bar: "Bar", + }, + type: "type", + }, + 'import { type member } from " module-name";': { + specifier: "module-name", + namedImports: { member: "member" }, + type: "static", + }, + 'import { type member, type Foo as Bar } from " module-name";': { + specifier: "module-name", + namedImports: { + member: "member", + Foo: "Bar", + }, + type: "static", + }, +}; + describe("findStaticImports", () => { for (const [input, _results] of Object.entries(staticTests)) { it(input.replace(/\n/g, "\\n"), () => { @@ -191,3 +244,31 @@ describe("findDynamicImports", () => { }); } }); + +describe("findTypeImports", () => { + for (const [input, _results] of Object.entries(TypeTests)) { + it(input.replace(/\n/g, "\\n"), () => { + const matches = findTypeImports(input); + const results = Array.isArray(_results) ? _results : [_results]; + expect(matches.length).toEqual(results.length); + for (const [index, test] of results.entries()) { + const match = matches[index]; + expect(match.specifier).to.equal(test.specifier); + + const parsed = parseTypeImport(match); + if (test.type) { + expect(parsed.type).to.equals(test.type); + } + if (test.defaultImport) { + expect(parsed.defaultImport).to.equals(test.defaultImport); + } + if (test.namedImports) { + expect(parsed.namedImports).to.eql(test.namedImports); + } + if (test.namespacedImport) { + expect(parsed.namespacedImport).to.eql(test.namespacedImport); + } + } + }); + } +});