LoginSignup
6
1

More than 3 years have passed since last update.

TypeScript Compiler API でビルド前にゴニョゴニョする話

Last updated at Posted at 2019-08-30

TypescriptCompilerAPI

導入

Typescriptを書いている際、ふと
「Typescriptのリフレクション事情ってどうなってるんだろう?」
と思ってしまい、興味本位で色々試してみることに。

やりたいこと

  • 特定の型から派生した型を取得する
  • 型の詳細を取得する
  • publicなpropertyだけを出力

環境

package.json
...
"devDependencies":{
    "linq": "^3.2.0",//(必須じゃない)
    "ts-node": "^8.3.0",
    "typescript": "^3.2.2"
}

型の取得

まずコンパイラそのもの(typescript)と、
.tsファイルをそのままnpm run 出来るようになるモジュール(tsnode)を導入する
あと趣味でLinqを導入する

npm i --save-dev typescript ts-node linq
適当なtypescriptファイルを作り、準備を整える

a.ts
import * as TypeScript from 'typescript'

let scriptAction = () => {

}

scriptAction()

準備が出来たらコード上でコンパイルを行ってみる

const program: TypeScript.Program = TypeScript.createProgram(
{
  options: {}, //?
  rootNames: entryPoints,
})

他にも入力できる値はあるが、必須なのはこの2つのようだ。

rootNameはReadonlyArray<string>ファイルもしくはディレクトリを指定でする。
ディレクトリなら中身を全てコンパイルしてくれる。

optionsはCompilerOptions型だが、手で入力するのは躊躇う物量だったため、
.tsconfigを引っ張れる手段が無いか調べてみる
最終的にParsedCommandLine.optionsを拾えればうまく行きそうな雰囲気を感じたので引っ張ってみる

ParseTSConfig(rootDirectory: string, configName: string = 'tsconfig.json'): TypeScript.ParsedCommandLine {
    const parseConfigHost: TypeScript.ParseConfigHost = {
      fileExists: TypeScript.sys.fileExists,
      readFile: TypeScript.sys.readFile,
      readDirectory: TypeScript.sys.readDirectory,
      useCaseSensitiveFileNames: true,
    }

    const configFileName = TypeScript.findConfigFile(rootDirectory, TypeScript.sys.fileExists, configName)
    const configFile = TypeScript.readConfigFile(configFileName, TypeScript.sys.readFile)
    const compilerOptions = TypeScript.parseJsonConfigFileContent(configFile.config, parseConfigHost, rootDirectory)

    return compilerOptions
  }

こんな感じに

無事にTypescript.Programが生成出来たらいよいよソースファイルに手を付けていく
まずはソースファイルを引っ張るところから

  private QueryTargetSource(): IEnumerable<TypeScript.Node> {
  // .tsconfigが持っているexclude
    const exclude: string[] = this.commandline.raw.exclude
    const result = Enumerator
      .from(this.program.getSourceFiles())
      .where(m => Enumerator.from(exclude).all(path => m.fileName.indexOf(path) === -1)
    )
    return result
  }

.tsconfigが持っているinclude/excludeは考慮されていなかったので
コンパイル後に弾いている(他に指定方法があるか要調査)

今回は指定した型から派生した型を列挙していく(直下の型のみ対応)

  public QuerrySubClasses(typeName: string) {
    const sourceFiles = this.QueryTargetSource()
    const typeNodes = sourceFiles
    // 木を配列に(source => Types[])
      .select(m => this.FlatNode(m))
    // 全ソースの物を纏める(sources[])
      .selectMany(m => m)
    // Classのみ抽出
      .where(m => m.kind === TypeScript.SyntaxKind.ClassDeclaration)
      .select(m => m as TypeScript.ClassLikeDeclarationBase)
    // 基底クラスがある
      .where(m => m.heritageClauses !== undefined)
    // 基底クラスのどれかが目的の型名と一致するか(2層以上遡るならここを再帰にする)
      .where(m =>
        Enumerator.from(m.heritageClauses)
          .selectMany(o => o.types)
          .any(n => n.getText() === typeName)
      )
      .toArray()
    return typeNodes
  }

 // hieralchyは継承ツリーなどを出力する際に使うと良い 不要なら削除してOK
 public FlatNode(node: TypeScript.Node, hieralchy: number = 0): IEnumerable<TypeScript.Node> {
    let nodes: TypeScript.Node[] = []
    nodes.push(node)
    node.forEachChild(m => {
      const childs = this.FlatNode(m, hieralchy + 1).toArray()
      nodes = nodes.concat(childs)
    })
    return Enumerator.from(nodes)
  }

上で列挙した型からpublicなpropertyだけを引っ張る


  GetAllMember(typeNode: TypeScript.ClassLikeDeclarationBase) {
    return Enumerator.from(typeNode.members)
      .where(m => this.IsPublicProperty(m))
      .toArray()
  }
  IsPublicProperty(element: TypeScript.ClassElement) {
    if (element.kind !== TypeScript.SyntaxKind.PropertyDeclaration) return false

    const property = element as TypeScript.PropertyDeclaration

    // no keyword
    if (!element.modifiers || element.modifiers.length === 0) return true

    if (element.modifiers.some(m => m.kind === TypeScript.SyntaxKind.StaticKeyword)) return false
    if (element.modifiers.some(m => m.kind === TypeScript.SyntaxKind.PrivateKeyword)) return false
    if (element.modifiers.some(m => m.kind === TypeScript.SyntaxKind.ProtectedKeyword)) return false
    if (element.modifiers.some(m => m.kind === TypeScript.SyntaxKind.AbstractKeyword)) return false
    if (element.modifiers.some(m => m.kind === TypeScript.SyntaxKind.ReadonlyKeyword)) return false

    return true
  }

動かす

まず動作させるためのスクリプトを作る

interface IClassValuesPair {
  classNode: TypeScript.ClassLikeDeclarationBase
  values: TypeScript.ClassElement[]
}

ToOutput(pair: IClassValuesPair): string {
  return `[${pair.classNode.name.getText()}]${pair.values
    .map(m => `\n  ${m.name.getText()}`)
    .reduce((m, n) => m + n, '')}`
}
class A {
  public static Generate(): void {
    const commandline = ParseTSConfig(process.cwd())
    const program = TypeScript.createProgram({
      options: commandline.options,
      rootNames: ["src/Main.ts"],
    })
    // IPluginOption から派生した型を列挙する
    QuerrySubClasses("IPluginOption").map(m => {
      const pair: IClassValuesPair = {
        classNode: m,
        values: GetAllMember(m),
      }
      const result = ToOutput(pair)
      console.log(result)
    })
  }
}

A.Generate()

先ほど作った.tsファイルをプロジェクト内に配置し、package.jsonに次のような記述を追加する

{
    "scripts": {
        "outputPublicProperty":"npx ts-node src/scripts/ファイル名.ts",
    }
}

これで.tsファイルをnpm run で呼び出せるようになる
npm run outputPublicProperty

[MarkdownOption]
  enable
  version
  activeCss
  useCodeBlockOverride
  languageToCssMap
[EmotionOption]
  enable
  version

出力はこんな感じ

これでコンパイラを利用した情報を程よく引っ張れるようになった
今回はプロジェクトでWebpackを使用していたので、上記のコードをWebpackのプラグインとして動作させることで
ビルドの際に勝手に動作するようにしている。

次回はWebpack.Configについて書く予定

6
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
1