diff --git a/README.md b/README.md index 070f74b..e9f179e 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ export interface PluginConfig { /** * Plugin entry point format type, default is program */ - type?: 'program' | 'config' | 'checker' | 'raw' | 'compilerOptions'; + type?: 'program' | 'config' | 'checker' | 'raw' | 'compilerOptions' | 'middleware'; /** * Should transformer applied after all ones @@ -155,6 +155,49 @@ Plugin config entry: `{ "transform": "transformer-module", type: "compilerOption }, } ``` +#### middleware +This type is special — it allows to intercept the build process before it starts, by hooking into `ts.createProgram`, in style similar to Express middlewares. + +This mechanism allows to implement virtual modules, virtual file system, custom loaders etc. + +Example middleware: + +```ts +import * as ts from 'typescript'; + +export default function(config: { foo: string, bar?: string }): ts.Middleware { + return { + createProgram(opts, next) { + // somehow change options - for example, intercept methods of CompilerHost + opts.host = opts.host ? opts.host : ts.createCompilerHost(opts.options); + + // pass control either to real ts.createProgram, or to the earlier middleware + const program = next(opts); + + // it's possible to call `next` multiple times, or not call it at all + + // when `next()` is called without arguments, it will use the same `opts` object as current middleware + + // do something with built program + console.log(program.getTypeCount()); + + return program; + } + } +} +``` + +Example configuration: + +```json +{ + "compilerOptions": { + "plugins": [ + { "transform": "some-middleware-module", "foo": "this is config property", "bar": "this is too" } + ] + }, +} +``` ### Command line diff --git a/packages/ttypescript/__tests__/middlewares/create-program.ts b/packages/ttypescript/__tests__/middlewares/create-program.ts new file mode 100644 index 0000000..f1fa17c --- /dev/null +++ b/packages/ttypescript/__tests__/middlewares/create-program.ts @@ -0,0 +1,47 @@ +import * as ts from 'typescript' + +interface TestConfig { + trace: (message: string) => void; +} + +export function foo(config: TestConfig): ts.Middleware { + return { + createProgram(opts, next) { + config.trace("foo: before"); + + opts.host = opts.host ? opts.host : ts.createCompilerHost(opts.options); + const host = opts.host; + + const originFileExists = host.fileExists.bind(host); + + let existsCalled = false; + host.fileExists = (fileName: string): boolean => { + if (!existsCalled) { + existsCalled = true; + config.trace("in hook"); + } + return originFileExists(fileName); + } + + const result = next(opts); + + config.trace("foo: after"); + + return result; + } + } +} + +export function bar(config: TestConfig): ts.Middleware { + return { + createProgram(opts, next) { + config.trace("bar: before"); + + const result = next(opts); + + config.trace("bar: after"); + + return result; + } + } +} diff --git a/packages/ttypescript/__tests__/typescript.spec.ts b/packages/ttypescript/__tests__/typescript.spec.ts index b23597d..95e1f58 100644 --- a/packages/ttypescript/__tests__/typescript.spec.ts +++ b/packages/ttypescript/__tests__/typescript.spec.ts @@ -97,4 +97,28 @@ console.log(abc.toString()); const result = `var x = 1;\n`; expect(res.outputText).toEqual(result); }); + + it('should run middlewares in order', () => { + const lines: string[] = []; + const trace = (m: string) => { + lines.push(m); + } + + const res = ts.transpileModule('import { itWorks } from "##arbitrary##";', { + compilerOptions: { + plugins: [ + { transform: __dirname + '/middlewares/create-program.ts', import: 'foo', type: 'middleware', trace }, + { transform: __dirname + '/middlewares/create-program.ts', import: 'bar', type: 'middleware', trace }, + ] as any, + }, + }); + + expect(lines).toEqual([ + "bar: before", + "foo: before", + "in hook", + "foo: after", + "bar: after", + ]); + }); }); diff --git a/packages/ttypescript/src/PluginCreator.ts b/packages/ttypescript/src/PluginCreator.ts index 43964d2..5bf91cf 100644 --- a/packages/ttypescript/src/PluginCreator.ts +++ b/packages/ttypescript/src/PluginCreator.ts @@ -21,7 +21,7 @@ export interface PluginConfig { /** * Plugin entry point format type, default is program */ - type?: 'ls' | 'program' | 'config' | 'checker' | 'raw' | 'compilerOptions'; + type?: 'ls' | 'program' | 'config' | 'checker' | 'raw' | 'compilerOptions' | 'middleware'; /** * Should transformer applied after all ones @@ -34,7 +34,24 @@ export interface PluginConfig { afterDeclarations?: boolean; } -export interface TransformerBasePlugin { +export type CreateProgramMiddlewareNext = (createProgramOptions?: ts.CreateProgramOptions) => ts.Program; +export type CreateProgramMiddlewareHead = (createProgramOptions: ts.CreateProgramOptions) => ts.Program; +export type CreateProgramMiddleware = (createProgramOptions: ts.CreateProgramOptions, next: CreateProgramMiddlewareNext) => ts.Program; + +declare module 'typescript' { + export interface Middleware { + createProgram?: CreateProgramMiddleware; + } + export interface MiddlewareHead { + createProgram: CreateProgramMiddlewareHead; + } +} + +export type OriginEntries = { + createProgram: typeof ts.createProgram; +} + +export interface TransformerBasePlugin extends ts.Middleware { before?: ts.TransformerFactory; after?: ts.TransformerFactory; afterDeclarations?: ts.TransformerFactory; @@ -57,13 +74,18 @@ export type RawPattern = ( program: ts.Program, config: {} ) => ts.Transformer; +export type MiddlewarePattern = ( + config: {}, + typescript: typeof ts +) => ts.Middleware; export type PluginFactory = | LSPattern | ProgramPattern | ConfigPattern | CompilerOptionsPattern | TypeCheckerPattern - | RawPattern; + | RawPattern + | MiddlewarePattern; function createTransformerFromPattern({ typescript, @@ -75,7 +97,7 @@ function createTransformerFromPattern({ typescript: typeof ts; factory: PluginFactory; config: PluginConfig; - program: ts.Program; + program?: ts.Program; ls?: ts.LanguageService; }): TransformerBasePlugin { const { transform, after, afterDeclarations, name, type, ...cleanConfig } = config; @@ -87,24 +109,32 @@ function createTransformerFromPattern({ ret = (factory as LSPattern)(ls, cleanConfig); break; case 'config': + if (!program) throw new Error(`Plugin ${transform} needs a Program`); ret = (factory as ConfigPattern)(cleanConfig); break; case 'compilerOptions': + if (!program) throw new Error(`Plugin ${transform} needs a Program`); ret = (factory as CompilerOptionsPattern)(program.getCompilerOptions(), cleanConfig); break; case 'checker': + if (!program) throw new Error(`Plugin ${transform} needs a Program`); ret = (factory as TypeCheckerPattern)(program.getTypeChecker(), cleanConfig); break; case undefined: case 'program': + if (!program) throw new Error(`Plugin ${transform} needs a Program`); ret = (factory as ProgramPattern)(program, cleanConfig, { ts: typescript, addDiagnostic: addDiagnosticFactory(program), }); break; case 'raw': + if (!program) throw new Error(`Plugin ${transform} needs a Program`); ret = (ctx: ts.TransformationContext) => (factory as RawPattern)(ctx, program, cleanConfig); break; + case 'middleware': + ret = (factory as MiddlewarePattern)(cleanConfig, typescript); + break; default: return never(config.type); } @@ -174,9 +204,9 @@ export class PluginCreator { program = params.program; } for (const config of this.configs) { - if (!config.transform) { - continue; - } + if (config.type === 'middleware') continue; + if (!config.transform) continue; + const factory = this.resolveFactory(config.transform, config.import); // if recursion if (factory === undefined) continue; @@ -198,6 +228,51 @@ export class PluginCreator { return chain; } + private composeMiddlewareTransformers(inner: TransformerBasePlugin, outer: TransformerBasePlugin) { + inner.createProgram = this.composeMiddlewares(inner.createProgram, outer.createProgram); + } + + private composeMiddlewares(inner?: F, outer?: F): F { + if (!inner) { + throw new Error('inner middleware must exist'); + } + + if (!outer) { + return inner; + } + + return ((...args: A) => { + return outer(...args, (...newArgs: A) => { + newArgs = newArgs.length === 0 ? args : newArgs; + + return inner(...newArgs) + }); + }) as unknown as F; + } + + createMiddlewares(originEntries: OriginEntries): ts.MiddlewareHead { + const chain: ts.MiddlewareHead = { + createProgram: (opts) => originEntries.createProgram(opts) + } + + for (const config of this.configs) { + if (config.type !== 'middleware') continue; + if (!config.transform) continue; + + const factory = this.resolveFactory(config.transform, config.import); + // if recursion + if (factory === undefined) continue; + const transformer = createTransformerFromPattern({ + typescript: this.typescript, + factory, + config + }); + this.composeMiddlewareTransformers(chain, transformer); + } + + return chain; + } + private resolveFactory(transform: string, importKey: string = 'default'): PluginFactory | undefined { if ( !tsNodeIncluded && diff --git a/packages/ttypescript/src/patchCreateProgram.ts b/packages/ttypescript/src/patchCreateProgram.ts index 39fcb24..bdea469 100644 --- a/packages/ttypescript/src/patchCreateProgram.ts +++ b/packages/ttypescript/src/patchCreateProgram.ts @@ -32,52 +32,22 @@ export function addDiagnosticFactory(program: ts.Program) { export function patchCreateProgram(tsm: typeof ts, forceReadConfig = false, projectDir = process.cwd()) { const originCreateProgram = tsm.createProgram as any; - function createProgram(createProgramOptions: ts.CreateProgramOptions): ts.Program; - function createProgram( - rootNames: ReadonlyArray, - options: ts.CompilerOptions, - host?: ts.CompilerHost, - oldProgram?: ts.Program, - configFileParsingDiagnostics?: ReadonlyArray - ): ts.Program; - function createProgram( - rootNamesOrOptions: ReadonlyArray | ts.CreateProgramOptions, - options?: ts.CompilerOptions, - host?: ts.CompilerHost, - oldProgram?: ts.Program, - configFileParsingDiagnostics?: ReadonlyArray - ): ts.Program { - let rootNames; - let createOpts: ts.CreateProgramOptions | undefined; - if (!Array.isArray(rootNamesOrOptions)) { - createOpts = rootNamesOrOptions as ts.CreateProgramOptions; - } - if (createOpts) { - rootNames = createOpts.rootNames; - options = createOpts.options; - host = createOpts.host; - oldProgram = createOpts.oldProgram; - configFileParsingDiagnostics = createOpts.configFileParsingDiagnostics; - } else { - options = options!; - rootNames = rootNamesOrOptions as ReadonlyArray; - } - + function createProgramWithOpts(createProgramOptions: ts.CreateProgramOptions): ts.Program { if (forceReadConfig) { - const info = getConfig(tsm, options, rootNames, projectDir); - options = info.compilerOptions; - if (createOpts) { - createOpts.options = options; - } + const info = getConfig(tsm, createProgramOptions.options, createProgramOptions.rootNames, projectDir); + createProgramOptions.options = info.compilerOptions; projectDir = info.projectDir; } - const program: ts.Program = createOpts - ? originCreateProgram(createOpts) - : originCreateProgram(rootNames, options, host, oldProgram, configFileParsingDiagnostics); - const plugins = preparePluginsFromCompilerOptions(options.plugins); + const plugins = preparePluginsFromCompilerOptions(createProgramOptions.options.plugins); const pluginCreator = new PluginCreator(tsm, plugins, projectDir); + const middlewares = pluginCreator.createMiddlewares({ + createProgram: originCreateProgram + }); + + const program: ts.Program = middlewares.createProgram(createProgramOptions); + const originEmit = program.emit; program.emit = function newEmit( targetSourceFile?: ts.SourceFile, @@ -100,7 +70,37 @@ export function patchCreateProgram(tsm: typeof ts, forceReadConfig = false, proj }; return program; } + + function createProgram(createProgramOptions: ts.CreateProgramOptions): ts.Program; + function createProgram( + rootNames: ReadonlyArray, + options: ts.CompilerOptions, + host?: ts.CompilerHost, + oldProgram?: ts.Program, + configFileParsingDiagnostics?: ReadonlyArray + ): ts.Program; + function createProgram( + createProgramOptionsOrRootNames: ts.CreateProgramOptions | ReadonlyArray, + options?: ts.CompilerOptions, + host?: ts.CompilerHost, + oldProgram?: ts.Program, + configFileParsingDiagnostics?: ReadonlyArray + ): ts.Program { + if (Array.isArray(createProgramOptionsOrRootNames)) { + return createProgramWithOpts({ + rootNames: createProgramOptionsOrRootNames as ReadonlyArray, + options: options!, + host, + oldProgram, + configFileParsingDiagnostics + }); + } else { + return createProgramWithOpts(createProgramOptionsOrRootNames as ts.CreateProgramOptions); + } + } + tsm.createProgram = createProgram; + return tsm; }