001/* 002 * JDrupes Builder 003 * Copyright (C) 2025 Michael N. Lipp 004 * 005 * This program is free software: you can redistribute it and/or modify 006 * it under the terms of the GNU Affero General Public License as 007 * published by the Free Software Foundation, either version 3 of the 008 * License, or (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU Affero General Public License for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License 016 * along with this program. If not, see <https://www.gnu.org/licenses/>. 017 */ 018 019package org.jdrupes.builder.java; 020 021import com.google.common.flogger.FluentLogger; 022import static com.google.common.flogger.LazyArgs.*; 023import java.io.File; 024import java.io.IOException; 025import java.nio.file.Files; 026import java.nio.file.Path; 027import java.util.ArrayList; 028import java.util.Collection; 029import java.util.Collections; 030import java.util.List; 031import java.util.function.Function; 032import java.util.regex.Matcher; 033import java.util.regex.Pattern; 034import java.util.stream.Collectors; 035import java.util.stream.Stream; 036import javax.tools.DiagnosticCollector; 037import javax.tools.JavaFileObject; 038import javax.tools.StandardJavaFileManager; 039import javax.tools.StandardLocation; 040import javax.tools.ToolProvider; 041import org.jdrupes.builder.api.BuildException; 042import static org.jdrupes.builder.api.CoreProperties.*; 043import org.jdrupes.builder.api.FileResource; 044import org.jdrupes.builder.api.FileTree; 045import static org.jdrupes.builder.api.Intent.*; 046import org.jdrupes.builder.api.MergedTestProject; 047import org.jdrupes.builder.api.Project; 048import org.jdrupes.builder.api.Resource; 049import org.jdrupes.builder.api.ResourceRequest; 050import org.jdrupes.builder.api.ResourceType; 051import static org.jdrupes.builder.api.ResourceType.*; 052import org.jdrupes.builder.api.Resources; 053import org.jdrupes.builder.api.UnavailableException; 054import static org.jdrupes.builder.java.JavaTypes.*; 055 056/// The [JavaCompiler] generator provides two types of resources. 057/// 058/// 1. The [JavaSourceFile]s of the project as configured with 059/// [addSources(FileTree<JavaSourceFile>)][addSources] 060/// in response to a [ResourceRequest] with [ResourceType] 061/// [JavaTypes#JavaSourceTreeType] (or a more general type). 062/// 063/// 2. The [ClassFile]s that result from compiling the sources in response 064/// to a [ResourceRequest] with [ResourceType] 065/// [JavaTypes#ClassTreeType] (or a more general type such as 066/// [JavaTypes#CodeContributionType]). 067/// 068/// No attempt has been made to define types for the options of 069/// the java compiler. Rather, the options are passed as strings 070/// as the [ToolProvider] API suggests. There are some noteworthy 071/// exceptions for options that are directly related to resource 072/// types (files, directory trees, paths) from the builder context. 073/// 074/// If no "`-g...`" option is specified, the generator adds "`-g`" and 075/// thus generates full debug information. If you want to restore the 076/// default behavior of the java compiler, you have to specify 077/// "`-g:[lines, source]`" explicitly. 078/// 079/// ## JPMS support 080/// 081/// When the sources contain a `module-info.java`, or when the module mode 082/// is set to `ModuleMode.MODULE`, compilation uses the module-path 083/// (`--module-path`) for code contributions that contain a module descriptor. 084/// Non-modular code contributions remain on the classpath (`-cp`). In 085/// `ModuleMode.AUTO` mode (the default), module compilation is activated 086/// automatically when a `module-info.java` is found among the sources. 087/// 088public class JavaCompiler extends JavaTool { 089 090 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 091 092 private final Resources<FileTree<JavaSourceFile>> sources 093 = Resources.of(new ResourceType<>() {}); 094 private Path destination; 095 096 /// Initializes a new java compiler. 097 /// 098 /// @param project the project 099 /// 100 public JavaCompiler(Project project) { 101 super(project); 102 if (project instanceof MergedTestProject) { 103 destination = Path.of("test-classes"); 104 } else { 105 destination = Path.of("classes"); 106 } 107 } 108 109 /// Returns the destination directory. Defaults to "`classes`" for 110 /// "normal" projects and to "`test-classes`" for projects that 111 /// implement the [MergedTestProject] interface. 112 /// 113 /// @return the destination 114 /// 115 public Path destination() { 116 return project().buildDirectory().resolve(destination); 117 } 118 119 /// Sets the destination directory. The [Path] is resolved against 120 /// the project's build directory (see [Project#buildDirectory]). 121 /// 122 /// @param destination the new destination 123 /// @return the java compiler 124 /// 125 public JavaCompiler destination(Path destination) { 126 this.destination = destination; 127 return this; 128 } 129 130 /// Adds the source tree. 131 /// 132 /// @param sources the sources 133 /// @return the java compiler 134 /// 135 public final JavaCompiler addSources(FileTree<JavaSourceFile> sources) { 136 this.sources.add(sources); 137 return this; 138 } 139 140 /// Adds the files from the given directory matching the given pattern. 141 /// Short for 142 /// `addSources(project().newFileTree(directory, pattern, JavaSourceFile.class))`. 143 /// 144 /// @param directory the directory 145 /// @param pattern the pattern 146 /// @return the java compiler 147 /// 148 public final JavaCompiler addSources(Path directory, String pattern) { 149 addSources(FileTree.of( 150 project(), directory, JavaSourceFile.class, pattern)); 151 return this; 152 } 153 154 /// Adds the sources. 155 /// 156 /// @param sources the sources 157 /// @return the java compiler 158 /// 159 public final JavaCompiler 160 addSources(Stream<FileTree<JavaSourceFile>> sources) { 161 this.sources.addAll(sources); 162 return this; 163 } 164 165 /// Returns the source trees configured for the compiler. 166 /// 167 /// @return the resources 168 /// 169 public Resources<FileTree<JavaSourceFile>> sources() { 170 return sources; 171 } 172 173 /// Returns the source paths. 174 /// 175 /// @return the collection 176 /// 177 private Collection<Path> sourcePaths() { 178 return sources.stream().map(Resources::stream) 179 .flatMap(Function.identity()).map(FileResource::path) 180 .collect(Collectors.toList()); 181 } 182 183 @Override 184 protected boolean hasModuleInfo() { 185 return sources.stream().flatMap(Resources::stream) 186 .anyMatch(f -> f.path().endsWith("module-info.java")); 187 } 188 189 /// Extracts the module name from `module-info.java` by parsing the 190 /// first `module <name>` declaration. Returns `null` if no module 191 /// descriptor source is found. 192 /// 193 /// @return the module name, or null 194 /// 195 private String extractModuleName() { 196 var pattern = Pattern.compile("^\\s*module\\s+(\\S+)"); 197 for (var sourceTree : sources.get()) { 198 for (var file : sourceTree.get()) { 199 if (file.path().endsWith("module-info.java")) { 200 try { 201 var content = Files.readString(file.path()); 202 Matcher matcher = pattern.matcher(content); 203 if (matcher.find()) { 204 return matcher.group(1); 205 } 206 } catch (IOException e) { 207 logger.atWarning().withCause(e) 208 .log("Cannot read %s", file.path()); 209 } 210 } 211 } 212 } 213 return null; 214 } 215 216 @Override 217 protected <T extends Resource> Collection<T> 218 doProvide(ResourceRequest<T> requested) { 219 if (requested.accepts(JavaSourceTreeType)) { 220 @SuppressWarnings({ "unchecked" }) 221 var result = (Collection<T>) sources.get(); 222 return result; 223 } 224 225 if (!requested.accepts(ClassTreeType) 226 && !requested.accepts(CleanlinessType)) { 227 return Collections.emptyList(); 228 } 229 230 // Get this project's previously generated classes for checking 231 // or deleting. 232 var destDir = project().buildDirectory().resolve(destination); 233 final var classSet = ClassTree.of(project(), destDir); 234 if (requested.accepts(CleanlinessType)) { 235 classSet.cleanup(); 236 return Collections.emptyList(); 237 } 238 239 // Evaluate for most special type 240 if (requested.accepts(ClassTreeType) 241 && !requested.type().equals(ClassTreeType)) { 242 @SuppressWarnings("unchecked") 243 var result = (Collection<T>) resources(of(ClassTreeType)).toList(); 244 return result; 245 } 246 247 // Get classpath for compilation. Filter myself, in case the 248 // compilation result is consumed by the project. 249 var cpResources = Resources.of(CodeContributionsType).addAll( 250 project().providers(Consume, Reveal, Expose).without(this) 251 .resources(of(CodeContributionType))); 252 253 // (Re-)compile only if necessary 254 var useModulePath = effectiveModuleMode() == ModuleMode.MODULE; 255 var expectedClassCount = sources.stream() 256 .flatMap(Resources::stream).map(FileResource::path) 257 .filter(p -> p.toString().endsWith(".java") 258 && !p.endsWith("package-info.java") 259 && (useModulePath || !p.endsWith("module-info.java"))) 260 .count(); 261 if (sources.isNewerThan(classSet) 262 || cpResources.isNewerThan(classSet) 263 || classSet.stream().count() < expectedClassCount) { 264 classSet.cleanup(); 265 compile(cpResources, destDir); 266 } else { 267 logger.atFine().log("%s found classes to be up to date", this); 268 } 269 classSet.clear(); 270 @SuppressWarnings("unchecked") 271 var result = (Collection<T>) List.of(classSet); 272 return result; 273 } 274 275 @SuppressWarnings({ "PMD.AvoidCatchingGenericException", 276 "PMD.ExceptionAsFlowControl" }) 277 private void compile(Resources<CodeContribution> ccResources, 278 Path destDir) { 279 logger.atInfo().log("Compiling Java in %s", project().name()); 280 var javac = ToolProvider.getSystemJavaCompiler(); 281 var diagnostics = new DiagnosticCollector<JavaFileObject>(); 282 var useModulePath = effectiveModuleMode() == JavaTool.ModuleMode.MODULE; 283 if (useModulePath) { 284 logger.atFine().log("Using module-path compilation"); 285 } 286 287 try (var fileManager 288 = javac.getStandardFileManager(diagnostics, null, null)) { 289 List<String> allOptions = new ArrayList<>(options()); 290 291 // If no -g... option is given, add -g (full debug info) 292 if (allOptions.stream() 293 .filter(o -> o.startsWith("-g")).findAny().isEmpty()) { 294 allOptions.add("-g"); 295 } 296 297 // Add common options 298 allOptions.addAll(List.of( 299 "-d", destDir.toString(), 300 "-encoding", project().get(Encoding))); 301 302 if (useModulePath) { 303 compileWithModulePath(fileManager, ccResources, allOptions); 304 } else { 305 compileWithClasspath(fileManager, ccResources); 306 } 307 308 var compilationUnits 309 = fileManager.getJavaFileObjectsFromPaths(sourcePaths()); 310 if (!javac.getTask(null, fileManager, diagnostics, allOptions, 311 null, compilationUnits).call()) { 312 throw new UnavailableException().from(this); 313 } 314 } catch (Exception e) { 315 logger.atSevere().withCause(e) 316 .log("Project %s: Problem compiling Java: %s", project().name(), 317 e.getMessage()); 318 throw new BuildException().from(this).cause(e); 319 } finally { 320 logDiagnostics(diagnostics); 321 logger.atFine().log("%s finished compilation", this); 322 } 323 } 324 325 /// Configures the options for plain classpath compilation. 326 /// 327 /// @param fileManager the file manager 328 /// @param ccResources the classpath resources 329 /// @param allOptions the option list to modify 330 /// @throws IOException 331 /// 332 private void compileWithClasspath(StandardJavaFileManager fileManager, 333 Resources<CodeContribution> ccResources) 334 throws IOException { 335 var classpathElements = ccResources.stream().toList(); 336 if (!classpathElements.isEmpty()) { 337 var cpPaths = classpathElements.stream() 338 .map(e -> e.toPath().toFile()).toList(); 339 fileManager.setLocation(StandardLocation.CLASS_PATH, cpPaths); 340 } 341 logger.atFiner().log("%s uses classpath %s", this, 342 lazy(() -> classpathElements.stream() 343 .map(e -> e.toPath().toString()) 344 .collect(Collectors.joining(File.pathSeparator)))); 345 } 346 347 /// Configures the file manager and options for module-path compilation. 348 /// Modular dependencies go to --module-path, non-modular stay on -cp. 349 /// 350 /// @param fileManager the file manager 351 /// @param ccResources the classpath resources 352 /// @param allOptions the option list to modify 353 /// @throws IOException 354 /// 355 @SuppressWarnings("PMD.ConfusingTernary") 356 private void compileWithModulePath(StandardJavaFileManager fileManager, 357 Resources<CodeContribution> ccResources, List<String> allOptions) 358 throws IOException { 359 var moduleElements = ccResources.stream() 360 .filter(cc -> cc.isModular()).toList(); 361 var classpathElements = ccResources.stream() 362 .filter(cc -> !cc.isModular()).toList(); 363 364 if (!moduleElements.isEmpty()) { 365 var modulePaths = moduleElements.stream() 366 .map(e -> e.toPath().toFile()).toList(); 367 fileManager.setLocation(StandardLocation.MODULE_PATH, modulePaths); 368 logger.atFiner().log("%s uses module-path %s", this, 369 lazy(() -> moduleElements.stream() 370 .map(e -> e.toPath().toString()) 371 .collect(Collectors.joining(File.pathSeparator)))); 372 } 373 374 if (!classpathElements.isEmpty()) { 375 var cpPaths = classpathElements.stream() 376 .map(e -> e.toPath().toFile()).toList(); 377 fileManager.setLocation(StandardLocation.CLASS_PATH, cpPaths); 378 379 // Allow the named module to read from the unnamed module 380 var moduleName = extractModuleName(); 381 if (moduleName != null) { 382 allOptions.add("--add-reads"); 383 allOptions.add(moduleName + "=ALL-UNNAMED"); 384 } 385 logger.atFiner().log("%s uses classpath %s", this, 386 lazy(() -> classpathElements.stream() 387 .map(e -> e.toPath().toString()) 388 .collect(Collectors.joining(File.pathSeparator)))); 389 } else { 390 logger.atFiner().log("%s compiling as pure module", this); 391 } 392 } 393}