10

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.

2
  • npmjs.com/package/ts-refactor Commented Oct 22, 2021 at 8:49
  • @RobertoZvjerković that project seems to run some custom refactorings, but I need to run the refactoring already defined at the Typescript repo. Commented Oct 22, 2021 at 9:15

1 Answer 1

12

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.

Sign up to request clarification or add additional context in comments.

8 Comments

Thanks a lot for your extended answer! I'm going to play around with it. Do you have an idea though how it could be used within a Typescript AST transformer? The problem is that I need this refactoring to be run within a ts-jest transformer: kulshekhar.github.io/ts-jest/docs/getting-started/options/… I'm just not sure how to convert the newFiles returned by your code into something that can be returned by a transformer.
In any case, I guess your response does answer my original question, so I'll accept it
BTW, this line of the output index.ts seems to be a bug related to char positioning: "import {e, f} from './b'./b'\n" +
(ie, './b'./b' shouldn't be duplicated)
Just a heads up: thanks to @cherryblossom I was able to create a workaround to a pretty annoying jest-preset-angular bug: github.com/Maximaximum/jest-namespace-imports-transformer
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.