AST変換の無駄使い

G* Advent Calendar 2012の8日目です。

[twitter:@uehaj]さんとジャンルが被ってしまいましたが、AST変換ネタです。

AST変換とは?

「AST変換ってナニ?」ですが、AST変換を参照してください。簡単に言ってしまえば、ソースから作られた抽象構文木バイトコードに変換する前に都合がいいように弄ってしまおう、ということになります。

今回のお題

Groovyを使っていると、printlnを使って各種情報を出力することがあり、それはそれで有意義なことではあります。
普通に出力するだけではつまらないので、何かできないかと考えてみました。
そう言えば、Macではsayというコマンドがあり、色々と喋らせることができます。LionやMountain Lionでは、Kyokoを入れることで、日本語を喋らせることができます。
これらをうまく組み合わせられないかと考え、AST変換を使って、printlnしてる内容をsayコマンドで喋らせてしまえばいいんだ、と思い立ちました。

と、いうことで、今回のお題は、printlnしてる箇所をAST変換してsayコマンドで喋らせるようにするアノテーションを作る、というものです。

Sayableアノテーション

今回作成するのは、Sayableアノテーションという名前のアノテーションです。このアノテーションをパッケージ、クラス、メソッド、コンストラクタに付けることで、printlnメソッドの引数で指定された内容をsayコマンドで喋らせることができます。その代わり、標準出力には出力しません。
Sayableアノテーションを付ける位置で喋らせる範囲を変えることできます。
変換する対象は、printlnメソッドだけでなく、System.out.printlnメソッド、System.err.printlnメソッドも対象となります。
Sayableアノテーションはフルアノテーションであり、sayコマンドで指定できるオプションを全てではないですが指定することができます。

Sayableアノテーションの例

Sayableアノテーションの例です。

ファイル:Example01.groovy

def say(message) {
    println message
}

say 'Hello, World!'

これを実行すると、

$ groovy Test.groovy
Hello, World!
$

とprintlnメソッドの引数の内容が標準出力に出力されますが、Sayableアノテーションを付け

ファイル:Example02.groovy

import org.jggug.dojo.sayable.transform.Sayable

@Sayable
def say(message) {
    println message
}

say 'Hello, World!'

て実行すると、

$ groovy Test.groovy
$

と、標準出力には出力されず、デフォルトの声で'Hello, World!'としゃべります。
声を指定したい場合は、voice変数で指定することができます。Example03.groovyでは、Kyokoで喋らせています。

ファイル:Example03.groovy

import org.jggug.dojo.sayable.transform.Sayable

@Sayable(voice = 'Kyoko')
def say(message) {
    println message
}

say 'Hello, World!'

Sayableアノテーションのコード

Sayableアノテーションのコードです。まずは、アノテーションの定義です。

ファイル:Sayable.java

package org.jggug.dojo.sayable.transform;

import org.codehaus.groovy.transform.GroovyASTTransformationClass;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@java.lang.annotation.Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.PACKAGE, ElementType.TYPE})
@GroovyASTTransformationClass("org.jggug.dojo.sayable.transform.SayableASTTransformation")
public @interface Sayable {
//    String inputFile() default "";
//    boolean progress() default false;
    String voice() default "";
    int rate() default -1;
    String outputFile() default "";
    String networkSend() default "";
    String audioDevice() default "";
    String fileFormat() default "";
    String dataFormat() default "";
    int channels() default -1;
    int bitRate() default -1;
    int quality() default -1;
}

次がAST変換している部分です。

ファイル:SayableASTTransformation.java

package org.jggug.dojo.sayable.transform;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

import org.codehaus.groovy.GroovyBugError;
import org.codehaus.groovy.ast.ASTNode;
import org.codehaus.groovy.ast.AnnotatedNode;
import org.codehaus.groovy.ast.AnnotationNode;
import org.codehaus.groovy.ast.ClassCodeExpressionTransformer;
import org.codehaus.groovy.ast.ClassHelper;
import org.codehaus.groovy.ast.ClassNode;
import org.codehaus.groovy.ast.MethodNode;
import org.codehaus.groovy.ast.PackageNode;
import org.codehaus.groovy.ast.expr.ArgumentListExpression;
import org.codehaus.groovy.ast.expr.BinaryExpression;
import org.codehaus.groovy.ast.expr.ClassExpression;
import org.codehaus.groovy.ast.expr.ConstantExpression;
import org.codehaus.groovy.ast.expr.Expression;
import org.codehaus.groovy.ast.expr.MethodCallExpression;
import org.codehaus.groovy.ast.expr.PropertyExpression;
import org.codehaus.groovy.ast.expr.VariableExpression;
import org.codehaus.groovy.ast.stmt.BlockStatement;
import org.codehaus.groovy.ast.stmt.ExpressionStatement;
import org.codehaus.groovy.ast.stmt.Statement;
import org.codehaus.groovy.control.CompilePhase;
import org.codehaus.groovy.control.SourceUnit;
import org.codehaus.groovy.syntax.Token;
import org.codehaus.groovy.syntax.Types;
import org.codehaus.groovy.transform.ASTTransformation;
import org.codehaus.groovy.transform.GroovyASTTransformation;
import org.objectweb.asm.Opcodes;

@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION)
public class SayableASTTransformation extends ClassCodeExpressionTransformer implements ASTTransformation, Opcodes {

    static final Class MY_CLASS = Sayable.class;
    static final ClassNode MY_TYPE = ClassHelper.make(MY_CLASS);
    static final String MY_TYPE_NAME = "@" + MY_TYPE.getNameWithoutPackage();

    private SourceUnit sourceUnit;

    @Override
    public SourceUnit getSourceUnit() {
        return sourceUnit;
    }

    private AnnotationNode annotationNode;

    @Override
    public void visit(ASTNode[] nodes, SourceUnit source) {
        sourceUnit = source;

        if (nodes.length != 2 || !(nodes[0] instanceof AnnotationNode) || !(nodes[1] instanceof AnnotatedNode)) {
            throw new GroovyBugError("Internal error: expecting [AnnotationNode, AnnotatedNode] but got: " + Arrays.asList(nodes));
        }

        AnnotatedNode parent = (AnnotatedNode)nodes[1];
        AnnotationNode anno = (AnnotationNode)nodes[0];
        if (!MY_TYPE.equals(anno.getClassNode())) {
        	return;
        }
        annotationNode = anno;
        if (parent instanceof PackageNode) {
            visitPackage((PackageNode)parent);
        } else if (parent instanceof ClassNode) {
            visitClass((ClassNode)parent);
        } else if (parent instanceof MethodNode) {
            visitMethod((MethodNode)parent);
        }
    }

    @Override
    public Expression transform(Expression exp) {
        if (exp == null) {
        	return null;
        }
        if (exp instanceof MethodCallExpression) {
            MethodCallExpression methodCallExpression = (MethodCallExpression)exp;
            Expression objectExpression = methodCallExpression.getObjectExpression();

            if (objectExpression instanceof VariableExpression) {
            	VariableExpression variableExpression = (VariableExpression)objectExpression;
            	if ("this".equals(variableExpression.getName())) {
                    ConstantExpression methodConstant = (ConstantExpression)methodCallExpression.getMethod();
            		if ("println".equals(methodConstant.getText())) {
                        return createExcuteExpression(methodCallExpression);
            		}
            	}
            } else if (objectExpression instanceof PropertyExpression) {
                PropertyExpression propertyExpression = (PropertyExpression)objectExpression;
                ClassExpression object = (ClassExpression) propertyExpression.getObjectExpression();
                ConstantExpression property = (ConstantExpression)propertyExpression.getProperty();
                if ("java.lang.System".equals(object.getText())) {
                    if ("out".equals(property.getText()) || "err".equals(property.getText())) {
                        return createExcuteExpression(methodCallExpression);
                    }
                }
            }

            Expression object = transform(methodCallExpression.getObjectExpression());
            Expression method = transform(methodCallExpression.getMethod());
            Expression args = transform(methodCallExpression.getArguments());
            return new MethodCallExpression(object, method, args);
        } else if (exp instanceof ArgumentListExpression) {
        	ArgumentListExpression argumentListExpression = (ArgumentListExpression)exp;
            List<Expression> argumentList = new ArrayList<Expression>();
        	for (Expression argumentExpression : argumentListExpression.getExpressions()) {
        		argumentList.add(transform(argumentExpression));
        	}
        	return new ArgumentListExpression(argumentList);
        }
        return exp.transformExpression(this);
    }

    protected Expression createExcuteExpression(MethodCallExpression methodCallExpression) {
        Expression objectExpression = methodCallExpression.getObjectExpression();
        ConstantExpression methodConstant = (ConstantExpression)methodCallExpression.getMethod();

        StringBuilder sb = new StringBuilder("say ");
        setStringOption(sb, "voice", "--voice");
        setIntegerOption(sb, "rate", "--rate");
        setStringOption(sb, "outputFile", "--output-file");
        setStringOption(sb, "networkSend", "--network-send");
        setStringOption(sb, "audioDevice", "--audio-device");
        setStringOption(sb, "fileFormat", "--file-format");
        setStringOption(sb, "dataFormat", "--data-format");
        setIntegerOption(sb, "channels", "--channels");
        setIntegerOption(sb, "bitRate", "--bit-rate");
        setIntegerOption(sb, "quality", "--quality");

        Expression object = new BinaryExpression(
            new ConstantExpression(sb.toString()),
            Token.newSymbol("+", methodConstant.getLineNumber(), methodConstant.getColumnNumber()),
            ((ArgumentListExpression)(methodCallExpression.getArguments())).getExpressions().get(0)
        );
        Expression method = new ConstantExpression("execute");
        Expression args = new ArgumentListExpression();
        return new MethodCallExpression(object, method, args);
    }

    protected Object getMemberValue(AnnotationNode node, String name) {
        final Expression member = node.getMember(name);
        if (member != null && member instanceof ConstantExpression) {
            return ((ConstantExpression)member).getValue();
        }
        return null;
    }

    protected StringBuilder setStringOption(StringBuilder sb, String name, String option) {
        String value = (String)getMemberValue(annotationNode, name);
        if (value != null && !value.isEmpty()) {
            sb.append(option).append("=").append(value).append(" ");
        }
        return sb;
    }

    protected StringBuilder setIntegerOption(StringBuilder sb, String name, String option) {
        Integer value = (Integer)getMemberValue(annotationNode, name);
        if (value != null && value != -1) {
            sb.append(option).append("=").append(value).append(" ");
        }
        return sb;
    }
}

テストケースもない、取りあえず動くコードなので、いろいろとアレですが...

AST変換でやっていること

AST変換でやっていることですが、簡単に言うと、抽象構文木から

  • "this.println"というメッソッド呼び出し式
  • "System.out.println”というメソッド呼び出し式
  • "System.err.println”というメソッド呼び出し式

を探し、「("say " + "メソッドの引数").execute()」というメソッド呼び出し式に変換する、ということをやっています。

使い道

...ないな(-_-;)

ソースの公開とか

githubで公開予定ですが、まだ準備ができていません...orz 公開を希望している人はそうそういないと思いますが、準備ができましたら、twitterやブログなどでお知らせします。

今後の予定

テストケース作ったりとか、テストケースやgradleのビルドファイルからでも動かせるようにするとか、まぁ、それなりに。

さ〜て、次回のG* Advent Calendarさんは?

G* Advent Calendar 2012の9日目は、[twitter:@kyon_mm]さんです。「Groovyのマニアックな話を書きます」ということなので、非常に楽しみです。