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}