Search code examples
typescriptabstract-syntax-treeautomated-refactoringtypescript-compiler-api

Applying a Typescript refactoring programmatically


VS Code has a 'Convert namespace import to named imports' refactoring. As far as I understand, the refactoring is defined in the Typescript codebase itself, so it's not specific to VS Code.

I need to run this refactoring on a source file programmatically within a Jest transformer. Unfortunately, I've been unable to find any documentation regarding running TypeScript refactorings programmatically. Any help appreciated.


Solution

  • TypeScript refactorings are supplied by the language server. VSCode uses the standalone tsserver binary, but you can also use the API directly.

    import ts from 'typescript'
    
    const REFACTOR_NAME = 'Convert import'
    const ACTION_NAME = 'Convert namespace import to named imports'
    
    const compilerOptions: ts.CompilerOptions = {
      target: ts.ScriptTarget.ES2020,
      module: ts.ModuleKind.ES2020
      // ...
    }
    
    const formatOptions: ts.FormatCodeSettings = {
      insertSpaceAfterCommaDelimiter: true,
      insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces: false
      // ...
    }
    
    const preferences: ts.UserPreferences = {
      // This is helpful to find out why the refactor isn't working
      // provideRefactorNotApplicableReason: true
    }
    
    // An example with the 'filesystem' as an object
    const files = {
      'index.ts': `
        // Both should be transformed
        import * as a from './a'
        import * as b from './b'
    
        a.c()
        a.d()
        b.e()
        b.f()
      `,
      'another.ts': `
        // Should be transformed
        import * as a from './a'
        // Should NOT be transformed
        import b from './b'
    
        a.a
      `,
      'unaffected.ts': `
        console.log(42)
      `
    }
    
    // https://github.com/microsoft/TypeScript/wiki/Using-the-Language-Service-API#document-registry
    // It was the only way I could find to get a SourceFile from the language
    // service without having to parse the file again
    const registry = ts.createDocumentRegistry()
    
    // I think the getScriptVersion thing may be useful for incremental compilation,
    // but I'm trying to keep this as simple as possible
    const scriptVersion = '0'
    const service = ts.createLanguageService(
      {
        getCurrentDirectory: () => '/',
        getCompilationSettings: () => compilerOptions,
        getScriptFileNames: () => Object.keys(files),
        getScriptVersion: _file => scriptVersion,
        // https://github.com/microsoft/TypeScript/wiki/Using-the-Language-Service-API#scriptsnapshot
        getScriptSnapshot: file =>
          file in files
            ? ts.ScriptSnapshot.fromString(files[file as keyof typeof files])
            : undefined,
        getDefaultLibFileName: ts.getDefaultLibFilePath
      },
      registry
    )
    
    const transformFile = (fileName: string, text: string): string => {
      // Get the AST of the file
      const sourceFile = registry.acquireDocument(
        fileName,
        compilerOptions,
        ts.ScriptSnapshot.fromString(text),
        scriptVersion
      )
      return (
        sourceFile.statements
          // Get the namespace import declarations
          .filter(
            node =>
              ts.isImportDeclaration(node) &&
              node.importClause?.namedBindings &&
              ts.isNamespaceImport(node.importClause.namedBindings)
          )
          // Get the refactors
          .flatMap(node => {
            // The range of the import declaration
            const range: ts.TextRange = {
              pos: node.getStart(sourceFile),
              end: node.getEnd()
            }
            // If preferences.provideRefactorNotApplicableReason is true,
            // each refactor will have a notApplicableReason property if it
            // isn't applicable (could be useful for debugging)
            const refactors = service.getApplicableRefactors(
              fileName,
              range,
              preferences
            )
            // Make sure the refactor is applicable (otherwise getEditsForRefactor
            // will throw an error)
            return refactors
              .find(({name}) => name === REFACTOR_NAME)
              ?.actions.some(({name}) => name === ACTION_NAME) ?? false
              ? // The actual part where you get the edits for the refactor
                service
                  .getEditsForRefactor(
                    fileName,
                    formatOptions,
                    range,
                    REFACTOR_NAME,
                    ACTION_NAME,
                    preferences
                  )
                  ?.edits.flatMap(({textChanges}) => textChanges) ?? []
              : []
          })
          .sort((a, b) => a.span.start - b.span.start)
          // Apply the edits
          .reduce<[text: string, offset: number]>(
            ([text, offset], {span: {start, length}, newText}) => {
              // start: index (of original text) of text to replace
              // length: length of text to replace
              // newText: new text
              // Because newText.length does not necessarily === length, the second
              // element of the accumulator keeps track of the of offset
              const newStart = start + offset
              return [
                text.slice(0, newStart) + newText + text.slice(newStart + length),
                offset + newText.length - length
              ]
            },
            [text, 0]
          )[0]
      )
    }
    
    const newFiles = Object.fromEntries(
      Object.entries(files).map(([fileName, text]) => [
        fileName,
        transformFile(fileName, text)
      ])
    )
    
    console.log(newFiles)
    /*
    {
      'index.ts': '\n' +
        '    // Both should be transformed\n' +
        "    import {c, d} from './a'\n" +
        "    import {e, f} from './b'\n" +
        '\n' +
        '    c()\n' +
        '    d()\n' +
        '    e()\n' +
        '    f()\n' +
        '  ',
      'another.ts': '\n' +
        '    // Should be transformed\n' +
        "    import {a as a_1} from './a'\n" +
        '    // Should NOT be transformed\n' +
        "    import b from './b'\n" +
        '\n' +
        '    a_1\n' +
        '  ',
      'unaffected.ts': '\n    console.log(42)\n  '
    }
    */
    

    There isn't much documentation on the TypeScript compiler API, unfortunately. The repository wiki seems to be the only official resource.

    In my experience the best way to figure out how to do something with the TS API is to just type ts. and search for an appropriately named function in the autocomplete suggestions, or to look at the source code of TypeScript and/or VSCode.