From 15570d05e422dd02635eb3c63dc6b3a036cb543a Mon Sep 17 00:00:00 2001 From: Ron Spickenagel Date: Sun, 3 Dec 2023 22:40:31 -0500 Subject: [PATCH] feat: Added Plugin Package Configuration + parseAllJsDoc (closes #134 closes #133) --- projects/core/shared/plugin-types.ts | 16 ++ projects/patch/src/plugin/plugin-creator.ts | 77 ++++--- projects/patch/src/plugin/plugin.ts | 205 +++++++++++++++++++ projects/patch/src/plugin/resolve-factory.ts | 117 ----------- projects/patch/src/ts/create-program.ts | 19 +- projects/patch/src/ts/shim.ts | 1 + projects/patch/src/types/plugin-types.ts | 5 + 7 files changed, 281 insertions(+), 159 deletions(-) create mode 100644 projects/patch/src/plugin/plugin.ts delete mode 100644 projects/patch/src/plugin/resolve-factory.ts diff --git a/projects/core/shared/plugin-types.ts b/projects/core/shared/plugin-types.ts index a220896..3db0731 100644 --- a/projects/core/shared/plugin-types.ts +++ b/projects/core/shared/plugin-types.ts @@ -138,3 +138,19 @@ export type RawPattern = ( ) => ts.Transformer; // endregion + +/* ****************************************************************************************************************** */ +// region: Plugin Package +/* ****************************************************************************************************************** */ + +export interface PluginPackageConfig { + tscOptions?: { + /** + * Sets the JSDocParsingMode to ParseAll + * @default false + */ + parseAllJsDoc?: boolean; + } +} + +// endregion diff --git a/projects/patch/src/plugin/plugin-creator.ts b/projects/patch/src/plugin/plugin-creator.ts index 604454b..28420f0 100644 --- a/projects/patch/src/plugin/plugin-creator.ts +++ b/projects/patch/src/plugin/plugin-creator.ts @@ -14,17 +14,18 @@ namespace tsp { ls?: tsShim.LanguageService; } + export namespace PluginCreator { + export interface Options { + resolveBaseDir: string; + } + } + // endregion /* ********************************************************* */ // region: Helpers /* ********************************************************* */ - function validateConfigs(configs: PluginConfig[]) { - for (const config of configs) - if (!config.name && !config.transform) throw new TsPatchError('tsconfig.json plugins error: transform must be present'); - } - function createTransformerFromPattern(opt: CreateTransformerFromPatternOptions): TransformerBasePlugin { const { factory, config, program, ls, registerConfig } = opt; const { transform, after, afterDeclarations, name, type, transformProgram, ...cleanConfig } = config; @@ -131,27 +132,31 @@ namespace tsp { * PluginCreator (Class) * ********************************************************* */ - /** - * @example - * - * new PluginCreator([ - * {transform: '@zerollup/ts-transform-paths', someOption: '123'}, - * {transform: '@zerollup/ts-transform-paths', type: 'ls', someOption: '123'}, - * {transform: '@zerollup/ts-transform-paths', type: 'ls', after: true, someOption: '123'} - * ]).createTransformers({ program }) - */ export class PluginCreator { - constructor( - private configs: PluginConfig[], - public resolveBaseDir: string = process.cwd() - ) - { - validateConfigs(configs); + public readonly plugins: TspPlugin[] = []; + public readonly options: PluginCreator.Options; + public readonly needsTscJsDocParsing: boolean; + + private readonly configs: PluginConfig[]; + + constructor(configs: PluginConfig[], options: PluginCreator.Options) { + this.configs = configs; + this.options = options; + + const { resolveBaseDir } = options; + + /* Create plugins */ + this.plugins = configs.map(config => new TspPlugin(config, { resolveBaseDir })); + + /* Check if we need to parse all JSDoc comments */ + this.needsTscJsDocParsing = this.plugins.some(plugin => plugin.packageConfig?.tscOptions?.parseAllJsDoc === true); } - public mergeTransformers(into: TransformerList, source: tsShim.CustomTransformers | TransformerBasePlugin) { + private mergeTransformers(into: TransformerList, source: tsShim.CustomTransformers | TransformerBasePlugin) { const slice = (input: T | T[]) => (Array.isArray(input) ? input.slice() : [ input ]); + // TODO : Consider making this optional https://github.com/nonara/ts-patch/issues/122 + if (source.before) into.before.push(...slice(source.before)); if (source.after) into.after.push(...slice(source.after)); if (source.afterDeclarations) into.afterDeclarations.push(...slice(source.afterDeclarations)); @@ -159,7 +164,7 @@ namespace tsp { return this; } - public createTransformers( + public createSourceTransformers( params: { program: tsShim.Program } | { ls: tsShim.LanguageService }, customTransformers?: tsShim.CustomTransformers ): TransformerList { @@ -167,13 +172,15 @@ namespace tsp { const [ ls, program ] = ('ls' in params) ? [ params.ls, params.ls.getProgram()! ] : [ void 0, params.program ]; - for (const config of this.configs) { - if (!config.transform || config.transformProgram) continue; + for (const plugin of this.plugins) { + if (plugin.kind !== 'SourceTransformer') continue; + + const { config } = plugin; - const resolvedFactory = tsp.resolveFactory(this, config); - if (!resolvedFactory) continue; + const createFactoryResult = plugin.createFactory(); + if (!createFactoryResult) continue; - const { factory, registerConfig } = resolvedFactory; + const { factory, registerConfig } = createFactoryResult; this.mergeTransformers( transformers, @@ -193,16 +200,18 @@ namespace tsp { return transformers; } - public getProgramTransformers(): Map { + public createProgramTransformers(): Map { const res = new Map(); - for (const config of this.configs) { - if (!config.transform || !config.transformProgram) continue; + for (const plugin of this.plugins) { + if (plugin.kind !== 'ProgramTransformer') continue; + + const { config } = plugin; - const resolvedFactory = resolveFactory(this, config); - if (resolvedFactory === undefined) continue; + const createFactoryResult = plugin.createFactory(); + if (createFactoryResult === undefined) continue; - const { registerConfig } = resolvedFactory; - const factory = wrapTransformer(resolvedFactory.factory as ProgramTransformer, registerConfig, false); + const { registerConfig, factory: unwrappedFactory } = createFactoryResult; + const factory = wrapTransformer(unwrappedFactory as ProgramTransformer, registerConfig, false); const transformerKey = crypto .createHash('md5') diff --git a/projects/patch/src/plugin/plugin.ts b/projects/patch/src/plugin/plugin.ts new file mode 100644 index 0000000..e440b1f --- /dev/null +++ b/projects/patch/src/plugin/plugin.ts @@ -0,0 +1,205 @@ +namespace tsp { + const path = require('path'); + const fs = require('fs'); + + const requireStack: string[] = []; + + /* ****************************************************** */ + // region: Types + /* ****************************************************** */ + + export namespace TspPlugin { + export interface CreateOptions { + resolveBaseDir: string + } + + export type Kind = 'SourceTransformer' | 'ProgramTransformer' + } + + // endregion + + /* ****************************************************** */ + // region: Helpers + /* ****************************************************** */ + + function getModulePackagePath(transformerPath: string, resolveBaseDir: string): string | undefined { + let transformerPackagePath: string | undefined; + try { + const pathQuery = path.join(transformerPath, 'package.json'); + transformerPackagePath = path.normalize(require.resolve(pathQuery, { paths: [ resolveBaseDir ] })); + } catch (e) { + return undefined; + } + + let currentDir = path.dirname(transformerPath); + + const seenPaths = new Set(); + while (currentDir !== path.parse(currentDir).root) { + if (seenPaths.has(currentDir)) return undefined; + seenPaths.add(currentDir); + + // Could likely fail if the transformer is in a symlinked directory or the package's main file is in a + // directory above the package.json – however, I believe that the walking up method used here is the common + // approach, so we'll consider these acceptable edge cases for now. + if (path.relative(currentDir, transformerPackagePath).startsWith('..')) return undefined; + + const potentialPkgPath = path.join(currentDir, 'package.json'); + if (fs.existsSync(potentialPkgPath)) { + if (potentialPkgPath === transformerPackagePath) return transformerPackagePath; + return undefined; + } + + currentDir = path.resolve(currentDir, '..'); + } + + return undefined; + } + + // endregion + + /* ****************************************************** */ + // region: TspPlugin + /* ****************************************************** */ + + export class TspPlugin { + public readonly config: PluginConfig; + public readonly tsConfigPath: string | undefined; + public readonly entryFilePath: string; + public readonly importKey: string; + public readonly packageConfig: PluginPackageConfig | undefined; + public readonly kind: TspPlugin.Kind; + + private readonly _createOptions: TspPlugin.CreateOptions; + + constructor(config: PluginConfig, createOptions: TspPlugin.CreateOptions) { + this.config = { ...config }; + this.validateConfig(); + + this._createOptions = createOptions; + this.importKey = config.import || 'default'; + this.kind = config.transformProgram === true ? 'ProgramTransformer' : 'SourceTransformer'; + + const { resolveBaseDir } = createOptions; + const configTransformValue = config.transform!; + + /* Resolve paths */ + this.tsConfigPath = config.tsConfig && path.resolve(resolveBaseDir, config.tsConfig); + const entryFilePath = require.resolve(configTransformValue, { paths: [ resolveBaseDir ] }); + this.entryFilePath = entryFilePath; + + /* Get module PluginPackageConfig */ + const modulePackagePath = getModulePackagePath(entryFilePath, resolveBaseDir); + let pluginPackageConfig: PluginPackageConfig | undefined; + if (modulePackagePath) { + const modulePkgJsonContent = fs.readFileSync(modulePackagePath, 'utf8'); + const modulePkgJson = JSON.parse(modulePkgJsonContent) as { tsp?: PluginPackageConfig }; + + pluginPackageConfig = modulePkgJson.tsp; + if (pluginPackageConfig === null || typeof pluginPackageConfig !== 'object') pluginPackageConfig = undefined; + } + + this.packageConfig = pluginPackageConfig; + } + + private validateConfig() { + const { config } = this; + + const configTransformValue = config.transform; + if (!configTransformValue) throw new TsPatchError(`Invalid plugin config: missing "transform" value`); + + if (config.resolvePathAliases && !config.tsConfig) { + console.warn(`[ts-patch] Warning: resolvePathAliases needs a tsConfig value pointing to a tsconfig.json for transformer" ${configTransformValue}.`); + } + } + + createFactory() { + const { entryFilePath, config, tsConfigPath, importKey } = this; + const configTransformValue = config.transform!; + + /* Prevent circular require */ + if (requireStack.includes(entryFilePath)) return; + requireStack.push(entryFilePath); + + /* Check if ESM */ + let isEsm: boolean | undefined = config.isEsm; + if (isEsm == null) { + const impliedModuleFormat = tsShim.getImpliedNodeFormatForFile( + entryFilePath as tsShim.Path, + undefined, + tsShim.sys, + { moduleResolution: tsShim.ModuleResolutionKind.Node16 } + ); + + isEsm = impliedModuleFormat === tsShim.ModuleKind.ESNext; + } + + const isTs = configTransformValue.match(/\.[mc]?ts$/) != null; + + const registerConfig: RegisterConfig = { + isTs, + isEsm, + tsConfig: tsConfigPath, + pluginConfig: config + }; + + registerPlugin(registerConfig); + + try { + /* Load plugin */ + const commonjsModule = loadEntryFile(); + + const factoryModule = (typeof commonjsModule === 'function') ? { default: commonjsModule } : commonjsModule; + const factory = factoryModule[importKey]; + + if (!factory) + throw new TsPatchError( + `tsconfig.json > plugins: "${configTransformValue}" does not have an export "${importKey}": ` + + require('util').inspect(factoryModule) + ); + + if (typeof factory !== 'function') { + throw new TsPatchError( + `tsconfig.json > plugins: "${configTransformValue}" export "${importKey}" is not a plugin: ` + + require('util').inspect(factory) + ); + } + + return { + factory, + registerConfig: registerConfig + }; + } + finally { + requireStack.pop(); + unregisterPlugin(); + } + + function loadEntryFile(): PluginFactory | { [key: string]: PluginFactory } { + /* Load plugin */ + let res: PluginFactory | { [key: string]: PluginFactory } + try { + res = require(entryFilePath); + } catch (e) { + if (e.code === 'ERR_REQUIRE_ESM') { + if (!registerConfig.isEsm) { + unregisterPlugin(); + registerConfig.isEsm = true; + registerPlugin(registerConfig); + return loadEntryFile(); + } else { + throw new TsPatchError( + `Cannot load ESM transformer "${configTransformValue}" from "${entryFilePath}". Please file a bug report` + ); + } + } + else throw e; + } + return res; + } + } + } + + // endregion +} + +// endregion diff --git a/projects/patch/src/plugin/resolve-factory.ts b/projects/patch/src/plugin/resolve-factory.ts deleted file mode 100644 index e120292..0000000 --- a/projects/patch/src/plugin/resolve-factory.ts +++ /dev/null @@ -1,117 +0,0 @@ -namespace tsp { - const path = require('path'); - - const requireStack: string[] = []; - - /* ********************************************************* */ - // region: Types - /* ********************************************************* */ - - /** @internal */ - export interface ResolveFactoryResult { - factory: PluginFactory | ProgramTransformer - registerConfig: RegisterConfig - } - - // endregion - - /* ********************************************************* */ - // region: Utils - /* ********************************************************* */ - - export function resolveFactory(pluginCreator: PluginCreator, pluginConfig: PluginConfig): ResolveFactoryResult | undefined { - const tsConfig = pluginConfig.tsConfig && path.resolve(pluginCreator.resolveBaseDir, pluginConfig.tsConfig); - const transform = pluginConfig.transform!; - const importKey = pluginConfig.import || 'default'; - const transformerPath = require.resolve(transform, { paths: [ pluginCreator.resolveBaseDir ] }); - - if (pluginConfig.resolvePathAliases && !tsConfig) { - console.warn(`[ts-patch] Warning: resolvePathAliases needs a tsConfig value pointing to a tsconfig.json for transformer" ${transform}.`); - } - - /* Prevent circular require */ - if (requireStack.includes(transformerPath)) return; - requireStack.push(transformerPath); - - /* Check if ESM */ - let isEsm: boolean | undefined = pluginConfig.isEsm; - if (isEsm == null) { - const impliedModuleFormat = tsShim.getImpliedNodeFormatForFile( - transformerPath as tsShim.Path, - undefined, - tsShim.sys, - { moduleResolution: tsShim.ModuleResolutionKind.Node16 } - ); - - isEsm = impliedModuleFormat === tsShim.ModuleKind.ESNext; - } - - const isTs = transform!.match(/\.[mc]?ts$/) != null; - - const registerConfig: RegisterConfig = { - isTs, - isEsm, - tsConfig: tsConfig, - pluginConfig - }; - - registerPlugin(registerConfig); - - try { - /* Load plugin */ - const commonjsModule = loadPlugin(); - - const factoryModule = (typeof commonjsModule === 'function') ? { default: commonjsModule } : commonjsModule; - const factory = factoryModule[importKey]; - - if (!factory) - throw new TsPatchError( - `tsconfig.json > plugins: "${transform}" does not have an export "${importKey}": ` + - require('util').inspect(factoryModule) - ); - - if (typeof factory !== 'function') { - throw new TsPatchError( - `tsconfig.json > plugins: "${transform}" export "${importKey}" is not a plugin: ` + - require('util').inspect(factory) - ); - } - - return { - factory, - registerConfig: registerConfig, - }; - } - finally { - requireStack.pop(); - unregisterPlugin(); - } - - function loadPlugin(): PluginFactory | { [key: string]: PluginFactory } { - /* Load plugin */ - let res: PluginFactory | { [key: string]: PluginFactory } - try { - res = require(transformerPath); - } catch (e) { - if (e.code === 'ERR_REQUIRE_ESM') { - if (!registerConfig.isEsm) { - unregisterPlugin(); - registerConfig.isEsm = true; - registerPlugin(registerConfig); - return loadPlugin(); - } else { - throw new TsPatchError( - `Cannot load ESM transformer "${transform}" from "${transformerPath}". Please file a bug report` - ); - } - } - else throw e; - } - return res; - } - } - - // endregion -} - -// endregion diff --git a/projects/patch/src/ts/create-program.ts b/projects/patch/src/ts/create-program.ts index f889e9f..4694ac3 100644 --- a/projects/patch/src/ts/create-program.ts +++ b/projects/patch/src/ts/create-program.ts @@ -88,18 +88,21 @@ namespace tsp { if (createOpts) createOpts.options = options; } + /* Prepare Plugins */ + const plugins = preparePluginsFromCompilerOptions(options.plugins); + const pluginCreator = new PluginCreator(plugins, { resolveBaseDir: projectConfig.projectDir ?? process.cwd() }); + + if (tsp.currentLibrary === 'tsc' && pluginCreator.needsTscJsDocParsing) { + host!.jsDocParsingMode = tsShim.JSDocParsingMode.ParseAll; + } + /* Invoke TS createProgram */ - let program: tsShim.Program & { originalEmit?: tsShim.Program['emit'] } = - createOpts ? + let program: tsShim.Program & { originalEmit?: tsShim.Program['emit'] } = createOpts ? tsShim.originalCreateProgram(createOpts) : tsShim.originalCreateProgram(rootNames, options, host, oldProgram, configFileParsingDiagnostics); - /* Prepare Plugins */ - const plugins = preparePluginsFromCompilerOptions(options.plugins); - const pluginCreator = new PluginCreator(plugins, projectConfig.projectDir ?? process.cwd()); - /* Prevent recursion in Program transformers */ - const programTransformers = pluginCreator.getProgramTransformers(); + const programTransformers = pluginCreator.createProgramTransformers(); /* Transform Program */ for (const [ transformerKey, [ programTransformer, config ] ] of programTransformers) { @@ -127,7 +130,7 @@ namespace tsp { ...additionalArgs: any ): tsShim.EmitResult { /* Merge in our transformers */ - const transformers = pluginCreator.createTransformers({ program }, customTransformers); + const transformers = pluginCreator.createSourceTransformers({ program }, customTransformers); /* Invoke TS emit */ const result: tsShim.EmitResult = program.originalEmit!( diff --git a/projects/patch/src/ts/shim.ts b/projects/patch/src/ts/shim.ts index 9325565..aaafec5 100644 --- a/projects/patch/src/ts/shim.ts +++ b/projects/patch/src/ts/shim.ts @@ -44,5 +44,6 @@ namespace tsp { export type TransformerFactory = import('typescript').TransformerFactory; export type Bundle = import('typescript').Bundle; export type Path = import('typescript').Path; + export type JSDocParsingMode = import('typescript').JSDocParsingMode; } } diff --git a/projects/patch/src/types/plugin-types.ts b/projects/patch/src/types/plugin-types.ts index c6ccc3f..7f5a5f0 100644 --- a/projects/patch/src/types/plugin-types.ts +++ b/projects/patch/src/types/plugin-types.ts @@ -43,4 +43,9 @@ declare namespace tsp { export type TypeCheckerPattern = (checker: ts.TypeChecker, config: {}) => TransformerPlugin; export type ProgramPattern = (program: ts.Program, config: {}, extras: TransformerExtras) => TransformerPlugin; export type RawPattern = (context: ts.TransformationContext, program: ts.Program, config: {}) => ts.Transformer; + export interface PluginPackageConfig { + tscOptions?: { + parseAllJsDoc?: boolean; + }; + } }