Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 706
Maksym Avatar asked Apr 25 '26 04:04

Maksym


1 Answers

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.

  • Using the Compiler API
  • Using the Language Service API

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.

like image 191
cherryblossom Avatar answered Apr 28 '26 13:04

cherryblossom