javac 树 API

最近在 Twitter 上关于特定(Java)编码风格的讨论引发了 Richard Startin 的一个有趣问题。

问题之一是您最喜欢的代码风格分析工具可能不支持您想要施加的所有编码风格!

javac 作为库

这是 javac 树 API 可以提供帮助的地方,因为javac 不仅仅是一个命令行工具。它也可以用作库。 javac(以及其他一些 JDK bin 工具)支持 ToolProvider API。使用此 API,您可以获取 JavaCompiler 实例。使用此编译器实例,您可以创建一个 JavaCompiler.CompilationTask。设置编译任务后,您可以在其上使用 call 方法来启动编译。

但在这种情况下,您不想编译 Java 源文件,因为您只需要解析源文件以获取 抽象语法树。CompilationTask 有一个称为 JavacTask 的子类型。此类提供对编译过程的更精细控制。 parse 方法是必需的方法。还有用于后续编译步骤的其他方法,例如 analyze 用于类型检查,generate 用于代码生成,等等。

树 API 的解析方法

parse 方法返回 CompilationUnitTree 对象的列表。CompilationUnitTree 是 Tree 的子类型。为 Java 源文件获取编译单元树后,您可以使用 访问者模式 遍历树以执行必要的编码风格检查。要访问树,Tree 接口支持 accept 方法。您只需要实现 TreeVisitor 传递给 accept 方法即可。

访问者和 ForLoopTree

对于特定的编码风格检查器,您实际上并不关心 TreeVisitor 的所有 visitXYZ 方法!您只关心 visitForLoop 方法。幸运的是,JDK 已经提供了一个 TreeScanner 类。您只需要对 TreeScanner 进行子类化并覆盖 visitForLoop 以进行分析。在您的 visitForLoop 方法中,您将获得一个 ForLoopTree。从 ForLoopTree 对象中,您可以使用 getUpdate 方法获取循环更新表达式。如果有任何更新表达式(请注意,for 循环不一定总是有更新表达式,它可以是空的!),您必须查看它是否是 UnaryTree 类型 PREFIX_INCREMENTPREFIX_DECREMENT。如果您发现它,您必须使用文件名、行号和列号报告它。就是这样!

实现编码风格检查器的完整 Java 代码如下


/*
 * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 *
 *   - Redistributions of source code must retain the above copyright
 *     notice, this list of conditions and the following disclaimer.
 *
 *   - Redistributions in binary form must reproduce the above copyright
 *     notice, this list of conditions and the following disclaimer in the
 *     documentation and/or other materials provided with the distribution.
 *
 *   - Neither the name of Oracle nor the names of its
 *     contributors may be used to endorse or promote products derived
 *     from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
 * IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
 * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 * PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

import java.io.*;
import java.nio.file.*;
import java.nio.file.attribute.*;
import java.util.*;
import javax.tools.*;
import com.sun.source.tree.*;
import com.sun.source.util.*;

public class CheckForPreIncrement {
    public static void main(String[] args) throws Exception {
        // a single argument that is directory from which .java sources are scanned.
        // If no argument supplied, use the current directory
        Path path = Paths.get(args.length == 0? "." : args[0]);

        // walk the file system from the given path recursively
        Files.walkFileTree(path, new SimpleFileVisitor<Path>() {
            @Override
            public FileVisitResult postVisitDirectory(Path dir, IOException exc) {
                // if exception (say non-readable dir), just print and continue scanning.
                if (exc != null) {
                    System.err.printf("dir visit failed for %s : %s\n", dir, exc);
                }
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult visitFileFailed(Path file, IOException exc) {
                // if a file cannot be read, just print error and continue scanning
                if (exc != null) {
                    System.err.printf("file visit failed for %s : %s\n", file, exc);
                }
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                // is this a .java file?
                if (file.getFileName().toString().endsWith(".java")) {
                    try {
                        // check for ++i and --i pattern and report
                        check(file.toAbsolutePath());
                    } catch (IOException exc) {
                        // report parse failures and continue scanning other files
                        System.err.printf("parsing failed for %s : %s\n", file, exc);
                    }
                }
                return FileVisitResult.CONTINUE;
            }
        });
    }

    // major version of JDK such as 16, 17, 18 etc.
    private static int getJavaMajorVersion() {
        return Runtime.version().feature();
    }

    // javac options we pass to the compiler. We enable preview so that
    // all preview features can be parsed.
    private static final List<String> OPTIONS = 
        List.of("--enable-preview", "--release=" + getJavaMajorVersion());

    // get the system java compiler instance
    private static final JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();

    private static void check(Path javaSrc) throws IOException {
        // create a compilation task (JavacTask) for the given java source file
        var compUnits = compiler.
                getStandardFileManager(null, null, null).
                getJavaFileObjects(javaSrc);
        // we need to cast to JavacTask so that we can call parse method
        var task = (JavacTask) compiler.getTask(null, null, null,
            OPTIONS, null, compUnits);
        // we need this to report line and column numbers of coding patterns we find
        var sourcePositions = Trees.instance(task).getSourcePositions();

        // TreeVisitor implementation using TreeScanner
        var scanner = new TreeScanner<Void, Void>() {
            private CompilationUnitTree compUnit;
            private LineMap lineMap;
            private String fileName;

            // store details of the current compilation unit in instance vars
            @Override
            public Void visitCompilationUnit(CompilationUnitTree t, Void v) {
                compUnit = t;
                lineMap = t.getLineMap();
                fileName = t.getSourceFile().getName();
                return super.visitCompilationUnit(t, v);
            }

            // found a for loop to analyze
            @Override
            public Void visitForLoop(ForLoopTree t, Void v) {
                // check each update expression
                for (var est : t.getUpdate()) {
                    // is this a UnaryTree expression statement?
                    if (est.getExpression() instanceof UnaryTree unary) {
                        // is this prefix decrement or increment?
                        var kind = unary.getKind();
                        if (kind == Tree.Kind.PREFIX_DECREMENT ||
                            kind == Tree.Kind.PREFIX_INCREMENT) {
                            // report file name, line number and column number
                            var pos = sourcePositions.getStartPosition(compUnit, unary);
                            var line = lineMap.getLineNumber(pos);
                            var col = lineMap.getColumnNumber(pos);
                            System.out.printf("Found ++i or --i in %s %d:%d\n",
                                    fileName, line, col);
                        }
                    }
                    
                }
                return super.visitForLoop(t, v);
            }
        };

        // visit each compilation unit tree object with our scanner
        for (var compUnitTree : task.parse()) {
            compUnitTree.accept(scanner, null);
        }
    }
}

有趣的趣闻

此特定编码风格检查器在最新的 OpenJDK 源代码中发现了 905 个上述“for 循环更新模式”的实例