ts-morph icon indicating copy to clipboard operation
ts-morph copied to clipboard

Using ts-morph in transform plugin

Open MichalLytek opened this issue 5 years ago • 8 comments

Is your feature request related to a problem? Please describe.

I am trying to use ts-morph to create a custom transformer that will enhance the limited TypeScript reflection system.

The raw TS API is really hard to use without proper docs and tutorials but I've tried your API and it's great and have all I need, works great using a standalone Project API.

In transformer signature I can use createWrappedNode and pass program.getTypeChecker() to read all the types info of class properties but I'm not able to manipulate the class node, even simple things like rename throws an error:

A language service is required for this operation. This might occur when manipulating or getting type information from a node that was not added to a Project object and created via createWrappedNode. Please submit a bug report if you don't believe a language service should be required for this operation.

Describe the solution you'd like

It would be nice if some manipulations could work without language service (as it's possible to use ts.updateFooBar API to do it) as I think it's not possible to get a language service from Program, TransformationContext or SourceFile which are available in transform plugin hooks.

Describe alternatives you've considered

Using the raw compiler API... all I need is a way to pass additional data to the decorator factory call or emit a function call in the end of the file/class node + add import on top of file.

MichalLytek avatar Feb 14 '19 18:02 MichalLytek

Hi @19majkel94, sorry for the delay in my reply.

In order to implement this properly, I believe a new program, type checker, and language service would need to be created between each AST update. That doesn't sound so performant. Overall, I would highly recommend not using this library in custom transformers.

I'd also recommend not using the program, type checker, or language service in transformation plugins with the vanilla compiler API. For example:

console.log(typeChecker.typeToString(typeChecker.getTypeAtLocation(declaration))); // number
declaration = ts.updateVariableDeclaration(declaration, declaration.name, ts.createTypeReferenceNode("Date", undefined), undefined);
console.log(typeChecker.typeToString(typeChecker.getTypeAtLocation(declaration))); // number
console.log(typeChecker.typeToString(typeChecker.getTypeAtLocation(declaration.type!))); // any

If someone wanted to use this library to make changes for emitting, then a better workflow would be to do the changes prior to entering the compiler API's emit. For example:

  1. Load a ts-morph project.
  2. Manipulate the files in the project. <-- Do everything here
  3. Emit and possibly use other compiler API transforms.

Regarding the language service specifically, a new document registry would need to be created for the emit language service and the source files updated on each transformation. I believe with the current API, that would mean reprinting & reparsing the transformed ASTs between each request to the language server. Anyway, at emit that's just a strange time for the language service to be around. It would be simpler/more performant to do any changes that require the language service before emitting.

Hopefully that makes sense... let me know if you want some clarification on anything.

dsherret avatar Mar 09 '19 23:03 dsherret

@dsherret Thanks for your answer 😉

I've managed to overcome this problem by using the raw ts API to emit new code. I'm just reading the type info and emitting a new decorator, so I don't suffer from the synchronization lost issue.

It's also not so slow using ts-morph with typeChecker and createWrappedNode. Maybe in the future I will rewrite it to the raw ts API when the PoC become a really functional transform plugin.

MichalLytek avatar Mar 10 '19 10:03 MichalLytek

I have the same requirements, I generate a complex AST in my plugin, but it's hard to write it using ts.createXX. I'm fans of ts-morph's insertStatements('console.log()').

shrinktofit avatar May 23 '19 10:05 shrinktofit

Is this still not recommended? I've built a script that does everything I need with ts-morph and now I would like to use it as a transformation. Even if it's not recommended, can someone tell me how to use it anyways?

benjick avatar Jul 04 '21 08:07 benjick

Hello

I found a way to use it in transform plugin easily, don't know if it's bad or bad or good but here it is:

export default function(program: ts.Program, pluginOptions: {}) {
    const project = new Project();
    project.addSourceFilesFromTsConfig("./tsconfig.json");

    // Manipulate the files
    project.getSourceFiles()[0].insertStatements(0, "console.log('Hello world!');");

    return (ctx: ts.TransformationContext) => {
        return (sourceFile: ts.SourceFile) => {
            // Return source files from Project instead
            return project.getSourceFile(sourceFile.fileName)!.compilerNode;
        };
    };
}

Feavy avatar Aug 23 '22 14:08 Feavy

Using the program transformer of ts-patch, this now works:

import type * as ts from 'typescript';
import {CompilerOptions, Project} from "ts-morph";

export default function (program: ts.Program) {
    const compilerOptions = program.getCompilerOptions() as CompilerOptions;
    const project = new Project({compilerOptions});
    program.getSourceFiles()
        .forEach(sourceFile => project.createSourceFile(sourceFile.fileName, sourceFile.text, {overwrite: true}));

    //perform ts-morph magic
    project.createSourceFile('added-by-tsmorph.ts', 'console.log(true);');

    return project.getProgram().compilerObject;
}

The big drawback from this approach is that all existing files in the program have to be recompiled (this is done in the ts-morph.Project.createSourceFile call). This is not very performant, but I am willing to accept this in my project in favor of the much more readable ts-morph code.

@dsherret, do you think it is feasible to add a addSourceFile function to ts-morph.Project, which wraps an existing ts.SourceFile without having to recompile it? I would be happy to help implementing this.

MartinRamm avatar Aug 31 '23 01:08 MartinRamm

Hello

I found a way to use it in transform plugin easily, don't know if it's bad or bad or good but here it is:

export default function(program: ts.Program, pluginOptions: {}) {
    const project = new Project();
    project.addSourceFilesFromTsConfig("./tsconfig.json");

    // Manipulate the files
    project.getSourceFiles()[0].insertStatements(0, "console.log('Hello world!');");

    return (ctx: ts.TransformationContext) => {
        return (sourceFile: ts.SourceFile) => {
            // Return source files from Project instead
            return project.getSourceFile(sourceFile.fileName)!.compilerNode;
        };
    };
}

@Feavy, as far as I can tell you are recompiling all source files from the file system, which ignores changes to the AST already performed by other compiler plugins. You may want to adapt this to use the approach from my answer, which should solve this potential problem.

MartinRamm avatar Aug 31 '23 01:08 MartinRamm

Hello I found a way to use it in transform plugin easily, don't know if it's bad or bad or good but here it is:

export default function(program: ts.Program, pluginOptions: {}) {
    const project = new Project();
    project.addSourceFilesFromTsConfig("./tsconfig.json");

    // Manipulate the files
    project.getSourceFiles()[0].insertStatements(0, "console.log('Hello world!');");

    return (ctx: ts.TransformationContext) => {
        return (sourceFile: ts.SourceFile) => {
            // Return source files from Project instead
            return project.getSourceFile(sourceFile.fileName)!.compilerNode;
        };
    };
}

@Feavy, as far as I can tell you are recompiling all source files from the file system, which ignores changes to the AST already performed by other compiler plugins. You may want to adapt this to use the approach from my answer, which should solve this potential problem.

It type are look like no bad, but when I try to run, it will throw error:

/node_modules/.pnpm/[email protected]/node_modules/typescript/lib/typescript.js:47076
      if (symbol.flags & 33554432 /* Transient */)
                 ^

TypeError: Cannot read properties of undefined (reading 'flags')
    at getSymbolLinks (/node_modules/.pnpm/[email protected]/node_modules/typescript/lib/typescript.js:47076:18)
    at isReferencedAliasDeclaration (/node_modules/.pnpm/[email protected]/node_modules/typescript/lib/typescript.js:84510:24)
    at Object.isReferencedAliasDeclaration (/node_modules/.pnpm/[email protected]/node_modules/typescript/lib/typescript.js:84881:67)
    at shouldEmitAliasDeclaration (/node_modules/.pnpm/[email protected]/node_modules/typescript/lib/typescript.js:92146:164)
    at visitImportSpecifier (/node_modules/.pnpm/[email protected]/node_modules/typescript/lib/typescript.js:91845:34)
    at visitArrayWorker (/node_modules/.pnpm/[email protected]/node_modules/typescript/lib/typescript.js:87231:51)
    at visitNodes2 (/node_modules/.pnpm/[email protected]/node_modules/typescript/lib/typescript.js:87202:21)
    at visitNamedImportBindings (/node_modules/.pnpm/[email protected]/node_modules/typescript/lib/typescript.js:91840:26)
    at visitNode (/node_modules/.pnpm/[email protected]/node_modules/typescript/lib/typescript.js:87169:21)
    at visitImportClause (/node_modules/.pnpm/[email protected]/node_modules/typescript/lib/typescript.js:91826:29) {
  code: 'PLUGIN_ERROR',
  plugin: 'typescript',
  hook: 'buildStart'
}

Don't know how to resolve it, 😭

Groupguanfang avatar Dec 23 '23 17:12 Groupguanfang