BuildModeProcessor.java

package com.cloudforge.core.buildmode;

import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.Filer;
import javax.annotation.processing.Messager;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedOptions;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.TypeElement;
import javax.tools.Diagnostic;
import javax.tools.JavaFileObject;
import java.io.IOException;
import java.io.Writer;
import java.util.Map;
import java.util.Set;

/**
 * Bakes a compile-time constant into a generated class from a single {@code -A} compiler option,
 * so a build-time fact (which Maven profile compiled these sources) survives into the compiled
 * artifact as a real {@code static final} field — not something a caller can influence by how the
 * resulting binary is later invoked.
 *
 * <p>This exists because a runtime heuristic doing the equivalent job (distinguishing "packaged
 * production build" from "running from source" by inspecting the current {@link ClassLoader}'s
 * class name) stops meaning what it's supposed to mean the moment an artifact ships as an
 * ahead-of-time-compiled native image: there is no packaging-specific classloader in a native
 * image to detect at all, so that kind of check silently collapses to always-true inside the real
 * shipped binary. A value fixed at compile time has no equivalent failure mode — it's a constant
 * in the generated bytecode, evaluated once, at the moment the option is passed to {@code javac},
 * not re-derived from anything observable or influenceable at runtime.
 *
 * <p>Registered as a real {@link javax.annotation.processing.Processor} in this module's own
 * {@code META-INF/services} — any consumer already depending on this artifact can put it on its
 * annotation processor path with no separate module or extra dependency. Generic on purpose: this
 * class has no knowledge of what the baked value is used for, only how to bake one in.
 *
 * <p>Emits its generated file on the very first round, not on {@link
 * RoundEnvironment#processingOver()}: generating a new type during the last round is invalid per
 * {@link Filer#createSourceFile}'s own contract (javac warns "will not be subject to annotation
 * processing" and doesn't reliably compile it into the same pass). The first round is guaranteed
 * to happen exactly once before any dependent source is compiled, and this processor doesn't need
 * to observe anything from other rounds, so there's no reason to wait for the last one.
 */
@SupportedAnnotationTypes("*")
@SupportedOptions({BuildModeProcessor.OPTION, BuildModeProcessor.TARGET_CLASS_OPTION})
@SupportedSourceVersion(SourceVersion.RELEASE_25)
public final class BuildModeProcessor extends AbstractProcessor {

    /** {@code -A} option carrying the baked value, {@code "true"}/{@code "false"} (case-sensitive,
     *  matching {@link Boolean#parseBoolean}'s own contract, deliberately not more lenient than
     *  that — an unrecognized value should fail the build, not silently parse as {@code false}). */
    public static final String OPTION = "cfc.buildmode.value";

    /** {@code -A} option naming the fully-qualified generated class, e.g.
     *  {@code com.cloudforgeci.manager.generated.BuildProvenance}. Required, not defaulted: a
     *  processor silently picking its own package/class name for every consumer would be a worse
     *  surprise than requiring one explicit line per consumer's build. */
    public static final String TARGET_CLASS_OPTION = "cfc.buildmode.targetClass";

    /** Name of the generated field, fixed rather than configurable — one processor responsibility
     *  at a time; consumers needing a different field name can trivially reference this one by its
     *  fully-qualified name from wherever they need it. */
    private static final String FIELD_NAME = "VALUE";

    private boolean emitted;

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        if (emitted) {
            return false;
        }
        emitted = true;

        Messager messager = processingEnv.getMessager();
        Map<String, String> options = processingEnv.getOptions();

        // This processor is registered in cloudforge-core's own META-INF/services, so it's on the
        // classpath -- and, by javac's default auto-discovery, auto-RUN -- for every module that
        // merely depends on cloudforge-core, which is nearly every module in this platform, not
        // just the one that actually wants a baked constant. TARGET_CLASS_OPTION absent is the
        // "this module never asked for this" case and must stay a silent no-op, not an error, or
        // adding this class would break every other module's build the moment cloudforge-core
        // republishes. Only once a caller has clearly opted in (targetClass given) does a further
        // problem (missing/malformed value) become a real, intended-to-be-loud misconfiguration.
        String targetClass = options.get(TARGET_CLASS_OPTION);
        if (targetClass == null) {
            return false;
        }
        String rawValue = options.get(OPTION);
        if (rawValue == null) {
            messager.printMessage(Diagnostic.Kind.ERROR, "BuildModeProcessor: -A" + TARGET_CLASS_OPTION
                + " was given but -A" + OPTION + "=<true|false> was not -- both are required together.");
            return false;
        }
        if (!"true".equals(rawValue) && !"false".equals(rawValue)) {
            messager.printMessage(Diagnostic.Kind.ERROR, "BuildModeProcessor's -A" + OPTION
                + " must be exactly \"true\" or \"false\", got \"" + rawValue + "\".");
            return false;
        }

        writeGeneratedClass(targetClass, rawValue, messager);
        return false;
    }

    private void writeGeneratedClass(String fullyQualifiedName, String literal, Messager messager) {
        int lastDot = fullyQualifiedName.lastIndexOf('.');
        String packageName = lastDot < 0 ? "" : fullyQualifiedName.substring(0, lastDot);
        String simpleName = lastDot < 0 ? fullyQualifiedName : fullyQualifiedName.substring(lastDot + 1);

        Filer filer = processingEnv.getFiler();
        try {
            JavaFileObject sourceFile = filer.createSourceFile(fullyQualifiedName);
            try (Writer writer = sourceFile.openWriter()) {
                if (!packageName.isEmpty()) {
                    writer.write("package " + packageName + ";\n\n");
                }
                writer.write("/** Generated at compile time by " + BuildModeProcessor.class.getName()
                    + " -- see that class's javadoc. Do not edit; do not commit. */\n");
                writer.write("public final class " + simpleName + " {\n");
                writer.write("    public static final boolean " + FIELD_NAME + " = " + literal + ";\n");
                writer.write("    private " + simpleName + "() {}\n");
                writer.write("}\n");
            }
        } catch (IOException e) {
            messager.printMessage(Diagnostic.Kind.ERROR,
                "BuildModeProcessor could not write " + fullyQualifiedName + ": " + e.getMessage());
        }
    }
}