Search code examples
typescriptabstract-syntax-tree

How to update or insert to import using typescript compiler api?


I using transform function from typescript compiler api to change my code.

This function is recursive and visit each node.

When I found a StringLiteral of foo I want to add foo to import where my-lib like this:

import { foo } from 'my-lib';

The code can have already import something else from my-lib like:

import { bar } from 'my-lib';

And I want to avoid this result (duplicate imports):

import { foo } from 'my-lib';
import { bar } from 'my-lib';

The closest solution I was able to find is this:

const file = (node as ts.Node) as ts.SourceFile;
const update = ts.updateSourceFileNode(file, [
  ts.createImportDeclaration(
    undefined,
    undefined,
    ts.createImportClause(
      undefined,
      ts.createNamedImports([ts.createImportSpecifier(ts.createIdentifier("default"), ts.createIdentifier("salami"))])
    ),
    ts.createLiteral('salami')
  ),
  ...file.statements
]);

But the functions are deprecates. and I can't return ts.createImportDeclaration becuase I will get the import and not the code of StringLiteral of foo.

Is there a function say "update or insert y from x to the imports statements"?

The code I was able to do so far:

import * as ts from "typescript";

const code = `
console.log('foo');
`;

const node = ts.createSourceFile("x.ts", code, ts.ScriptTarget.Latest);

const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });

export const upsertImport = (context) => (rootNode) => {
  const { factory } = context;

  function visit(node) {
    if (ts.isStringLiteral(node) && node.text === "foo") {
      // need to add: import { foo } from 'my-lib'; but how??
      console.log("need to add: import { foo } from my-lib; but how??");
    }

    return ts.visitEachChild(node, visit, context);
  }

  return ts.visitNode(rootNode, visit);
};

const result = ts.transform(node, [upsertImport]);

const transformedSourceFile = result.transformed[0];

const out = printer.printFile(transformedSourceFile);

console.log({ out });

codesandbox.io


Solution

  • You can use the visitor to walk the AST and store which members have been utilized without editing any nodes. Afterward (while still in the transform), you can find/upsert the ImportDeclaration on the sourceFile with which members have been accessed:

    /**
     * Helper function for updating an import declaration statement with a new set of members
     * unhandled edge cases include:
     * existing `import "my-lib";`
     * existing `import * as myLib from "my-lib";
     */
    const updateNamedImports = (factory: ts.NodeFactory, node: ts.ImportDeclaration, utilizedMembers: Set<string>): ts.ImportDeclaration => {
        // We're not using updateNamedImports since we've verified
        // which imports are actually being used already
        const namedImports = factory.createNamedImports(
            Array.from(utilizedMembers)
                .map(name =>
                    factory.createImportSpecifier(
                        undefined,
                        factory.createIdentifier(name)
                    )
                )
        )
        let importClause: ts.ImportClause;
        if (node.importClause && node.importClause.namedBindings) {
            importClause = factory.updateImportClause(
                node.importClause,
                node.importClause.isTypeOnly,
                node.importClause.name,
                namedImports
            );
        }
        else {
            importClause = factory.createImportClause(
                false,
                undefined,
                namedImports
            )
        }
        return factory.updateImportDeclaration(
            node,
            node.decorators,
            node.modifiers,
            importClause,
            node.moduleSpecifier
        );
    }
    
    /**
     * Main transform function
     */
    const upsertImport: ts.TransformerFactory<ts.SourceFile> = (context) => (rootNode) => {
        const { factory } = context;
    
        const MY_LIB = "my-lib";
    
        // use a set to keep track of members which have been accessed,
        // and guarantee uniqueness
        const utilizedMembers = new Set<string>();
        // I ventured a guess you want to check more than just "foo"
        const availableMembers = new Set([ "foo", "bar", "baz" ]);
    
        // Find which imports you need
        function collectUtilizedMembers(node: ts.Node): ts.Node {
            if (ts.isStringLiteral(node) && availableMembers.has(node.text)) {
                utilizedMembers.add(node.text);
            }
            return ts.visitEachChild(node, collectUtilizedMembers, context);
        }
    
        // run your visitor which will fill up the `utilizedMembers` set.
        ts.visitNode(rootNode, collectUtilizedMembers);
    
        // find the existing import if it exists using findIndex
        // so we can splice it back into the existing statements
        const matchedImportIdx = rootNode.statements
            .findIndex(s => ts.isImportDeclaration(s)
                && (s.moduleSpecifier as ts.StringLiteral).text === MY_LIB
            );
    
        // if it exists, update it
        if (matchedImportIdx !== -1) {
            const node = rootNode.statements[matchedImportIdx] as ts.ImportDeclaration;
            // update source file with updated import statement
            return factory.updateSourceFile(rootNode, [
                ...rootNode.statements.slice(0, matchedImportIdx),
                updateNamedImports(factory, node, utilizedMembers),
                ...rootNode.statements.slice(matchedImportIdx + 1)
            ]);
        }
        else {
            // if it doesn't exist, create it and insert it at
            // the top of the source file
            return factory.updateSourceFile(rootNode, [
                factory.createImportDeclaration(
                    undefined,
                    undefined,
                    factory.createImportClause(
                        false,
                        undefined,
                        factory.createNamedImports(
                            Array.from(utilizedMembers).map(name =>
                                factory.createImportSpecifier(
                                    undefined,
                                    factory.createIdentifier(name)
                                )
                            )
                        )
                    ),
                    factory.createStringLiteral(MY_LIB)
                ),
                ...rootNode.statements
            ]);
        }
    };
    

    Because this code checks which members are actually utilized, it replaces the existing namedImports. If you want to preserve the existing ones (even if they haven't been used or are not included in availableMembers, you can merge the utilizedMembers set with the existing importDeclaration.importClause.namedImports array in updateNamedImports. This also doesn't handle the case of existing statements import * as myLib from "my-lib";/import "my-lib";, but that should be easy to add depending on how you want to handle it.