gnodet-bot commented on code in PR #1144: URL: https://github.com/apache/maven-compiler-plugin/pull/1144#discussion_r4176527134
########## src/main/java/org/apache/maven/plugin/compiler/incremental/AbiIncrementalBuild.java: ########## @@ -0,0 +1,898 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.plugin.compiler.incremental; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; + +/** + * ABI-fingerprint-driven incremental build engine, designed for embedding + * in maven-compiler-plugin alongside the existing timestamp-based + * {@code IncrementalBuild}. + * + * <p>The plugin drives compilation; this class determines <em>what</em> to + * compile and performs post-compilation bytecode analysis to build the + * dependency graph and ABI fingerprints. Typical usage: + * + * <pre>{@code + * var abi = new AbiIncrementalBuild(outputDir); + * abi.setClasspathEntries(classpath); + * abi.setReactorModulePaths(reactorModules); + * abi.setProcessorPath(processorPath); + * abi.setConfigHash(configHash); + * + * Set<Path> toCompile = abi.initialize(allSourceFiles); + * + * while (!toCompile.isEmpty()) { + * compiler.compile(toCompile); // any compiler, any mode + * toCompile = abi.processCompiledClasses(toCompile); + * } + * + * abi.finish(); + * }</pre> + * + * <p>After each compilation pass, {@link #processCompiledClasses(Set)} scans + * the freshly produced {@code .class} files, updates the dependency graph and + * ABI fingerprints, and returns any additional files that must be compiled in + * the next pass (cascade due to ABI changes, or newly discovered dependencies). + * The loop converges in at most 2–3 passes in practice. + * + * <p>The engine persists its state as {@code .abi-incremental-state} in the + * {@code target/maven-status/maven-compiler-plugin/<outputDirName>/} directory (outside the class + * output directory so it is not packaged into JARs) and writes an {@link AbiManifest} + * ({@code .abi-fingerprints}) in the build directory for downstream reactor modules. + * + * @see IncrementalState + */ +public class AbiIncrementalBuild { + + /** Prefix used to distinguish module-info entries from regular type entries in the state. */ + static final String MODULE_PREFIX = "module:"; + + private final Path outputDir; + private final Path buildDir; + private final Path stateFile; + private List<Path> classpathEntries; + private Set<Path> reactorModulePaths; + private List<Path> processorPath; + private ProcessorClassification processorClassification; + + private IncrementalState previousState; + private IncrementalState state; + private Map<String, String> sourceHashes; + private Map<String, Long> sourceMtimes; + private List<Path> allSourceFiles; + private Set<String> allCompiled; + private boolean fullBuild; + private boolean useModulePrefixedPaths; + private String configHash = ""; + private String rebuildCause; + private int totalSources; + /** Source files compiled in previous rounds of the current build (for loop detection). */ + private Set<String> compiledInPreviousRounds; + /** Lazily populated on full builds; maps each output class file to its simple top-level class name. */ + private java.util.Map<Path, String> outputClassIndex; + + public AbiIncrementalBuild(Path outputDir) { + this.outputDir = outputDir; + this.buildDir = outputDir.getParent() != null ? outputDir.getParent() : outputDir; + // Store state outside the output directory so it is not included in the JAR. + // Use the same maven-status convention as the timestamp-based strategy. + // Include the output directory name (e.g. "classes", "test-classes") to avoid + // collisions between compile and testCompile executions. + String outputDirName = outputDir.getFileName().toString(); + Path mavenStatus = buildDir.resolve("maven-status").resolve("maven-compiler-plugin"); + this.stateFile = mavenStatus.resolve(outputDirName).resolve(".abi-incremental-state"); + } + + /** + * Sets classpath entries for cross-module ABI tracking. Directory entries + * are checked for {@link AbiManifest} files; JAR entries use bytecode + * analysis as fallback. + */ + public void setClasspathEntries(List<Path> entries) { + this.classpathEntries = entries; + } + + /** + * Marks specific classpath entries as reactor modules. These are always + * checked for ABI changes (via manifest or bytecode). + */ + public void setReactorModulePaths(Set<Path> paths) { + this.reactorModulePaths = paths; + } + + /** + * Sets the annotation processor classpath for processor classification. + * Entries are scanned for {@code META-INF/javaci/incremental.annotation.processors} + * and {@code META-INF/gradle/incremental.annotation.processors} to determine + * whether each processor is {@link ProcessorType#ISOLATING}, + * {@link ProcessorType#AGGREGATING}, or {@link ProcessorType#UNKNOWN}. + */ + public void setProcessorPath(List<Path> processorPath) { + this.processorPath = processorPath; + this.processorClassification = new ProcessorClassification(processorPath); + } + + /** + * Indicates that class files are written under module-name subdirectories + * of the output directory (MODULE_SOURCE hierarchy). When set, stored module + * names are used as path prefixes when deleting class files. + */ + public void setUseModulePrefixedPaths(boolean useModulePrefixedPaths) { + this.useModulePrefixedPaths = useModulePrefixedPaths; + } + + /** + * Sets a hash of compilation context configuration (e.g. compiler options + * and module-info-patch files). If this hash differs from the previous + * build, a full rebuild is triggered. + */ + public void setConfigHash(String hash) { + this.configHash = hash != null ? hash : ""; + } + + /** + * Initializes the incremental build by scanning source files and comparing + * against the previous build's state. + * + * @param allSourceFiles all source files in this module + * @return the set of files that need compilation (may be all files for a + * full build, a subset for incremental, or empty if up-to-date) + */ + public Set<Path> initialize(List<Path> allSourceFiles) throws IOException { + Files.createDirectories(outputDir); + + this.allSourceFiles = allSourceFiles; + totalSources = allSourceFiles.size(); + allCompiled = new TreeSet<>(); + previousState = IncrementalState.load(stateFile); + sourceMtimes = new LinkedHashMap<>(); + sourceHashes = hashSourceFiles(allSourceFiles, previousState, sourceMtimes); + + if (previousState == null) { + rebuildCause = "no previous build state"; + return initFullBuild(allSourceFiles); + } else if (!configHash.equals(previousState.getConfigHash())) { + rebuildCause = "compilation configuration changed (module-info-patch.maven or compiler options)"; + return initFullBuild(allSourceFiles); + } else { + return initIncrementalBuild(allSourceFiles); + } + } + + /** + * Processes the {@code .class} files produced by the last compilation pass. + * Scans each class file to extract the dependency graph and ABI fingerprints, + * detects ABI changes, and returns any additional source files that must be + * compiled in the next pass. + * + * <p>The internal loop handles two cases: + * <ul> + * <li><b>ABI cascade</b>: a type's public API changed → its signature consumers + * need recompilation.</li> + * <li><b>New dependency discovery</b>: a source file was compiled and now references + * a type it didn't in the previous build → that dependency is recorded, and if + * the referenced type also changed, the newly discovered consumer is added.</li> + * </ul> + * + * @param compiledSourceFiles the source files that were passed to the compiler in this round + * @return additional source files to compile (may be empty when fixpoint is reached) + * @throws IOException if reading {@code .class} files fails + */ + public Set<Path> processCompiledClasses(Set<Path> compiledSourceFiles) throws IOException { + if (compiledInPreviousRounds == null) { + compiledInPreviousRounds = new TreeSet<>(allCompiled); + } + // Reset the class index so it is rebuilt fresh for each compilation round + outputClassIndex = null; + + // Scan .class files for the types produced from the compiled source files + var results = new java.util.LinkedHashMap<String, SourceFileAnalysis>(); + for (Path sourceFile : compiledSourceFiles) { + collectClassAnalyses(sourceFile, results); + } + + // Detect ABI changes vs. previous state + var abiChanged = new TreeSet<String>(); + for (var result : results.values()) { + String prevAbi = previousState != null ? previousState.getAbiFingerprint(result.qualifiedName()) : null; + if (prevAbi == null || !prevAbi.equals(result.abiFingerprint())) { + abiChanged.add(result.qualifiedName()); + } + } + + // Update incremental state with this round's results + for (var entry : sourceHashes.entrySet()) { + if (allCompiled.contains(entry.getKey())) { + state.setSourceHash(entry.getKey(), entry.getValue()); + } + } + for (var result : results.values()) { + String moduleName = useModulePrefixedPaths ? result.moduleName() : ""; + state.setType( + result.qualifiedName(), + new IncrementalState.TypeInfo( + result.sourceFile(), + result.abiFingerprint(), + result.signatureDeps(), + result.implementationDeps(), + result.annotationTypes(), + moduleName)); + } + + if (fullBuild || abiChanged.isEmpty()) { + return Set.of(); + } + + // Detect module name changes — require a full rebuild + if (previousState != null && hasModuleNameChanged(state, previousState)) { + return forceFullRebuild(); + } + + // Cascade: find signature consumers of ABI-changed types + var abiCascade = new TreeSet<>(abiChanged); + for (String type : abiChanged) { + expandSignatureCascade(type, state, abiCascade); + } + + var additionalFiles = new TreeSet<Path>(); + for (String cascadedType : abiCascade) { + for (String consumer : state.getAllConsumers(cascadedType)) { + String sf = state.sourceFileFor(consumer); + if (sf != null && !allCompiled.contains(sf)) { + additionalFiles.add(Path.of(sf)); + allCompiled.add(sf); + } + } + } + + // Annotation processor cascade + additionalFiles.addAll(computeProcessorCascade()); + + return additionalFiles; + } + + /** + * Scans the output directory for {@code .class} files produced from the given source file, + * analyzes each one with {@link BytecodeAnalyzer}, and accumulates {@link SourceFileAnalysis} + * records into {@code results}. + * + * <p>The source→class mapping is reconstructed by looking up types previously recorded for + * this source file in the previous state, and by scanning the output directory for class + * files whose name prefix matches the source file's simple name. This covers both primary + * classes and inner/anonymous classes ({@code Foo$Bar.class}). + */ + private void collectClassAnalyses(Path sourceFile, java.util.Map<String, SourceFileAnalysis> results) + throws IOException { + String sourceFilePath = sourceFile.toString(); + String simpleSourceName = sourceFile.getFileName().toString(); // e.g. "Model.java" + + // Map from class file path → pre-computed analysis (null if not yet analyzed). + // Reuses the analysis from the package-dir walk to avoid double BytecodeAnalyzer.analyze calls. + var classFileCache = new java.util.LinkedHashMap<Path, BytecodeAnalyzer.ClassAnalysis>(); + + // Types previously tracked for this source file — their .class files may have moved + if (previousState != null) { + for (String type : previousState.getTypesFromSource(sourceFilePath)) { + Path classFile = classFileFor(type, previousState.getType(type)); + if (Files.exists(classFile)) { + classFileCache.put(classFile, null); + } + // Also scan for inner classes (Foo$Bar.class etc.) + var innerFiles = new TreeSet<Path>(); + addInnerClassFiles(classFile, innerFiles); + for (Path inner : innerFiles) { + classFileCache.putIfAbsent(inner, null); + } + } + } + + // Walk output directory entries for this source's simple name + // (handles new types introduced in this compilation) + Path packageDir = inferPackageDir(sourceFile); + if (packageDir != null && Files.isDirectory(packageDir)) { + try (var stream = Files.list(packageDir)) { + stream.filter(p -> p.toString().endsWith(".class")).forEach(cf -> { + try { + // Match by SourceFile attribute — covers primary class, inner/anonymous + // classes (Foo$Bar.class), AND package-private secondary top-level classes + // (FooHelper in Foo.java), without cross-package simple-name collisions. + var a = BytecodeAnalyzer.analyze(cf); + if (simpleSourceName.equals(a.sourceFileName())) { + classFileCache.put(cf, a); // cache the analysis for reuse below + } + } catch (IOException e) { + // best effort — skip unreadable class files + } + }); + } + } else { + // No previous state for this source (full build or new file in incremental build): + // use the cached class index keyed by SourceFile attribute value (simple source name, + // e.g. "Foo.java"). This correctly associates package-private secondary types and + // avoids cross-package simple-name collisions. + for (var entry : getFullBuildClassIndex().entrySet()) { + if (simpleSourceName.equals(entry.getValue())) { + classFileCache.putIfAbsent(entry.getKey(), null); + } + } + } + + // Special case: module-info.class — check both flat and module-prefixed locations + if (sourceFile.getFileName().toString().equals("module-info.java")) { + // Flat layout: outputDir/module-info.class + Path flat = outputDir.resolve("module-info.class"); + if (Files.exists(flat)) { + classFileCache.putIfAbsent(flat, null); + } + // MODULE_SOURCE layout: outputDir/<moduleName>/module-info.class + if (useModulePrefixedPaths && previousState != null) { + // Find the module name from previous state + for (String type : previousState.getTypesFromSource(sourceFile.toString())) { + if (type.startsWith(MODULE_PREFIX)) { + String modName = type.substring(MODULE_PREFIX.length()); + Path modInfo = outputDir.resolve(modName).resolve("module-info.class"); + if (Files.exists(modInfo)) { + classFileCache.putIfAbsent(modInfo, null); + } + break; + } + } + } + } + + // Final pass: analyze each class file (reusing cached analysis where available) + for (var cacheEntry : classFileCache.entrySet()) { + Path classFile = cacheEntry.getKey(); + if (!Files.exists(classFile)) { + continue; + } + try { + var analysis = + cacheEntry.getValue() != null ? cacheEntry.getValue() : BytecodeAnalyzer.analyze(classFile); + String sourceFilePath2 = analysis.isModuleInfo() + ? sourceFilePath + : resolveSourceFile(analysis.className(), sourceFilePath); + var sfa = new SourceFileAnalysis( + analysis.className(), + sourceFilePath2, + analysis.signatureTypes(), + analysis.implementationTypes(), + analysis.abiFingerprint(), + analysis.abiCanonical(), + analysis.annotationTypes(), + analysis.moduleName()); Review Comment: test comment -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
