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