TypeScript Compiler APIでASTからコード生成しFormatter CLIを作る

2025年9月11日(木)
平井 柊太
第4回の今回は、TypeScript Compiler APIでASTからのコード生成とFormatter CLIの実装を解説します。

はじめに

こんにちは。サイバーエージェントのソフトウェアエンジニアの平井柊太(@did0es)です。

前回は、TypeScriptで書かれたソースコードのASTの解析と、これを応用したLinterの開発を行いました。

今回は、ASTからTypeScriptのソースコードを生成します。この生成の仕組みを利用し、前回開発したLinterをFormatterとして使えるようにします。

プロジェクトをセットアップする

前回と同様に、プロジェクトをセットアップします。Node.jsは「v23.10.0」を使います。build-your-own-ast-tools-by-typescriptディレクトリの中に、以下で新たな作業用ディレクトリ(004-generate-codeディレクトリ)を作成します。

mkdir -p build-your-own-ast-tools-by-typescript/004-generate-code
cd build-your-own-ast-tools-by-typescript/004-generate-code

今回は手順を簡単にするため、GitHubリポジトリにある basic-project ディレクトリにあるファイル一式を、作業用ディレクトリにコピーして使用します。以下の構造になります。

004-generate-code
├── console.ts
├── node.config.json
├── package-lock.json
├── package.json
└── tsconfig.json

環境構築の詳細は、前回の「セットアップ」の項を参照ください。

ASTからTypeScriptのソースコードを生成してみる

今まではTypeScriptのソースコードからASTを生成し、内容を解析してきました。今回はその逆で、ASTからソースコードを生成するような仕組みを実装します。

TypeScript Compiler APIを用いてASTからソースコードを生成する

TypeScript Compiler APIには、createPrinterというASTからソースコードの文字列を生成する機能があります。この機能を用いて、簡単なソースコードを生成してみましょう。

作業用ディレクトリにtsPrinterSample.tsファイルを作成し、以下を記述します。

import * as ts from 'typescript';

// ts.factory にある関数を使用して AST を作成します
const f = ts.factory;

// ASTの種類に対応したcreate関数を呼び出します
const ast = f.createVariableStatement(
  undefined,
  f.createVariableDeclarationList(
    [
      f.createVariableDeclaration(
        'greeting',
        undefined,
        f.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
        f.createStringLiteral('Hello, World!')
      ),
    ],
    ts.NodeFlags.Const
  )
);

// ASTをコンソールに出力するためのプリンターを作成します
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
const source = ts.createSourceFile('sample.ts', '', ts.ScriptTarget.Latest);
const code = printer.printNode(ts.EmitHint.Unspecified, ast, source);

console.log(code);

ターミナルを開き、npm run dev -- ./tsPrinterSample.tsを実行します。

$ npm run dev -- ./tsPrinterSample.ts

> basic-project@1.0.0 dev
> node --experimental-default-config-file ./tsPrinterSample.ts

const greeting: string = "Hello, World!";

create関数で構築したASTを元にconst greeting: string = "Hello, World!";が出力されます。

ASTからソースコードを生成するCLIを実装する

前項の応用として、入力されたデータからASTを構築し、ソースコードを出力するCLIを実装します。

作業用ディレクトリの中に__mocks__ディレクトリを作成し、その中にinput.jsonファイルを作成します。このファイルをCLIの入力として使います。

004-generate-code
└── __mocks__
    └── input.json

__mocks__ディレクトリの内容はnpmに公開する際に含めないように、tsconfig.jsonを開いてexcludesフィールドに__mocks__ディレクトリを追記します。

{
  // ~~~
  "exclude": ["node_modules", "dist", "__mocks__"]  // node_modules、出力先のdistフォルダ、__mocks__フォルダは対象外にする
}

__mocks__ディレクトリに追加したinput.jsonファイルを開き、以下を記述します。

{
  "kind": "SourceFile",
  "children": [
    {
      "kind": "FirstStatement",
      "children": [
        {
          "kind": "VariableDeclarationList",
          "children": [
            {
              "kind": "VariableDeclaration",
              "children": [
                {
                  "kind": "Identifier",
                  "text": "greeting",
                  "children": []
                },
                {
                  "kind": "StringKeyword",
                  "text": "string",
                  "children": []
                },
                {
                  "kind": "StringLiteral",
                  "text": "\"Hello, World!\"",
                  "children": []
                }
              ]
            }
          ]
        }
      ]
    },
    {
      "kind": "EndOfFileToken",
      "children": []
    }
  ]
}

このJSONはconst greeting: string = "Hello, World!";のASTの構造をJSON形式で再現したものです。

作業用ディレクトリの中にgenerateCodeCli.tsファイルを作成します。このファイルでは、JSON形式のASTを入力としてTypeScriptのソースコードを出力するCLIを実装します。

作成したgenerateCodeCli.tsファイルを開き、以下を記述します。

#!/usr/bin/env node

// Node.jsのモジュールをインポート
import fs from 'node:fs/promises';
import path from 'node:path';
// カスタムコンソールモジュールをインポート
import { debug, error, info } from './console.ts';
import { createSourceFromAst } from './createSourceFromAst.ts';

console.log('Welcome to the Generate Code CLI!');

const helpMessage =
  'Usage: basic-cli <file-path> [--out <output-path>] [--debug]';

// コマンドの引数を取得(0番目はnodeの実行パス、1番目はスクリプトのパスなので省く)
const args = process.argv.slice(2);
// 引数が不足している場合はヘルプメッセージを表示
if (args.length === 0) {
  info(helpMessage);
  process.exit(1);
}

let [inputFilePath, ...restArgs] = args;
// コマンドの1つ目の引数に、入力ファイルパスが指定されていない場合はエラーにする
if (!inputFilePath || inputFilePath.startsWith('--')) {
  error('Input file path is required');
  info(helpMessage);
  process.exit(1);
}

let outputFilePath = '';
for (let i = 0; i < restArgs.length; i++) {
  const arg = restArgs[i];

  if (arg === '--out') {
    // --outオプションが指定された場合、次の引数を出力ファイルパスとして取得する
    // 次の引数がない、または次の引数がオプション形式(--で始まる)ならエラーにする
    if (i + 1 >= args.length || restArgs[i + 1].startsWith('--')) {
      error('--out requires a file path');
      process.exit(1);
    }
    outputFilePath = restArgs[++i];
  } else if (arg === '--debug') {
    // --debugオプションが指定された場合、デバッグモードを有効にする
    process.env.DEBUG = 'true';
  } else {
    error(`Unknown argument: ${arg}`);
    process.exit(1);
  }
}

// 入力ファイルを読み込む
try {
  const absoluteInputPath = path.resolve(inputFilePath);
  const data = (await fs.readFile(absoluteInputPath)).toString();

  debug(`Input file path: ${absoluteInputPath}`);
  debug(`Input file content:\n${data}`);

  const ast = JSON.parse(data);
  const source = createSourceFromAst(ast);

  if (outputFilePath) {
    // 出力ファイルパスが指定されている場合、内容を逆順にしてファイルに書き込む
    const absoluteOutputPath = path.resolve(outputFilePath);
    await fs.writeFile(absoluteOutputPath, source);
    debug(`Output written to: ${absoluteOutputPath}`);
  } else {
    // 出力ファイルパスが指定されていない場合、コンソールに出力する
    info(`Source:\n${source}`);
  }
} catch (err) {
  if (err instanceof Error) {
    error(err.message);
  } else {
    error('An unknown error occurred');
  }
  process.exit(1);
}

第2回で実装したBasic CLIをベースに受け取ったJSONをcreateSourceFromAst関数に渡して実行し、結果を出力するCLIになっています。

次に、createSourceFromAst関数を実装します。作業用ディレクトリの中にcreateSourceFromAst.tsファイルを作成し、以下を記述します。

import * as ts from 'typescript';
import { debug } from './console.ts';

export type AstNode = {
  kind: string;
  text: string;
  children: AstNode[];
};

const f = ts.factory;

export function createSourceFromAst(astNode: AstNode): string {
  const tsNode = buildTsNodeFromAst(astNode);

  if (tsNode === null) {
    return '';
  }

  const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
  if (ts.isSourceFile(tsNode)) {
    return printer.printFile(tsNode);
  }
  const source = ts.createSourceFile(
    'generated.ts',
    '',
    ts.ScriptTarget.Latest
  );
  return printer.printNode(ts.EmitHint.Unspecified, tsNode, source);
}

function buildTsNodeFromAst(astNode: AstNode, depth = 0): ts.Node | null {
  const indent = '  '.repeat(depth);
  debug(`${indent}Kind: ${astNode.kind}, Text: ${astNode.text}`);

  switch (astNode.kind) {
    // SourceFileノードを生成します
    case 'SourceFile': {
      const statements: ts.Statement[] = [];

      for (const child of astNode.children) {
        if (child.kind === 'FirstStatement') {
          // FirstStatement が複数ある場合、すべての子ノードを処理します
          const inner = child.children
            .map((c) => buildTsNodeFromAst(c, depth + 2))
            .filter((n): n is ts.Statement => n !== null);
          statements.push(...inner);
        } else {
          // それ以外の子ノードは個別に処理します
          const node = buildTsNodeFromAst(child, depth + 1);
          if (node && ts.isStatement(node)) {
            statements.push(node);
          }
        }
      }

      return f.createSourceFile(
        statements,
        f.createToken(ts.SyntaxKind.EndOfFileToken),
        ts.NodeFlags.None
      );
    }

    // 変数宣言のリストからVariableStatementノードを生成します
    case 'VariableDeclarationList': {
      const letHead = /^(\s*)let\b/;
      const useLet = letHead.test(astNode.text);
      const varDeclaration = astNode.children
        .map((child) => buildTsNodeFromAst(child, depth + 1))
        .filter((node): node is ts.VariableDeclaration => node !== null);
      const varDeclarationList = f.createVariableDeclarationList(
        varDeclaration,
        useLet ? ts.NodeFlags.Let : ts.NodeFlags.Const
      );
      return f.createVariableStatement(undefined, varDeclarationList);
    }

    // 変数宣言の場合、VariableDeclarationノードを生成します
    case 'VariableDeclaration': {
      const [identifierNode, typeNode, initializerNode] = astNode.children;
      return f.createVariableDeclaration(
        identifierNode.text,
        undefined,
        toTsTypeNode(typeNode),
        toTsPrimitiveNode(initializerNode)
      );
    }

    // 識別子の場合、Identifierノードを生成します
    case 'Identifier':
      return f.createIdentifier(astNode.text);

    // 型の場合、TypeNodeノードを生成します
    case 'StringKeyword':
      return toTsTypeNode(astNode);

    //Primitiveな値の場合、Literalノードを生成します
    case 'StringLiteral':
      return toTsPrimitiveNode(astNode);

    // ファイルの末尾の改行またはサポートされていないノードの場合、nullを返します
    case 'EndOfFileToken':
    default:
      return null;
  }
}

function toTsTypeNode(node: AstNode): ts.TypeNode {
  switch (node.kind) {
    case 'StringKeyword':
      return f.createKeywordTypeNode(ts.SyntaxKind.StringKeyword);
    default:
      throw new Error(`Unsupported type: ${node.kind}`);
  }
}

function toTsPrimitiveNode(node: AstNode): ts.Expression {
  switch (node.kind) {
    case 'StringLiteral':
      const isSingleQuote = node.text.startsWith("'") && node.text.endsWith("'");
      return f.createStringLiteral(node.text.slice(1, -1), isSingleQuote);
    default:
      throw new Error(`Unsupported literal: ${node.kind}`);
  }
}

このファイルではinput.jsonに存在するASTのkindごとに、対応するTypeScriptのノードを生成するbuildTsNodeFromAst関数を実装しています。また、これを呼び出してソースコードを生成する関数として、createSourceFromAst関数を実装しています。

では、generateCodeCli.tsファイルを実行してみましょう。ターミナルを開き、以下のコマンドを実行します。

$ npm run dev -- ./generateCodeCli.ts ./__mocks__/input.json

> basic-project@1.0.0 dev
> node --experimental-default-config-file ./generateCodeCli.ts ./__mocks__/input.json

Welcome to the Generate Code CLI!
[INFO] Source:
const greeting: string = "Hello, World!";

JSON形式のASTを入力として、ソースコードを出力するCLIが完成しました。

ソースコードをFormatするCLIを実装する

前回実装したLinter CLIと、今回実装したASTからソースコードを生成するCLIを応用して、Formatter CLIを実装しましょう。Formatter CLIでは、以下のフローで処理を行います。

入力されたソースコードをASTに変換し、Linterと同様に検査します。ルールに違反している箇所のうち自動修正が可能なものは修正し、ASTからソースコードに変換します。

以上の処理を実装に落とし込んでみましょう。作業用ディレクトリの中にconfig.jsonファイル、formatterCli.tsファイル、createAst.ts ファイルを作成して以下を記述します。前回のLinter CLIの実装を一部改変しています。

config.jsonファイル:
{
  "use-let-never-reassigned": "error",
  "double-quotes": "error"
}
formatterCli.tsファイル:
#!/usr/bin/env node

// Node.jsのモジュールをインポートします
import fs from 'node:fs/promises';
import path from 'node:path';
// カスタムコンソールモジュールをインポートします
import { debug, error, info } from './console.ts';
import { createAst } from './createAst.ts';
import { createSourceFromAst } from './createSourceFromAst.ts';
import { fixAst } from './fixAst.ts';

// configの型を定義します
export type Config = {
  'use-let-never-reassigned': 'error' | 'warn' | 'off';
  'double-quotes': 'error' | 'warn' | 'off';
};

console.log('Welcome to the Formatter CLI!');

const helpMessage =
  'Usage: formatter-cli <file-path> [--out <output-path>] [--debug]';

// コマンドの引数を取得(0番目はnodeの実行パス、1番目はスクリプトのパスなので省きます)
const args = process.argv.slice(2);
// 引数が不足している場合はヘルプメッセージを表示します
if (args.length === 0) {
  info(helpMessage);
  process.exit(1);
}

let [inputFilePath, ...restArgs] = args;
// コマンドの1つ目の引数に、入力ファイルパスが指定されていない場合はエラーにします
if (!inputFilePath || inputFilePath.startsWith('--')) {
  error('Input file path is required');
  info(helpMessage);
  process.exit(1);
}

let outputFilePath = '';
for (let i = 0; i < restArgs.length; i++) {
  const arg = restArgs[i];

  if (arg === '--out') {
    // --outオプションが指定された場合、次の引数を出力ファイルパスとして取得します
    // 次の引数がない、または次の引数がオプション形式(--で始まる)ならエラーにします
    if (i + 1 >= args.length || restArgs[i + 1].startsWith('--')) {
      error('--out requires a file path');
      process.exit(1);
    }
    outputFilePath = restArgs[++i];
  } else if (arg === '--debug') {
    // --debugオプションが指定された場合、デバッグモードを有効にします
    process.env.DEBUG = 'true';
  } else {
    error(`Unknown argument: ${arg}`);
    process.exit(1);
  }
}

// コマンドが実行されたディレクトリにconfigファイルがない場合はエラーにします
const configFilePath = path.resolve('config.json');
const isConfigFileExists = await fs
  .access(configFilePath)
  .then(() => true)
  .catch(() => false);
if (!isConfigFileExists) {
  error('Config file not found: config.json');
  process.exit(1);
}

// 入力ファイルを読み込む
try {
  const absoluteInputPath = path.resolve(inputFilePath);
  const data = (await fs.readFile(absoluteInputPath)).toString();

  debug(`Input file path: ${absoluteInputPath}`);
  debug(`Input file content:\n${data}`);

  // configファイルを読み込みます
  const config: Config = JSON.parse(
    (await fs.readFile(configFilePath)).toString()
  );
  debug(`Config file content:\n${JSON.stringify(config, null, 2)}`);

  const ast = await createAst(absoluteInputPath);

  // AST を走査してルールを元に修正します
  const fixedAst = fixAst(ast, 0, config, ast);

  // AST をソースコードに変換し、ファイルに書き込みます
  const source = createSourceFromAst(fixedAst);
  if (outputFilePath) {
    const absoluteOutputPath = path.resolve(outputFilePath);
    await fs.writeFile(absoluteOutputPath, source);
    debug(`Output written to: ${absoluteOutputPath}`);
  } else {
    // 出力ファイルパスが指定されていない場合、コンソールに出力します
    info(`Formatted source:\n${source}`);
  }
} catch (err) {
  if (err instanceof Error) {
    error(err.message);
  } else {
    error('An unknown error occurred');
  }
  process.exit(1);
}
createAst.ts ファイル:
import { readFile } from 'fs/promises';

import ts from 'typescript';

export type AstNode = {
  kind: string;
  text: string;
  children: AstNode[];
};

function toAstNode(node: ts.Node) {
  const result: AstNode = {
    kind: ts.SyntaxKind[node.kind],
    text: node.getText(),
    children: [],
  };
  ts.forEachChild(node, (child) => {
    result.children.push(toAstNode(child));
  });
  return result;
}

export async function createAst(fileName: string) {
  const sourceCode = await readFile(fileName, 'utf8');
  const sourceFile = ts.createSourceFile(
    fileName,
    sourceCode,
    ts.ScriptTarget.Latest,
    true
  );

  return toAstNode(sourceFile);
}

作業用ディレクトリ内の__mocks__フォルダにinvalidInput.tsファイル、validInput1.tsファイル、validInput2.tsファイルを追加し、それぞれ以下を記述します。

invalidInput.tsファイル:
let message: string = "This method has fixable errors.";
console.log(message);
validInput1.tsファイル:
const message: string = 'This method has fixable errors.';
console.log(message);
validInput2.tsファイル:
let message: string = 'This method has fixable errors.';
message = 'Reassigned';
console.log(message);

前項で作成したcreateSourceFromAst.tsファイルを開き、以下のように改変します。この改変で、先ほど追加した__mocks__以下の3つの入力向けTypeScriptファイルのASTをソースコードに変換できます。

import * as ts from 'typescript';
import { debug } from './console.ts';

export type AstNode = {
  kind: string;
  text: string;
  children: AstNode[];
};

const f = ts.factory;

export function createSourceFromAst(astNode: AstNode): string {
  const tsNode = buildTsNodeFromAst(astNode);

  if (tsNode === null) {
    return '';
  }

  const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
  if (ts.isSourceFile(tsNode)) {
    return printer.printFile(tsNode);
  }
  const source = ts.createSourceFile(
    'generated.ts',
    '',
    ts.ScriptTarget.Latest
  );
  return printer.printNode(ts.EmitHint.Unspecified, tsNode, source);
}

function buildTsNodeFromAst(astNode: AstNode, depth = 0): ts.Node | null {
  const indent = '  '.repeat(depth);
  debug(`${indent}Kind: ${astNode.kind}, Text: ${astNode.text}`);

  switch (astNode.kind) {
    // SourceFileノードを生成します
    case 'SourceFile': {
      const statements: ts.Statement[] = [];

      for (const child of astNode.children) {
        if (child.kind === 'FirstStatement') {
          // FirstStatement が複数ある場合、すべての子ノードを処理します
          const inner = child.children
            .map((c) => buildTsNodeFromAst(c, depth + 2))
            .filter((n): n is ts.Statement => n !== null);
          statements.push(...inner);
        } else {
          // それ以外の子ノードは個別に処理します
          const node = buildTsNodeFromAst(child, depth + 1);
          if (node && ts.isStatement(node)) {
            statements.push(node);
          }
        }
      }

      return f.createSourceFile(
        statements,
        f.createToken(ts.SyntaxKind.EndOfFileToken),
        ts.NodeFlags.None
      );
    }

    // 変数宣言のリストからVariableStatementノードを生成します
    case 'VariableDeclarationList': {
      const letHead = /^(\s*)let\b/;
      const useLet = letHead.test(astNode.text);
      const varDeclaration = astNode.children
        .map((child) => buildTsNodeFromAst(child, depth + 1))
        .filter((node): node is ts.VariableDeclaration => node !== null);
      const varDeclarationList = f.createVariableDeclarationList(
        varDeclaration,
        useLet ? ts.NodeFlags.Let : ts.NodeFlags.Const
      );
      return f.createVariableStatement(undefined, varDeclarationList);
    }

    // 変数宣言の場合、VariableDeclarationノードを生成します
    case 'VariableDeclaration': {
      const [identifierNode, typeNode, initializerNode] = astNode.children;
      return f.createVariableDeclaration(
        identifierNode.text,
        undefined,
        toTsTypeNode(typeNode),
        toTsPrimitiveNode(initializerNode)
      );
    }

    // 識別子の場合、Identifierノードを生成します
    case 'Identifier':
      return f.createIdentifier(astNode.text);

    // 型の場合、TypeNodeノードを生成します
    case 'StringKeyword':
      return toTsTypeNode(astNode);

    //Primitiveな値の場合、Literalノードを生成します
    case 'StringLiteral':
      return toTsPrimitiveNode(astNode);

    // 式文の場合、ExpressionStatementノードを生成します
    case 'ExpressionStatement': {
      const expr = buildTsNodeFromAst(astNode.children[0], depth + 1);
      return expr ? f.createExpressionStatement(expr as ts.Expression) : null;
    }

    // 代入式の場合、BinaryExpressionノードを生成します
    case 'BinaryExpression': {
      const [leftNode, _, rightNode] = astNode.children;
      const left = buildTsNodeFromAst(leftNode, depth + 1) as ts.Expression;
      const right = buildTsNodeFromAst(rightNode, depth + 1) as ts.Expression;
      return f.createBinaryExpression(
        left,
        f.createToken(ts.SyntaxKind.EqualsToken),
        right
      );
    }

    // 関数呼び出しの場合、CallExpressionノードを生成します
    case 'CallExpression': {
      const [calleeNode, argNode] = astNode.children;
      const callee = buildTsNodeFromAst(calleeNode, depth + 1) as ts.Expression;
      const arg = buildTsNodeFromAst(argNode, depth + 1) as ts.Expression;
      return f.createCallExpression(callee, undefined, [arg]);
    }

    // プロパティへのアクセスの場合、PropertyAccessExpressionノードを生成します
    case 'PropertyAccessExpression': {
      const [objNode, propNode] = astNode.children;
      const obj = buildTsNodeFromAst(objNode, depth + 1) as ts.Expression;
      const prop = buildTsNodeFromAst(propNode, depth + 1) as ts.Identifier;
      return f.createPropertyAccessExpression(obj, prop);
    }

    // BinaryExpressionで生成するため、FirstAssignmentは無視します
    case 'FirstAssignment':
    // ファイルの末尾の改行またはサポートされていないノードの場合、nullを返します
    case 'EndOfFileToken':
    default:
      return null;
  }
}

function toTsTypeNode(node: AstNode): ts.TypeNode {
  switch (node.kind) {
    case 'StringKeyword':
      return f.createKeywordTypeNode(ts.SyntaxKind.StringKeyword);
    default:
      throw new Error(`Unsupported type: ${node.kind}`);
  }
}

function toTsPrimitiveNode(node: AstNode): ts.Expression {
  switch (node.kind) {
    case 'StringLiteral':
      const isSingleQuote =
        node.text.startsWith("'") && node.text.endsWith("'");
      return f.createStringLiteral(node.text.slice(1, -1), isSingleQuote);
    default:
      throw new Error(`Unsupported literal: ${node.kind}`);
  }
}

作業用ディレクトリにfixAst.tsファイルを作成し、以下を記述します。Formatter CLIのコアの実装です。

import type { AstNode } from './createAst.ts';
import type { Config } from './formatterCli.ts';
import { debug, info } from './console.ts';

export function fixAst(astNode: AstNode, depth = 0, config: Config, root: AstNode): AstNode {
  const indent = '  '.repeat(depth);
  debug(
    `${indent}Kind: ${astNode.kind}, Text: ${astNode.text.replaceAll('\n', ' ')}`
  );

  // configのuse-let-never-reassignedが有効な場合、ルールを適用してASTを修正します
  if (config['use-let-never-reassigned'] !== 'off') {
    const letHead = /^(\s*)let\b/;

    if (
      astNode.kind === 'VariableDeclarationList' &&
      letHead.test(astNode.text)
    ) {
      // 宣言に含まれる識別子名を抽出します
      const identifiers = astNode.children
        .filter((c) => c.kind === 'VariableDeclaration')
        .map(
          (v) =>
            v.children.find((n) => n.kind === 'Identifier')?.text || '__dummy__'
        );

      // 後続で再代入が無ければ const に置換します
      if (isNeverReassigned(root, astNode, identifiers)) {
        astNode.text = astNode.text.replace(letHead, '$1const');
        info('[use-let-never-reassigned] letをconstに置換しました');
      }
    }
  }

  // configのdouble-quotesが有効な場合、ルールを適用してASTを修正します
  if (config['double-quotes'] !== 'off') {
    if (astNode.kind === 'StringLiteral') {
      const text = astNode.text.trim();
      if (text.startsWith('"') && text.endsWith('"')) {
        // 本体を取り出し、シングルクォートをエスケープします
        const inner = text.slice(1, -1).replace(/'/g, "\\'");
        astNode.text = `'${inner}'`;

        info('[double-quotes] ダブルクオートをシングルクオートに修正しました');
      }
    }
  }

  for (const child of astNode.children) {
    // 子ノードを再帰的に修正します
    fixAst(child, depth + 1, config, root);
  }

  return astNode;
}

/**
 * let → const に変換して良いか判定するユーティリティ関数です
 */
function isNeverReassigned(
  root: AstNode,
  declNode: AstNode,
  identifiers: string[]
): boolean {
  /** 変数宣言ノード自身は検索対象から除外したいので引数で受け取り比較します */
  const walk = (node: AstNode): boolean => {
    if (node === declNode) return false; // 宣言部分は無視します

    // `Identifier = ...` という BinaryExpression があれば再代入とみなします
    if (
      node.kind === 'BinaryExpression' &&
      node.children.length >= 1 &&
      node.children[0].kind === 'Identifier' &&
      identifiers.includes(node.children[0].text)
    ) {
      return true;
    }

    return node.children.some(walk);
  };

  return !walk(root);
}

ターミナルを開き、__mocks__ディレクトリのinvalidInput.tsファイルがFormatされるか確かめましょう。

$ npm run dev -- ./formatterCli.ts ./__mocks__/invalidInput.ts

> basic-project@1.0.0 dev
> node --experimental-default-config-file ./formatterCli.ts ./__mocks__/invalidInput.ts

Welcome to the Formatter CLI!
[INFO] [use-let-never-reassigned] letをconstに置換しました
[INFO] [double-quotes] ダブルクオートをシングルクオートに修正しました
[INFO] Formatted source:
const message: string = 'This method has fixable errors.';
console.log(message);

ダブルクオートがシングルクオートに、再代入されていないletによる変数宣言がconstに書き換わっています。

では、引き続きターミナルで__mocks__ディレクトリのvalidInput1.tsファイルとvalidInput2.tsファイルがFormatされないか確かめましょう。

validInput1.tsファイルを入力した結果:
$ npm run dev -- ./formatterCli.ts ./__mocks__/validInput1.ts

> basic-project@1.0.0 dev
> node --experimental-default-config-file ./formatterCli.ts ./__mocks__/validInput1.ts

Welcome to the Formatter CLI!
[INFO] Formatted source:
const message: string = 'This method has fixable errors.';
console.log(message);
validInput2.tsファイルを入力した結果:
$ npm run dev -- ./formatterCli.ts ./__mocks__/validInput2.ts

> basic-project@1.0.0 dev
> node --experimental-default-config-file ./formatterCli.ts ./__mocks__/validInput2.ts

Welcome to the Formatter CLI!
[INFO] Formatted source:
let message: string = 'This method has fixable errors.';
message = 'Reassigned';
console.log(message);

どちらの入力ファイルもルールに違反していないので、Formatは行われません。

これでFormatter CLIが完成しました。CLIを公開する場合は、第2回のnpmへの公開手順を参照ください。

以上のコードはGitHubのリポジトリに掲載しています。併せてご覧ください。

次回は、最終回として今まで実装してきた内容の振り返りと、現場で広く用いられているLinterやFormatterのソースコードの一部をリーディングし、どういった処理になっているのかを概観します。

株式会社サイバーエージェント グループIT推進本部 CIU
2022年新卒入社。株式会社CAMでOSS開発やエンタメ関連のWebサービス開発に携わる。2023年CIUにJoin。プライベートクラウドのWeb UIの開発やWeb Speed Hachathon 2024の作問、CyberAgent Developer Conferenceの開発のリードを担当。Muddy WebやWeb Developer Conferenceなどで登壇。2024年サイバーエージェントのTypeScriptのNext Expertsに就任。
現在は、FrontEnd Conference Tokyoの実行委員長やECMAScriptコミュニティのMeguro.esの主催、TypeScript関連のOSSコントリビュートに従事している。

連載バックナンバー

開発言語技術解説
第4回

TypeScript Compiler APIでASTからコード生成しFormatter CLIを作る

2025/9/11
第4回の今回は、TypeScript Compiler APIでASTからのコード生成とFormatter CLIの実装を解説します。
開発言語技術解説
第3回

ASTの解析とCLIの実装によるTypeScript製Lintツール開発

2025/7/30
第3回の今回は、抽象構文木(AST)の解析を通じて、Lintルールを実装するCLIツールの開発手順について解説します。
開発言語技術解説
第2回

TypeScript×Node.jsでCLIを開発してnpmで公開する基本手順を学ぶ

2025/6/27
第2回の今回は、TypeScript×Node.jsでCLIを開発し、npmで公開するまでの手順について解説します。

Think ITメルマガ会員登録受付中

Think ITでは、技術情報が詰まったメールマガジン「Think IT Weekly」の配信サービスを提供しています。メルマガ会員登録を済ませれば、メルマガだけでなく、さまざまな限定特典を入手できるようになります。

Think ITメルマガ会員のサービス内容を見る

他にもこの記事が読まれています