Processor.java

/*
 * SPDX-FileCopyrightText: 2025 kaumei.io
 * SPDX-License-Identifier: Apache-2.0
 */
package io.kaumei.jdbc.anno;

import com.palantir.javapoet.AnnotationSpec;
import io.kaumei.jdbc.anno.ctx.Context;
import io.kaumei.jdbc.anno.ctx.JavaMessenger;
import org.jspecify.annotations.Nullable;

import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.TypeElement;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Optional;
import java.util.Set;

@SupportedAnnotationTypes({
        Processor.JDBC_NATIVE_NAME,
        Processor.JDBC_SELECT_NAME,
        Processor.JDBC_BATCH_UPDATE_NAME,
        Processor.JDBC_UPDATE_NAME,
        Processor.JDBC_CONFIG_NAME
})
@SupportedOptions({
        Processor.OPTION_KEY_DEBUG_FOLDER,
        Processor.OPTION_KEY_CONFIG,
        Processor.OPTION_KEY_LOG_LEVEL,
})
public final class Processor extends AbstractProcessor {

    // @formatter:off
    public static final String JDBC_CONFIG_NAME       = "io.kaumei.jdbc.annotation.config.JdbcConfig";
    public static final String JDBC_BATCH_UPDATE_NAME = "io.kaumei.jdbc.annotation.JdbcBatchUpdate";
    public static final String JDBC_NATIVE_NAME       = "io.kaumei.jdbc.annotation.JdbcNative";
    public static final String JDBC_SELECT_NAME       = "io.kaumei.jdbc.annotation.JdbcSelect";
    public static final String JDBC_UPDATE_NAME       = "io.kaumei.jdbc.annotation.JdbcUpdate";

    public static final String OPTION_KEY_CONFIG         = "io.kaumei.jdbc.processor.config";
    public static final String OPTION_KEY_DEBUG_FOLDER   = "io.kaumei.jdbc.processor.debugfolder";
    public static final String OPTION_KEY_LOG_LEVEL      = "io.kaumei.jdbc.processor.loglevel";
    // @formatter:on

    // ------------------------------------------------------------------------

    public static final String FILE_COMMENT = """
            Generated by Kaumei JDBC annotation processor.
            
            This code is generated and is not licensed under the Apache License 2.0.
            You may use, modify, and distribute this file without restriction.
            
            SPDX-License-Identifier: CC0-1.0
            """;

    public static final AnnotationSpec GENERATED = AnnotationSpec.builder(Generated.class)
            .addMember("value", "$S", Processor.class.getCanonicalName())
            .addMember("date", "$S", OffsetDateTime.now().toString())
            .build();

    // ---------------------------------------------------------------

    // ----- services
    @Nullable
    private Context ctx;
    @Nullable
    private JavaMessenger logger;

    // ----- state
    private int roundCount = 0;
    private long timeInMillis;

    @Override
    public SourceVersion getSupportedSourceVersion() {
        return SourceVersion.latestSupported();
    }

    @Override
    public synchronized void init(ProcessingEnvironment env) {
        super.init(env);
        this.ctx = new Context(env);
        this.logger = this.ctx.logger;
    }

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        try {
            roundCount++;
            return process0(annotations, roundEnv);
        } catch (Exception e) {
            if (this.logger != null) {
                this.logger.error(e);
            } else {
                e.printStackTrace();
            }
            return true;
        }
    }

    private boolean process0(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        if (this.ctx == null || this.logger == null) {
            throw new ProcessorException("Illegal state: logger is null");
        } else if (roundEnv.processingOver()) {
            // this.logger.always("Processing is over. Skip JDBC annotation processing.");
            return false;
        } else if (roundEnv.errorRaised()) {
            this.logger.always("Processing raised error. Skip JDBC annotation processing.");
            return false;
        } else if (roundEnv.getRootElements() == null || roundEnv.getRootElements().isEmpty()) {
            this.logger.always("No root elements found. Skip JDBC annotation processing.");
            return false;
        } else if (annotations.isEmpty()) {
            this.logger.always("No annotations found. Skip JDBC annotation processing.", "roundCount", roundCount);
            return false;
        }
        this.logger.debug("Start Kaumei JDBC annotation processing");

        var startTime = System.currentTimeMillis();

        var jdbcRoundEnv = new ProcessorEnvironment(this.ctx, annotations, roundEnv);
        String dumpState = "ok";
        try {
            this.ctx.kaumeiConfig.process(jdbcRoundEnv);
            this.ctx.sourceMethodService.process(jdbcRoundEnv);
            this.ctx.kaumeiJdbc2Java.process(jdbcRoundEnv);
            this.ctx.kaumeiJava2Jdbc.process(jdbcRoundEnv);
            this.ctx.kaumeiJdbcGenerator.process(jdbcRoundEnv);
        } catch (RuntimeException e) {
            dumpState = e.getMessage();
            throw e;
        } finally {
            timeInMillis += (System.currentTimeMillis() - startTime);
            this.logger.always("Kaumei JDBC annotation processor finished in " + timeInMillis + "ms");
            dumpState(jdbcRoundEnv, dumpState);
        }
        return true;
    }

    private void dumpState(ProcessorEnvironment jdbcRoundEnv, String state) {
        if (this.ctx == null || !this.ctx.kaumeiConfig.dump()) {
            return;
        }
        var folder = this.ctx.kaumeiConfig.dumpFolder();
        try {
            if (!Files.exists(folder)) {
                Files.createDirectories(folder);
            }
            var runId = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"));

            // ----- create run data
            var sb = new StringBuilder();
            this.ctx.kaumeiJdbc2Java.dump(sb);
            this.ctx.kaumeiJava2Jdbc.dump(sb);
            var dataString = sb.toString();
            var currentHash = sha256Short(dataString); // use a hash, so we can check if there are changes
            var lastDataFile = search(folder);
            if (!lastDataFile.contains(currentHash)) {
                var data = folder.resolve("data-" + runId + "-" + currentHash + ".txt");
                Files.writeString(data, dataString, StandardOpenOption.CREATE_NEW);
            }

            // ----- add run stats
            var runPath = folder.resolve("./run.csv");
            if (!Files.exists(runPath)) {
                var header = "timestamp, millis, jdbcInterfaces,jdbcToJava, basicJdbc,globalJdbc,localJdbc, basicJava,globalJava,localJava, hashJdbc, state\n";
                Files.writeString(runPath, header, StandardOpenOption.CREATE_NEW);
            }
            var row = runId
                    + ", " + timeInMillis
                    + ", " + jdbcRoundEnv.jdbcInterfaces().size()
                    + ", " + this.ctx.kaumeiJdbc2Java.csvStats()
                    + ", " + this.ctx.kaumeiJava2Jdbc.csvStats()
                    + ", " + currentHash
                    + ", " + state
                    + "\n";
            Files.writeString(runPath, row, StandardOpenOption.APPEND);
        } catch (Exception e) {
            if (this.logger != null) {
                this.logger.warn("Dumping state failed. ", "folder", folder, "msg", e);
            } else {
                System.out.println("Dumping state failed. folder=" + folder + ", msg=" + e.getMessage()); // Acceptable in this context
            }
        }
    }

    private static String search(Path folder) throws IOException {
        try (var stream = Files.list(folder)) {
            Optional<Path> last = stream.filter(p -> p.getFileName().toString().startsWith("data-"))
                    .sorted()                         // lexicographically ascending
                    .reduce((first, second) -> second); // keep only the last
            return last.map(path -> path.getFileName().toString()).orElse("");
        }
    }

    private static String sha256Short(String s) throws NoSuchAlgorithmException {
        MessageDigest d = MessageDigest.getInstance("SHA-256");
        byte[] hash = d.digest(s.getBytes(StandardCharsets.UTF_8));

        // take first 12 hex chars (48 bits)
        StringBuilder sb = new StringBuilder(12);
        for (int i = 0; i < 6; i++) {
            sb.append(String.format("%02X", hash[i]));
        }
        return sb.toString();
    }
}