diff --git a/src/utils/reflection/import-directive.ts b/src/utils/reflection/import-directive.ts new file mode 100644 index 0000000..52bce70 --- /dev/null +++ b/src/utils/reflection/import-directive.ts @@ -0,0 +1,210 @@ +import { ModuleLoader, NODE_MODULE_LOADER } from "./module-loader"; + +/** + * Represents an import directive. + */ +export interface ImportDirective { + /** + * The name of the value to import. + */ + name: string; + + /** + * The module to import the value from. + */ + module?: string; + + /** + * Whether the value to import is the default export of the module or not. + * + * Defaults to `false`. + */ + isDefault?: boolean; +} + +/** + * Represents a result of executing an import directive. + * + * @template T - The type of value being imported. + */ +export interface ExecutedImportDirective { + /** + * The imported value. + */ + value?: T; + + /** + * The module the value was imported from. + */ + module: Record; +} + +/** + * Options for executing an import directive. + */ +export interface ImportDirectiveExecutionOptions { + /** + * Determines whether the imported module is required or not. + * If required and not present, will throw. + * + * Defaults to `false`. + */ + required?: boolean; + + /** + * A function for loading a module. + */ + moduleLoader?: ModuleLoader; + + /** + * A function that provides a default module. + * + * @param directive - The import directive to use when loading the default module. + * + * @returns A Promise resolving to the default module. + */ + defaultModuleProvider?: (directive: ImportDirective) => Promise; +} + +/** + * A default module provider. + * + * @returns The `globalThis` object. + */ +const DEFAULT_MODULE_PROVIDER = () => Promise.resolve(globalThis); + +/** + * The name of the default export. + */ +const DEFAULT_EXPORT_NAME = "default"; + +/** + * Returns a string representation of an import directive. + * + * @param directive - The import directive to stringify. + * + * @returns A string representation of the import directive, or `undefined` if the input is invalid. + * + * @example + * + * ``` + * // "myModule->{myFunction}" + * formatImportDirective({ name: "myFunction", module: "myModule", isDefault: false }); + * + * // "@my-org/my-package->myClass" + * formatImportDirective({ name: "myClass", module: "@my-org/my-package", isDefault: true }); + * ``` + */ +export function formatImportDirective(directive: ImportDirective): string | undefined { + if (!directive) { + return undefined; + } + + const path = directive.module ? `${directive.module}->` : ""; + const wrappedName = directive.isDefault ? directive.name : `{${directive.name}}`; + return `${path}${wrappedName}`; +} + +/** + * Parses a stringified import directive into its constituent parts. + * + * @param stringifiedDirective - The stringified import directive to parse. + * + * @returns The parsed import directive, or `undefined` if the input is invalid. + * + * @example + * + * ``` + * // { name: "MyClass", module: "@my-org/my-package", isDefault: false } + * parseImportDirective("@my-org/my-package->{MyClass}"); + * + * // { name: "myFunction", module: undefined, isDefault: true } + * parseImportDirective("myFunction"); + * ``` + */ +export function parseImportDirective(stringifiedDirective: string): ImportDirective | undefined { + if (!stringifiedDirective) { + return undefined; + } + + const parts = stringifiedDirective.split("->"); + const module = parts.length > 1 ? parts[0] : undefined; + const wrappedName = parts[parts.length - 1]; + const isDefault = !wrappedName.startsWith("{") && !wrappedName.endsWith("}"); + const name = wrappedName.replaceAll(/^{|}$/g, "").trim(); + return { name, module, isDefault }; +} + +/** + * Executes the given import directive and returns an object containing the imported value and the module it was imported from. + * + * @template T - The type of value being imported. + * + * @param directive - The import directive to execute. + * @param options - Options for executing the import directive. + * + * @returns A Promise resolving to an object containing the imported value and the module it was imported from, if any; otherwise, `undefined`. + */ +export async function executeImportDirective(directive: string | ImportDirective, options?: ImportDirectiveExecutionOptions): Promise | undefined> { + directive = typeof directive === "string" ? parseImportDirective(directive) : directive; + const moduleLoader = options?.moduleLoader || NODE_MODULE_LOADER; + const defaultModuleProvider = options?.defaultModuleProvider || DEFAULT_MODULE_PROVIDER; + + const targetModule = await (directive.module ? moduleLoader(directive.module) : defaultModuleProvider(directive)); + if (options?.required && !targetModule) { + throw new Error(`Cannot find module "${directive.module}".`); + } + if (!targetModule) { + return undefined; + } + + const importName = normalizeImportName(directive.name); + const value = targetModule[directive.isDefault ? DEFAULT_EXPORT_NAME : importName] ?? targetModule[importName] ?? targetModule[directive.name]; + if (options?.required && value === undefined) { + throw new Error(`Cannot find value "${directive.name}" in the imported module${directive.module ? ` "${directive.module}"` : ""}.`); + } + + return { value, module: targetModule as Record }; +} + +/** + * Normalizes an import name. + * + * @param name - The import name to normalize. + * + * @returns A normalized import name. + */ +function normalizeImportName(name: string): string { + /** + * Trims whitespace from the name, if present. + */ + name = name?.trim(); + + /** + * If the name is empty, return the default export name. + */ + if (!name) { + return DEFAULT_EXPORT_NAME; + } + + /** + * If the name starts with "[" or ends with "]" (i.e., points to the Array type), + * return "Array". + */ + if (name.startsWith("[") || name.endsWith("]")) { + return Array.name; + } + + /** + * If the name contains generics, strip them and recursively call this function on the result. + */ + if (name.includes("<") && name.includes(">")) { + const nameWithoutGenerics = name.replaceAll(/<.*>/g, ""); + return normalizeImportName(nameWithoutGenerics); + } + + /** + * Otherwise, return the name as-is. + */ + return name; +} diff --git a/tests/unit/utils/reflection/import-directive.spec.ts b/tests/unit/utils/reflection/import-directive.spec.ts new file mode 100644 index 0000000..e7d0d55 --- /dev/null +++ b/tests/unit/utils/reflection/import-directive.spec.ts @@ -0,0 +1,101 @@ +import { + formatImportDirective, + parseImportDirective, + executeImportDirective, +} from "@/utils/reflection/import-directive"; + +describe("formatImportDirective", () => { + test("formats an import directive correctly", () => { + expect(formatImportDirective({ name: "myFunction", module: "myModule", isDefault: false })).toEqual("myModule->{myFunction}"); + expect(formatImportDirective({ name: "myClass", module: "@my-org/my-package", isDefault: true })).toEqual("@my-org/my-package->myClass"); + expect(formatImportDirective({ name: "myFunction", isDefault: false })).toEqual("{myFunction}"); + expect(formatImportDirective({ name: "myFunction", isDefault: true })).toEqual("myFunction"); + expect(formatImportDirective({ name: "" })).toEqual("{}"); + }); + + test("returns undefined for a null or undefined directive", () => { + expect(formatImportDirective(null)).toBeUndefined(); + expect(formatImportDirective(undefined)).toBeUndefined(); + }); +}); + +describe("parseImportDirective", () => { + test("parses a stringified import directive correctly", () => { + expect(parseImportDirective("@my-org/my-package->{MyClass}")).toEqual({ name: "MyClass", module: "@my-org/my-package", isDefault: false }); + expect(parseImportDirective("@my-org/my-package->MyClass")).toEqual({ name: "MyClass", module: "@my-org/my-package", isDefault: true }); + expect(parseImportDirective("myFunction")).toEqual({ name: "myFunction", module: undefined, isDefault: true }); + expect(parseImportDirective("{myFunction}")).toEqual({ name: "myFunction", module: undefined, isDefault: false }); + }); + + test("returns undefined for an invalid directive", () => { + expect(parseImportDirective(null)).toEqual(undefined); + expect(parseImportDirective(undefined)).toEqual(undefined); + expect(parseImportDirective("")).toEqual(undefined); + }); +}); + +describe("executeImportDirective", () => { + test("successfully executes an import directive", async () => { + const mockModule = { myFunction: jest.fn() }; + const mockModuleLoader = jest.fn().mockResolvedValue(mockModule); + const directive = { name: "myFunction", module: "myModule", isDefault: false }; + + const result = await executeImportDirective(directive, { moduleLoader: mockModuleLoader }); + + expect(result).toEqual({ value: mockModule.myFunction, module: mockModule }); + expect(mockModuleLoader).toHaveBeenCalledTimes(1); + expect(mockModuleLoader).toHaveBeenCalledWith("myModule"); + }); + + test("successfully executes an import directive for a default import", async () => { + const mockModule = { default: jest.fn() }; + const mockModuleLoader = jest.fn().mockResolvedValue(mockModule); + const directive = { name: "myFunction", module: "myModule", isDefault: true }; + + const result = await executeImportDirective(directive, { moduleLoader: mockModuleLoader }); + + expect(result).toEqual({ value: mockModule.default, module: mockModule }); + expect(mockModuleLoader).toHaveBeenCalledTimes(1); + expect(mockModuleLoader).toHaveBeenCalledWith("myModule"); + }); + + test("returns undefined instead of a missing non-required value in an existing module", async () => { + const mockModule = {}; + const mockModuleLoader = jest.fn().mockResolvedValue(mockModule); + const directive = { name: "nonExistentValue", module: "myModule", isDefault: false }; + + const result = await executeImportDirective(directive, { moduleLoader: mockModuleLoader }); + + expect(result).toEqual({ value: undefined, module: mockModule }); + expect(mockModuleLoader).toHaveBeenCalledWith("myModule"); + expect(mockModuleLoader).toHaveBeenCalledTimes(1); + }); + + test("returns undefined instead a missing non-required module", async () => { + const nonExistentModuleLoader = jest.fn().mockResolvedValue(undefined); + const directive = { name: "nonExistentValue", module: "nonExistentModule", isDefault: false }; + + const result = await executeImportDirective(directive, { moduleLoader: nonExistentModuleLoader }); + + expect(nonExistentModuleLoader).toHaveBeenCalledWith("nonExistentModule"); + expect(result).toBeUndefined(); + }); + + test("throws an error when a required value is missing from an existing module", async () => { + const mockModuleLoader = jest.fn().mockResolvedValue({}); + const directive = { name: "nonExistentValue", module: "myModule", isDefault: false }; + + await expect(executeImportDirective(directive, { required: true, moduleLoader: mockModuleLoader })).rejects.toThrow(`Cannot find value "${directive.name}" in the imported module "${directive.module}".`); + expect(mockModuleLoader).toHaveBeenCalledWith("myModule"); + expect(mockModuleLoader).toHaveBeenCalledTimes(1); + }); + + test("throws an error when a required module is missing", async () => { + const nonExistentModuleLoader = jest.fn().mockResolvedValue(undefined); + const directive = { name: "nonExistentValue", module: "nonExistentModule", isDefault: false }; + + await expect(executeImportDirective(directive, { required: true, moduleLoader: nonExistentModuleLoader })).rejects.toThrow(`Cannot find module "${directive.module}".`); + expect(nonExistentModuleLoader).toHaveBeenCalledWith("nonExistentModule"); + expect(nonExistentModuleLoader).toHaveBeenCalledTimes(1); + }); +});