001/*
002 * JDrupes Builder
003 * Copyright (C) 2025 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.core;
020
021import java.lang.reflect.InvocationTargetException;
022import java.nio.file.FileSystems;
023import java.nio.file.Path;
024import java.nio.file.PathMatcher;
025import java.util.Collections;
026import java.util.EnumSet;
027import java.util.HashMap;
028import java.util.HashSet;
029import java.util.Iterator;
030import java.util.Map;
031import java.util.Map.Entry;
032import java.util.Objects;
033import java.util.Optional;
034import java.util.Set;
035import java.util.Spliterator;
036import java.util.Spliterators.AbstractSpliterator;
037import java.util.Stack;
038import java.util.concurrent.ConcurrentHashMap;
039import java.util.concurrent.ExecutionException;
040import java.util.concurrent.Future;
041import java.util.function.Consumer;
042import java.util.stream.Stream;
043import java.util.stream.StreamSupport;
044
045import org.jdrupes.builder.api.BuildException;
046import org.jdrupes.builder.api.Cleanliness;
047import org.jdrupes.builder.api.Generator;
048import org.jdrupes.builder.api.Intend;
049import static org.jdrupes.builder.api.Intend.*;
050import org.jdrupes.builder.api.MergedTestProject;
051import org.jdrupes.builder.api.NamedParameter;
052import org.jdrupes.builder.api.Project;
053import org.jdrupes.builder.api.PropertyKey;
054import org.jdrupes.builder.api.Resource;
055import org.jdrupes.builder.api.ResourceProvider;
056import org.jdrupes.builder.api.ResourceRequest;
057import org.jdrupes.builder.api.ResourceType;
058import org.jdrupes.builder.api.RootProject;
059
060/// A default implementation of a [Project].
061///
062@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.GodClass" })
063public abstract class AbstractProject extends AbstractProvider
064        implements Project {
065
066    private Map<Class<? extends Project>, Future<Project>> projects;
067    private static ThreadLocal<AbstractProject> fallbackParent
068        = new ThreadLocal<>();
069    private static Path jdbldDirectory = Path.of("marker:jdbldDirectory");
070    private final AbstractProject parent;
071    private final String projectName;
072    private final Path projectDirectory;
073    private final Map<ResourceProvider, Intend> providers
074        = new ConcurrentHashMap<>();
075    @SuppressWarnings("PMD.UseConcurrentHashMap")
076    private final Map<PropertyKey, Object> properties = new HashMap<>();
077    // Only non null in the root project
078    private DefaultBuildContext context;
079    private Map<String, ResourceRequest<?>[]> commands;
080
081    /// Named parameter for specifying the parent project.
082    ///
083    /// @param parentProject the parent project
084    /// @return the named parameter
085    ///
086    protected static NamedParameter<Class<? extends Project>>
087            parent(Class<? extends Project> parentProject) {
088        return new NamedParameter<>("parent", parentProject);
089    }
090
091    /// Named parameter for specifying the name.
092    ///
093    /// @param name the name
094    /// @return the named parameter
095    ///
096    protected static NamedParameter<String> name(String name) {
097        return new NamedParameter<>("name", name);
098    }
099
100    /// Named parameter for specifying the directory.
101    ///
102    /// @param directory the directory
103    /// @return the named parameter
104    ///
105    protected static NamedParameter<Path> directory(Path directory) {
106        return new NamedParameter<>("directory", directory);
107    }
108
109    /// Hack to pass `context().jdbldDirectory()` as named parameter
110    /// for the directory to the constructor. This is required because
111    /// you cannot "refer to an instance method while explicitly invoking
112    /// a constructor". 
113    ///
114    /// @return the named parameter
115    ///
116    protected static NamedParameter<Path> jdbldDirectory() {
117        return new NamedParameter<>("directory", jdbldDirectory);
118    }
119
120    /// Base class constructor for all projects. The behavior depends 
121    /// on whether the project is a root project (implements [RootProject])
122    /// or a subproject and on whether the project specifies a parent project.
123    ///
124    /// [RootProject]s must invoke this constructor with a null parent project
125    /// class.
126    ///
127    /// A sub project that wants to specify a parent project must invoke this
128    /// constructor with the parent project's class. If a sub project does not
129    /// specify a parent project, the root project is used as parent. In both
130    /// cases, the constructor adds a [Intend#Forward] dependency between the
131    /// parent project and the new project. This can then be overridden in the
132    /// sub project's constructor.
133    ///
134    /// @param params the named parameters
135    ///   * parent - the class of the parent project
136    ///   * name - the name of the project. If not provided the name is
137    ///     set to the (simple) class name
138    ///   * directory - the directory of the project. If not provided,
139    ///     the directory is set to the name with uppercase letters
140    ///     converted to lowercase for subprojects.
141    /// 
142    ///     If a project implements [MergedTestProject] and does not 
143    ///     specify a directory, its directory is set to the parent
144    ///     project's directory.
145    /// 
146    ///     For root projects the directory is always set to the current
147    ///     working directory.
148    ///
149    @SuppressWarnings({ "PMD.ConstructorCallsOverridableMethod",
150        "PMD.UseLocaleWithCaseConversions", "PMD.AvoidCatchingGenericException",
151        "PMD.CognitiveComplexity", "PMD.AvoidDeeplyNestedIfStmts" })
152    protected AbstractProject(NamedParameter<?>... params) {
153        // Evaluate parent project
154        var parentProject = NamedParameter.<
155                Class<? extends Project>> get(params, "parent", null);
156        if (parentProject == null) {
157            parent = fallbackParent.get();
158            if (this instanceof RootProject) {
159                if (parent != null) {
160                    throw new BuildException("Root project of type "
161                        + getClass().getSimpleName()
162                        + " cannot be a sub project.");
163                }
164                // ConcurrentHashMap does not support null values.
165                projects = Collections.synchronizedMap(new HashMap<>());
166                context = new DefaultBuildContext();
167                commands = new HashMap<>(Map.of(
168                    "clean", new ResourceRequest<?>[] {
169                        new ResourceRequest<Cleanliness>(
170                            new ResourceType<>() {}) }));
171            }
172        } else {
173            parent = (AbstractProject) project(parentProject);
174        }
175
176        // Set name and directory, add fallback dependency
177        var name = NamedParameter.<String> get(params, "name",
178            () -> getClass().getSimpleName());
179        projectName = name;
180        var directory = NamedParameter.<Path> get(params, "directory", null);
181        if (directory == jdbldDirectory) { // NOPMD
182            directory = context().jdbldDirectory();
183        }
184        if (parent == null) {
185            if (directory != null) {
186                throw new BuildException("Root project of type "
187                    + getClass().getSimpleName()
188                    + " cannot specify a directory.");
189            }
190            projectDirectory = LauncherSupport.buildRoot();
191        } else {
192            if (directory == null) {
193                if (this instanceof MergedTestProject
194                    && parentProject != null) {
195                    directory = parent.directory();
196                } else {
197                    directory = Path.of(projectName.toLowerCase());
198                }
199            }
200            projectDirectory = parent.directory().resolve(directory);
201            // Fallback, will be replaced when the parent explicitly adds a
202            // dependency.
203            parent.dependency(Forward, this);
204        }
205        try {
206            rootProject().prepareProject(this);
207        } catch (Exception e) {
208            throw new BuildException(e);
209        }
210    }
211
212    @Override
213    public final RootProject rootProject() {
214        if (this instanceof RootProject root) {
215            return root;
216        }
217        // The method may be called (indirectly) from the constructor
218        // of a subproject, that specifies its parent project class, to
219        // get the parent project instance. In this case, the new
220        // project's parent attribute has not been set yet and we have
221        // to use the fallback.
222        return Optional.ofNullable(parent).orElse(fallbackParent.get())
223            .rootProject();
224    }
225
226    @Override
227    public Project project(Class<? extends Project> prjCls) {
228        if (this.getClass().equals(prjCls)) {
229            return this;
230        }
231        if (projects == null) {
232            return rootProject().project(prjCls);
233        }
234
235        // "this" is the root project.
236        try {
237            return projects.computeIfAbsent(prjCls, k -> {
238                return context().executor().submit(() -> {
239                    try {
240                        fallbackParent.set(this);
241                        return (Project) k.getConstructor().newInstance();
242                    } catch (SecurityException | InstantiationException
243                            | IllegalAccessException
244                            | InvocationTargetException
245                            | NoSuchMethodException e) {
246                        throw new IllegalArgumentException(e);
247                    } finally {
248                        fallbackParent.set(null);
249                    }
250                });
251            }).get();
252        } catch (InterruptedException | ExecutionException e) {
253            throw new BuildException(e);
254        }
255    }
256
257    @Override
258    public Optional<Project> parentProject() {
259        return Optional.ofNullable(parent);
260    }
261
262    @Override
263    @SuppressWarnings("checkstyle:OverloadMethodsDeclarationOrder")
264    public String name() {
265        return projectName;
266    }
267
268    @Override
269    @SuppressWarnings("checkstyle:OverloadMethodsDeclarationOrder")
270    public Path directory() {
271        return projectDirectory;
272    }
273
274    @Override
275    public Project generator(Generator provider) {
276        if (this instanceof MergedTestProject) {
277            providers.put(provider, Consume);
278        } else {
279            providers.put(provider, Supply);
280        }
281        return this;
282    }
283
284    @Override
285    public Project dependency(Intend intend, ResourceProvider provider) {
286        providers.put(provider, intend);
287        return this;
288    }
289
290    @Override
291    public Stream<ResourceProvider> providers(Set<Intend> intends) {
292        return providers.entrySet().stream()
293            .filter(e -> intends.contains(e.getValue())).map(Entry::getKey);
294    }
295
296    @Override
297    public DefaultBuildContext context() {
298        return ((AbstractProject) rootProject()).context;
299    }
300
301    @Override
302    @SuppressWarnings("unchecked")
303    public <T> T get(PropertyKey property) {
304        return (T) Optional.ofNullable(properties.get(property))
305            .orElseGet(() -> {
306                if (parent != null) {
307                    return parent.get(property);
308                }
309                return property.defaultValue();
310            });
311    }
312
313    @Override
314    public AbstractProject set(PropertyKey property, Object value) {
315        if (!property.type().isAssignableFrom(value.getClass())) {
316            throw new IllegalArgumentException("Value for " + property
317                + " must be of type " + property.type());
318        }
319        properties.put(property, value);
320        return this;
321    }
322
323    /// A project itself does not provide any resources. Rather, requests
324    /// for resources are forwarded to the project's providers with intend
325    /// [Intend#Forward], [Intend#Expose] or [Intend#Supply].
326    ///
327    /// @param <R> the generic type
328    /// @param requested the requested
329    /// @return the provided resources
330    ///
331    @Override
332    protected <R extends Resource> Stream<R>
333            doProvide(ResourceRequest<R> requested) {
334        return from(Forward, Expose, Supply).get(requested);
335    }
336
337    /// Define command, see [RootProject#commandAlias].
338    ///
339    /// @param name the name
340    /// @param requests the requests
341    /// @return the root project
342    ///
343    public RootProject commandAlias(String name,
344            ResourceRequest<?>... requests) {
345        if (commands == null) {
346            throw new BuildException("Commands can only be defined for"
347                + " the root project.");
348        }
349        commands.put(name, requests);
350        return (RootProject) this;
351    }
352
353    /* default */ ResourceRequest<?>[] lookupCommand(String name) {
354        return commands.getOrDefault(name, new ResourceRequest[0]);
355    }
356
357    public static class ProjectTreeSpliterator
358            extends AbstractSpliterator<Project> {
359
360        private Project next;
361        private final Stack<Iterator<Project>> stack = new Stack<>();
362        private final Set<Project> seen = new HashSet<>();
363
364        public ProjectTreeSpliterator(Project root) {
365            super(Long.MAX_VALUE, Spliterator.ORDERED | Spliterator.DISTINCT
366                | Spliterator.IMMUTABLE | Spliterator.NONNULL);
367            this.next = root;
368        }
369
370        private Iterator<Project> children(Project project) {
371            return project.providers(EnumSet.allOf(Intend.class))
372                .filter(p -> p instanceof Project).map(Project.class::cast)
373                .filter(p -> !seen.contains(p))
374                .iterator();
375        }
376
377        @Override
378        public boolean tryAdvance(Consumer<? super Project> action) {
379            if (next == null) {
380                return false;
381            }
382            action.accept(next);
383            seen.add(next);
384            var children = children(next);
385            if (children.hasNext()) {
386                next = children.next();
387                stack.push(children);
388                return true;
389            }
390            while (!stack.isEmpty()) {
391                if (stack.peek().hasNext()) {
392                    next = stack.peek().next();
393                    return true;
394                }
395                stack.pop();
396            }
397            next = null;
398            return true;
399        }
400
401    }
402
403    public Stream<Project> projects(String pattern) {
404        final PathMatcher pathMatcher = FileSystems.getDefault()
405            .getPathMatcher("glob:" + pattern);
406        return StreamSupport.stream(new ProjectTreeSpliterator(this), false);
407    }
408
409    @Override
410    public int hashCode() {
411        return Objects.hash(projectDirectory, projectName);
412    }
413
414    @Override
415    public boolean equals(Object obj) {
416        if (this == obj) {
417            return true;
418        }
419        if (obj == null) {
420            return false;
421        }
422        if (getClass() != obj.getClass()) {
423            return false;
424        }
425        AbstractProject other = (AbstractProject) obj;
426        return Objects.equals(projectDirectory, other.projectDirectory)
427            && Objects.equals(projectName, other.projectName);
428    }
429
430    /// To string.
431    ///
432    /// @return the string
433    ///
434    @Override
435    public String toString() {
436        var relDir = rootProject().directory().relativize(directory());
437        return "Project " + name() + (relDir.toString().isBlank() ? ""
438            : (" (in " + relDir + ")"));
439    }
440
441}