001/* 002 * JDrupes Builder 003 * Copyright (C) 2025, 2026 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.startup; 020 021import com.google.common.flogger.FluentLogger; 022import java.io.IOException; 023import java.io.InputStream; 024import java.lang.reflect.Modifier; 025import java.net.URISyntaxException; 026import java.net.URL; 027import java.nio.file.Files; 028import java.nio.file.Path; 029import java.util.ArrayList; 030import java.util.Collections; 031import java.util.List; 032import java.util.Map; 033import java.util.Properties; 034import java.util.concurrent.Callable; 035import java.util.concurrent.ConcurrentHashMap; 036import java.util.logging.LogManager; 037import java.util.stream.Collectors; 038import java.util.stream.Stream; 039import org.apache.commons.cli.CommandLine; 040import org.apache.commons.cli.Option; 041import org.apache.commons.cli.Options; 042import org.jdrupes.builder.api.BuildContext; 043import org.jdrupes.builder.api.BuildException; 044import org.jdrupes.builder.api.ConfigurationException; 045import org.jdrupes.builder.api.Launcher; 046import org.jdrupes.builder.api.Masked; 047import org.jdrupes.builder.api.Project; 048import org.jdrupes.builder.api.Resource; 049import org.jdrupes.builder.api.ResourceFactory; 050import org.jdrupes.builder.api.ResourceRequest; 051import static org.jdrupes.builder.api.ResourceType.*; 052import org.jdrupes.builder.api.RootProject; 053import org.jdrupes.builder.core.BuildExceptionFormatter; 054import org.jdrupes.builder.core.DefaultBuildContext; 055import org.jdrupes.builder.core.DefaultBuildExceptionFormatter; 056import org.jdrupes.builder.core.LauncherBase; 057import org.jdrupes.builder.core.ScopedValueContext; 058import org.jdrupes.builder.java.ClassTree; 059import static org.jdrupes.builder.java.JavaTypes.*; 060 061/// A default implementation of a [Launcher]. 062/// 063@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") 064public abstract class AbstractLauncher extends LauncherBase 065 implements Launcher { 066 067 /// The log. 068 private static final FluentLogger logger = FluentLogger.forEnclosingClass(); 069 @SuppressWarnings("PMD.FieldNamingConventions") 070 private static final BuildExceptionFormatter defaultFormatter 071 = new DefaultBuildExceptionFormatter(); 072 073 /// Initializes a new abstract launcher. 074 /// 075 protected AbstractLauncher() { 076 // Makes javadoc happy 077 } 078 079 /// Get the properties from the properties files in the user's home 080 /// directory and the build root directory. 081 /// 082 /// @param buildRoot the build root directory 083 /// @return the properties 084 /// 085 protected static Properties propertiesFromFiles(Path buildRoot) { 086 Properties fallbacks = new Properties(); 087 fallbacks.putAll(Map.of(BuildContext.JDBLD_DIRECTORY, "_jdbld", 088 BuildContext.JDBLD_COMMON_DIRECTORY, 089 Path.of(System.getProperty("user.home")).resolve(".jdbld") 090 .toString())); 091 for (Path propsPath : List.of( 092 Path.of(fallbacks.getProperty(BuildContext.JDBLD_COMMON_DIRECTORY)) 093 .resolve("jdbld.properties"), 094 buildRoot.resolve(".jdbld.properties"))) { 095 try { 096 if (propsPath.toFile().canRead()) { 097 fallbacks = new Properties(fallbacks); 098 fallbacks.load(Files.newBufferedReader(propsPath)); 099 } 100 } catch (IOException e) { 101 throw new BuildException().cause(e); 102 } 103 } 104 return new Properties(fallbacks); 105 } 106 107 /// Adds properties or overrides existing properties with those from 108 /// the command line. 109 /// 110 /// @param jdbldProps the jdbld props 111 /// @param commandLine the command line 112 /// 113 protected static void addCliProperties(Properties jdbldProps, 114 CommandLine commandLine) { 115 jdbldProps.putAll(commandLine.getOptionProperties("P")); 116 } 117 118 /// Configure the logging from logging properties found in 119 /// `DefaultBuildContext.JDBLD_DIRECTORY` resolved against `buildRoot`. 120 /// 121 /// @param buildRoot the build root 122 /// @param jdbldProps the jdbld properties 123 /// 124 protected static void configureLogging(Path buildRoot, 125 Properties jdbldProps) { 126 // Get logging configuration 127 InputStream props; 128 try { 129 props = Files.newInputStream(Path.of( 130 jdbldProps.getProperty(DefaultBuildContext.JDBLD_DIRECTORY), 131 "logging.properties")); 132 } catch (IOException e) { 133 props = BootstrapProjectLauncher.class 134 .getResourceAsStream("logging.properties"); 135 } 136 // Get logging properties from file and put them in effect 137 try (var from = props) { 138 LogManager.getLogManager().readConfiguration(from); 139 } catch (SecurityException | IOException e) { 140 e.printStackTrace(); // NOPMD 141 } 142 } 143 144 /// Return the handled options. 145 /// 146 /// @return the options 147 /// 148 protected final Options baseOptions() { 149 Options options = new Options(); 150 options.addOption("B-x", true, "Exclude from project scan"); 151 options.addOption(Option.builder("P").hasArgs().valueSeparator('=') 152 .desc("Property in form key=value").get()); 153 return options; 154 } 155 156 /// Find projects. The classpath is scanned for classes that implement 157 /// [Project] but do not implement [Masked]. 158 /// 159 /// @param clsLoader the cls loader 160 /// @param rootProjects classes that implement [RootProject] 161 /// @param subprojects classes that implement [Project] but not 162 /// [RootProject] 163 /// 164 @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition" }) 165 protected void findProjects(ClassLoader clsLoader, 166 List<Class<? extends RootProject>> rootProjects, 167 List<Class<? extends Project>> subprojects) { 168 List<URL> classDirUrls; 169 try { 170 classDirUrls = Collections.list(clsLoader.getResources("")); 171 } catch (IOException e) { 172 throw new BuildException().cause(e); 173 } 174 Map<Path, List<Class<? extends RootProject>>> rootProjectMap 175 = new ConcurrentHashMap<>(); 176 classDirUrls.parallelStream() 177 .filter(uri -> !"jar".equals(uri.getProtocol())).map(uri -> { 178 try { 179 return Path.of(uri.toURI()); 180 } catch (URISyntaxException e) { 181 throw new BuildException().cause(e); 182 } 183 }).map(p -> ResourceFactory.create(ClassTreeType, p, "**/*.class", 184 false)) 185 .forEach(tree -> searchTree(clsLoader, rootProjectMap, subprojects, 186 tree)); 187 if (rootProjectMap.isEmpty()) { 188 throw new ConfigurationException() 189 .message("No project implements RootProject"); 190 } 191 if (rootProjectMap.size() > 1) { 192 StringBuilder msg = new StringBuilder(50); 193 msg.append("More than one class implements RootProject: ") 194 .append(rootProjectMap.entrySet().stream() 195 .map(e -> e.getValue().get(0).getName() + " (in " 196 + e.getKey() + ")") 197 .collect(Collectors.joining(", "))); 198 throw new ConfigurationException().message(msg.toString()); 199 } 200 rootProjects.addAll(rootProjectMap.values().iterator().next()); 201 } 202 203 @SuppressWarnings("unchecked") 204 private void searchTree(ClassLoader clsLoader, 205 Map<Path, List<Class<? extends RootProject>>> rootProjects, 206 List<Class<? extends Project>> subprojects, ClassTree tree) { 207 tree.paths().map(Path::toString) 208 .map(p -> p.substring(0, p.length() - 6).replace('/', '.')) 209 .map(cn -> { 210 try { 211 return clsLoader.loadClass(cn); 212 } catch (ClassNotFoundException e) { 213 throw new IllegalStateException( 214 "Cannot load detected class", e); 215 } 216 }).forEach(cls -> { 217 if (!Masked.class.isAssignableFrom(cls) 218 && !cls.isInterface() 219 && !Modifier.isAbstract(cls.getModifiers())) { 220 if (RootProject.class.isAssignableFrom(cls)) { 221 logger.atFine().log("Found root project: %s in %s", 222 cls, tree.root()); 223 rootProjects.computeIfAbsent(tree.root(), 224 _ -> new ArrayList<>()) 225 .add((Class<? extends RootProject>) cls); 226 } else if (Project.class.isAssignableFrom(cls)) { 227 logger.atFiner().log("Found sub project: %s in %s", 228 cls, tree.root()); 229 subprojects.add((Class<? extends Project>) cls); 230 } 231 } 232 }); 233 } 234 235 @Override 236 public <T extends Resource> Stream<T> resources(Stream<Project> projects, 237 ResourceRequest<T> request) { 238 if (scopedBuildContext.isBound()) { 239 throw new ConfigurationException().cause(new IllegalStateException( 240 "Scoped build context is already bound")); 241 } 242 var snapshot = ScopedValueContext.snapshot(); 243 @SuppressWarnings("PMD.CloseResource") 244 var context = (DefaultBuildContext) rootProject().context(); 245 var result = reportBuildException(() -> projects.parallel() 246 .map(p -> snapshot.where(context::startRequestChain) 247 .where(scopedBuildContext, context) 248 .call(() -> context.resources(p, request))) 249 .flatMap(r -> r).toList().stream()); 250 if (request.isFor(CleanlinessType)) { 251 regenerateRootProject(); 252 } 253 return result; 254 } 255 256 /// A utility method for reliably reporting problems as [BuildException]s. 257 /// It invokes the callable. If a Throwable occurs, it unwraps the causes 258 /// until it finds the root [BuildException] and rethrows it. Any other 259 /// [Throwable] is wrapped in a new [BuildException] which is then thrown. 260 /// 261 /// Effectively, the method thus either returns the requested result 262 /// or a [BuildException]. 263 /// 264 /// @param <T> the generic type 265 /// @param todo the todo 266 /// @return the result 267 /// 268 @SuppressWarnings({ "PMD.AvoidCatchingGenericException", 269 "PMD.PreserveStackTrace" }) 270 public static final <T> T reportBuildException(Callable<T> todo) { 271 try { 272 return todo.call(); 273 } catch (Throwable thrown) { 274 Throwable checking = thrown; 275 BuildException foundBldEx = null; 276 while (checking != null) { 277 if (checking instanceof BuildException exc) { 278 foundBldEx = exc; 279 } 280 checking = checking.getCause(); 281 } 282 if (foundBldEx != null) { 283 throw foundBldEx; 284 } 285 throw new BuildException().cause(thrown); 286 } 287 } 288 289 /// Return the default formatter for build exceptions. 290 /// 291 /// @return the builds the exception formatter 292 /// 293 public static BuildExceptionFormatter formatter() { 294 return defaultFormatter; 295 } 296 297}