萌え萌えIrenka

運命だったのか魔が差したのか、Irenkaのドキュメントを読んでしまって。惚れた。やばい、これは使わなければ。と、すぐに地豆に適用してしまったw

Irenkaとは何か。

誤解を恐れずに言えば、高機能Checkstyleみたいなものです。ただ、出来ることは何かを見つけて指摘するだけじゃない。コンパイルのタイミングで、コードを書き換えちゃうことだってできるのだ。

とあるコーディング規約(Irenkaでは「掟」と呼ぶのか?w)があって、それに違反した箇所を見つけて指摘するのがCheckstyleCheckstyleは、独自のルールを組む事が出来ない訳ではないのだが、俺はよく知らない。

Irenkaは、掟に背いた部分を「指摘する」のはもちろん、「勝手に直す」こともできる無茶苦茶な奴なんだ。しかも掟は自分で書ける。(というか、標準では提供されていないっぽいので自分で書くしかないw)

コンパイルのタイミングでコンパイラに「介入」を行い、問題点の指摘やコードの修正など、様々な動作が出来、それを自分で書くことができるのがIrenka。

その動作を、Irenkaでは「Hack」と呼ぶ。

今日は萌えに萌えたので、エントリー長いゼ。

Irenkaの導入

細かい説明はドキュメントに譲るとして、ザックリと導入方法を。

  1. 更新サイトからIrenkaをインストール
  2. IrenkaのHackを記述するプロジェクト(A)と、Hack対象のプロジェクト(B)を生成する。
  3. 両者のプロジェクトのプロパティ>Java Compiler>Irenka で Irenka Builderを有効にする。
  4. AにHackを書く(後述)。
  5. BのビルドパスにAを追加する。
  6. Bのプロジェクトのプロパティ>Java Compiler>Irenka で「追加」を選択し、今作ったHackを追加する。

以上でProject BにHackが適用される。

「指摘する」Hackの例

今日初めてドキュメントを読み、いきなりHackを書いてみた。というか誰かが書いたHackをあまり見つける事が出来なかった(そんなにマジメに探してないけどw)ので、自分で書くしか無い訳だがw

まず、書いた中で一番簡単な例。

/**
 * 変数名の命名規則をチェックするIrenka Hack。
 * @author daisuke
 */
public class VariableNamingRuleChecker {
  
  /**
   * 配列に関する命名規則チェック。
   * @when variable.type <= {@link Object[]}
   */
  public void foundArray(CtVariable<?> variable, Messager messager) {
    String name = variable.getSimpleName();
    if (name.endsWith("s") == false) {
      messager.warn(variable, "配列の識別子は、複数形でなければなりません。");
    }
  }
}

このHackを適用すると、配列型の変数名がsで終わっていなければProblemに警告が表示される、という感じ。

クラスがHackと見なされる条件は以下の通り(全て満たすこと)。

  1. ビルドパス上のファイル内で定義されており、同ファイルは拡張子が".java"である
  2. publicであり、かつabstractでないトップレベルのクラスとして宣言されている
  3. 同クラス内で引数を持たないpublicコンストラクタを宣言する
  4. 同クラス内で1つ以上のHack Actionと解釈可能なメソッドを宣言する
Irenka Studio Users' Guide (PDF)

Hack Actionというのは、Hackクラス内のメソッド。メソッドがHackActionと見なされる条件は以下の通り(全て満たすこと)。

  1. Javaドキュメンテーションコメントが直接付与されている
  2. Javaドキュメンテーションコメント内にIrenka Search Queryを記述する"@when"タグブロックを有する
  3. publicであり、かつstaticでないメソッドとして宣言されている
  4. 1つ以上の引数を宣言し、いずれも次の型のうちいずれかを有する
    • org.ashikunep.irenka.dom.CtElement 型、またはそのサブタイプ
    • org.ashikunep.irenka.toolkit.Tool 型のサブタイプ
    • org.ashikunep.irenka.event.CtEvent 型、またはそのサブタイプ
Irenka Studio Users' Guide (PDF)

Irenka Search Queryとは。ザックリ言えば「何を探すか」を記述するためのJava言語仕様に酷似した問い合わせ言語。先ほどの例で言う「variable.type <= {@link Object}」だ。これは「変数の型が、Objectのサブタイプである」という条件を表す。

コンパイル時に、この条件を満たす変数が見つかり次第、このHackActionが呼び出される訳だ。そして、その対象となった変数にまつわる情報が、引数(CtVariable)として渡される。後は中で好きな事を書けばいい。

ここで、もう一つHackActionを見てみる。

  /**
   * {@link Map}に関する命名規則チェック。
   * @when
   */
  public void foundMap(Messager messager, CtVariable<? extends Map<?, ?>> variable) {
    String name = variable.getSimpleName();
    if (name.endsWith("s") == false && name.endsWith("Map") == false) {
      messager.warn(variable, "Map型の識別子は、複数形もしくはMapで終わらなければなりません。");
    }
  }

あー、今度は@whenがからっぽだー。だけど、CtVariableのGenericsにMapのサブクラスを指定している。こうすることにより、Map型の変数を探すことができる。「@when variable.type <= {@link Map}」と同等かな。

さて、次のHackActionは…。やり方がわからんかった部分もあるw

  /**
   * {@link Collection}に関する命名規則チェック。
   * @when
   */
  public void foundCollection(CtVariable<? extends Collection<?>> variable, DeclarationFactory decFactory,
      Messager messager) {
    String name = variable.getSimpleName();
    if (name.endsWith("s")) {
      return;
    }
    if (decFactory.typeOf(List.class).isAssignableFrom(variable.getType()) && name.endsWith("List")) {
      return;
    }
    if (decFactory.typeOf(Set.class).isAssignableFrom(variable.getType()) && name.endsWith("Set")) {
      return;
    }
    messager.warn(variable, "Collection型の識別子は、複数形もしくはList,Setで終わらなければなりません。");
  }

Collection型で探して、Listならsuffix=List, Setならsuffix=Setを許したかったのだが、特定のクラスのCtTypeインスタンスをどうやって手に入れていいか分からなかった。あう。す。

その他、変数名だけではなく、クラス名などにも応用できる。

/**
 * ユーティリティクラスに関するチェックを行うIrenka Hack。
 * @author daisuke
 */
public class UtilClassChecker {
  
  /**
   * Utils拒否。
   * @when klass.simpleName =~ "^.*Utils$" 
   */
  public void foundUtilsClass(CtClass<?> klass, Messager messager) {
    messager.warn(klass, "ユーティリティクラスのsuffixは、UtilsではなくUtilであるべきです。");
  }
}

さらに、名前の警告だけではなく、Utilクラスは「privateの引数なしコンストラクタを一つだけ持つべき」という掟も、以下のように。

  /**
   * 引数無しprivateコンストラクタチェック。
   * @when klass.simpleName =~ "^.*Util$" 
   */
  public void foundUtilClass(CtClass<?> klass, Messager messager) {
    int constructorSize = klass.getDeclaredConstructors().size();
    if (constructorSize > 1) {
      messager.warn(klass, "ユーティリティクラスが複数のコンストラクタを持っています。");
    } else {
      CtConstructor<?> ctConstructor = klass.getDeclaredConstructors().get(0);
      int constructorParameterSize = ctConstructor.getParameters().size();
      if (constructorParameterSize != 0) {
        messager.warn(ctConstructor, "ユーティリティクラスは引数無しのコンストラクタを持つべきです。");
      } else if (ctConstructor.getVisibility() != VisibilityKind.PRIVATE) {
        messager.warn(ctConstructor, "ユーティリティクラスはprivateコンストラクタを持つべきです。");
      }
    }
  }

あと更に、「コンストラクタの中身はカラッポじゃなきゃいけない」という掟も作りたかったのだが、作り方が分からなかった><

「勝手に直す」Hackの例

最後に。「勝手に直す」Hackも書いてみたので紹介する。ちょっとデカくなってしまうので細かい解説はしない。しかも、まだ今日Irenkaを学び始めたばかりなので甘い所も多いと思う。実現したくても方法が分からない事もあり。まだ不完全ですが晒します。詳しい方、どうか色々御指南頂ければ幸いです。

このHackは「accessor(setter/getter)メソッドに、勝手にJavadocを付ける」というものです。accessorには、対応する操作対象フィールドがあるハズで、そのフィールドについているJavadocコメントを取得し、そこからaccessor用のJavadocコメントを生成、それをメソッドに付加する、ということをやってます。

import java.util.List;

import org.ashikunep.irenka.dom.CtClass;
import org.ashikunep.irenka.dom.CtField;
import org.ashikunep.irenka.dom.CtMethod;
import org.ashikunep.irenka.dom.CtType;
import org.ashikunep.irenka.dom.doc.CtDocBlock;
import org.ashikunep.irenka.toolkit.DocFactory;

/**
 * 自動でJavadocコメントを付加するIrenka Hack。
 * @author daisuke
 */
public class AutoJavadocDescriber {
  
  /**
   * getterのJavadocコメントを設定する。
   * @when
   *     method.simpleName =~ "^get[A-Z][a-zA-Z0-9]*$"
   *     void != method.returnType  // 戻り値がvoidではない
   *     () = method.parameters     // 引数がない
   */
  public void foundGetter(CtMethod<?> method, DocFactory docFactory) {
    String fieldDescription = getTargetFieldDescription(method);
    if (fieldDescription == null) {
      return;
    }
    
    setSummaryBlock(method, docFactory, fieldDescription + "を取得する。");
    setBlock(method, docFactory, "return", fieldDescription);
  }
  
  /**
   * setterのJavadocコメントを設定する。
   * @when
   *     method.simpleName =~ "^set[A-Z][a-zA-Z0-9]*$"
   *     void = method.returnType   // 戻り値がvoidである
   * //  1 = method.parameters.size // TODO 引数が1つである、を表現したいが方法不明
   */
  public void foundSetter(CtMethod<?> method, DocFactory docFactory) {
    String fieldDescription = getTargetFieldDescription(method);
    if (fieldDescription == null) {
      return;
    }
    
    setSummaryBlock(method, docFactory, fieldDescription + "を設定する。");
    setBlock(method, docFactory, "param", getFieldName(method) + " " + fieldDescription);
  }
  
  /**
   * 一文字目を小文字に変える。
   * @param str 処理対象文字列
   * @return 処理結果
   */
  private String decapital(String str) {
    final char[] ch = str.toCharArray();
    ch[0] = Character.toLowerCase(ch[0]);
    return new String(ch);
  }
  
  /**
   * accessorメソッドから、その操作対象フィールドの名称を取得する。
   * @param method accessorメソッド
   * @return フィールド名
   */
  private String getFieldName(CtMethod<?> method) {
    return decapital(method.getSimpleName().substring(3));
  }
  
  /**
   * フィールドを示す説明句を取得する。
   * @param method
   * @return フィールドを示す説明句
   */
  private String getTargetFieldDescription(CtMethod<?> method) {
    String fieldName = getFieldName(method);
    CtClass<?> klass = (CtClass<?>) method.getParent();
    CtField<?> field = klass.getField(fieldName);
    if (field == null) {
      return null;
    }
    
    String fieldJavadocString = null;
    List<CtDocBlock> fieldJavadocBlocks = field.getJavadoc().getBlocks();
    if (fieldJavadocBlocks.size() != 0) {
      fieldJavadocString = fieldJavadocBlocks.get(0).getFragments().get(0).toString();
    }
    if (fieldJavadocString == null || fieldJavadocString.length() == 0) {
      fieldJavadocString = "{@link #" + field.getSimpleName() + "}";
    }
    return fieldJavadocString;
  }
  
  /**
   * 特定のタグのブロックをセットする。
   * @param method 
   * @param docFactory 
   * @param targetTag 対象となるタグ
   * @param javadocString 
   */
  private void setBlock(CtMethod<?> method, DocFactory docFactory, String targetTag, String javadocString) {
    CtDocBlock newDocBlock = docFactory.newDocBlock(targetTag);
    newDocBlock.getFragments().add(docFactory.newDocText(javadocString));
    
    List<CtDocBlock> methodJavadocBlocks = method.getJavadoc().getBlocks();
    for (CtDocBlock methodJavadocBlock : methodJavadocBlocks) {
      String tag = methodJavadocBlock.getTag();
      if (("@" + targetTag).equals(tag) && methodJavadocBlock.exists()) {
        // 対象タグ(@returnや@param)が見つかったら、新しいタグに置き換える
//      methodJavadocBlock.substitute(newDocBlock);
        // FIXME methodJavadocBlockをnewDocBlockと置換したいのだが、これじゃダメらしい。
        return;
      }
    }
    // 対象タグがなかったら、単純に追加する。
    methodJavadocBlocks.add(newDocBlock);
  }
  
  /**
   * 概要ブロック(1つ目のブロック)をセットする。
   * @param method 
   * @param docFactory 
   * @param javadocString 
   */
  private void setSummaryBlock(CtMethod<?> method, DocFactory docFactory, String javadocString) {
    CtDocBlock summaryDocBlock = docFactory.newDocBlock(null);
    summaryDocBlock.getFragments().add(docFactory.newDocText(javadocString));
    
    List<CtDocBlock> methodJavadocBlocks = method.getJavadoc().getBlocks();
    if (methodJavadocBlocks.isEmpty() || methodJavadocBlocks.get(0).getTag() != null) {
      methodJavadocBlocks.add(0, summaryDocBlock);
    } else {
      // FIXME 1行目のみを置換したいんだけどな…。改行では区切れないのだろうか。
      methodJavadocBlocks.set(0, summaryDocBlock);
    }
  }
}