Applying a Typescript refactoring programmatically
Asked Answered
A

1

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.

Alwin answered 22/10, 2021 at 8:30 Comment(2)
npmjs.com/package/ts-refactorExpectancy
@RobertoZvjerković that project seems to run some custom refactorings, but I need to run the refactoring already defined at the Typescript repo.Alwin
A
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.

Adenaadenauer answered 25/10, 2021 at 6:24 Comment(8)
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.Alwin
In any case, I guess your response does answer my original question, so I'll accept itAlwin
BTW, this line of the output index.ts seems to be a bug related to char positioning: "import {e, f} from './b'./b'\n" +Alwin
(ie, './b'./b' shouldn't be duplicated)Alwin
@Alwin Oops, sorry about that! I added a sort before the reduce to sort the reactors by their position, and that fixed that issue. It's probably not the most efficient way to do it though.Adenaadenauer
I had a look at the ts-jest transformer thing and the TsCompilerInstance has an internal property _languageService. I tried using that and then used ts.createSourceFile to create a new source file from the new text, but that didn't work because the new import specifier nodes didn't seem to have any symbols.Adenaadenauer
Maybe it would be easier to copy the code for the refactor so you can directly transform the sourceFile. I'll have a look into if there's a better way to transform a sourceFile with the refactors from the language service.Adenaadenauer
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-transformerAlwin

© 2022 - 2024 — McMap. All rights reserved.