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}