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