以编程方式应用 Typescript 重构

Applying a Typescript refactoring programmatically

VS Code 进行了 'Convert namespace import to named imports' 重构。据我了解,重构是 defined in the Typescript codebase itself,因此它不特定于 VS Code。

我需要 运行 在 Jest 转换器中以编程方式对源文件进行重构。不幸的是,我一直无法找到任何有关 运行ning TypeScript 编程重构的文档。任何帮助表示赞赏。

TypeScript 重构由语言服务器提供。 VSCode 使用独立的 tsserver 二进制文件,但您也可以直接使用 API。

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  '
}
*/

遗憾的是,关于 TypeScript 编译器的文档不多 API。存储库 wiki 似乎是唯一的官方资源。

根据我的经验,弄清楚如何使用 TS API 做某事的最佳方法是键入 ts. 并在自动完成建议中搜索适当命名的函数,或者查看在 TypeScript 的源代码 and/or VSCode.