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.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.List;
031import java.util.Map;
032import java.util.Map.Entry;
033import java.util.Objects;
034import java.util.Optional;
035import java.util.Set;
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;
044import org.jdrupes.builder.api.BuildException;
045import org.jdrupes.builder.api.Cleanliness;
046import org.jdrupes.builder.api.Generator;
047import org.jdrupes.builder.api.Intent;
048import static org.jdrupes.builder.api.Intent.*;
049import org.jdrupes.builder.api.MergedTestProject;
050import org.jdrupes.builder.api.NamedParameter;
051import org.jdrupes.builder.api.Project;
052import org.jdrupes.builder.api.PropertyKey;
053import org.jdrupes.builder.api.ProviderSelection;
054import org.jdrupes.builder.api.Resource;
055import org.jdrupes.builder.api.ResourceFactory;
056import org.jdrupes.builder.api.ResourceProvider;
057import org.jdrupes.builder.api.ResourceRequest;
058import org.jdrupes.builder.api.ResourceType;
059import org.jdrupes.builder.api.RootProject;
060import org.jdrupes.builder.core.LauncherSupport.CommandData;
061
062/// A default implementation of a [Project].
063///
064@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.GodClass",
065    "PMD.TooManyMethods" })
066public abstract class AbstractProject extends AbstractProvider
067        implements Project {
068
069    private Map<Class<? extends Project>, Future<Project>> projects;
070    private static ThreadLocal<AbstractProject> fallbackParent
071        = new ThreadLocal<>();
072    private static Path jdbldDirectory = Path.of("marker:jdbldDirectory");
073    private final AbstractProject parent;
074    private final String projectName;
075    private final Path projectDirectory;
076    private final Map<ResourceProvider, Intent> providers
077        = new ConcurrentHashMap<>();
078    @SuppressWarnings("PMD.UseConcurrentHashMap")
079    private final Map<PropertyKey, Object> properties = new HashMap<>();
080    private Map<String, CommandData> commands;
081
082    /// Named parameter for specifying the parent project.
083    ///
084    /// @param parentProject the parent project
085    /// @return the named parameter
086    ///
087    protected static NamedParameter<Class<? extends Project>>
088            parent(Class<? extends Project> parentProject) {
089        return new NamedParameter<>("parent", parentProject);
090    }
091
092    /// Named parameter for specifying the name.
093    ///
094    /// @param name the name
095    /// @return the named parameter
096    ///
097    protected static NamedParameter<String> name(String name) {
098        return new NamedParameter<>("name", name);
099    }
100
101    /// Named parameter for specifying the directory.
102    ///
103    /// @param directory the directory
104    /// @return the named parameter
105    ///
106    protected static NamedParameter<Path> directory(Path directory) {
107        return new NamedParameter<>("directory", directory);
108    }
109
110    /// Hack to pass `context().jdbldDirectory()` as named parameter
111    /// for the directory to the constructor. This is required because
112    /// you cannot "refer to an instance method while explicitly invoking
113    /// a constructor". 
114    ///
115    /// @return the named parameter
116    ///
117    protected static NamedParameter<Path> jdbldDirectory() {
118        return new NamedParameter<>("directory", jdbldDirectory);
119    }
120
121    /// Base class constructor for all projects. The behavior depends 
122    /// on whether the project is a root project (implements [RootProject])
123    /// or a subproject and on whether the project specifies a parent project.
124    ///
125    /// [RootProject]s must invoke this constructor with a null parent project
126    /// class.
127    ///
128    /// A sub project that wants to specify a parent project must invoke this
129    /// constructor with the parent project's class. If a sub project does not
130    /// specify a parent project, the root project is used as parent. In both
131    /// cases, the constructor adds a [Intent#Forward] dependency between the
132    /// parent project and the new project. This can then be overridden in the
133    /// sub project's constructor.
134    ///
135    /// @param params the named parameters
136    ///   * parent - the class of the parent project
137    ///   * name - the name of the project. If not provided the name is
138    ///     set to the (simple) class name
139    ///   * directory - the directory of the project. If not provided,
140    ///     the directory is set to the name with uppercase letters
141    ///     converted to lowercase for subprojects.
142    /// 
143    ///     If a project implements [MergedTestProject] and does not 
144    ///     specify a directory, its directory is set to the parent
145    ///     project's directory.
146    /// 
147    ///     For root projects the directory is always set to the current
148    ///     working directory.
149    ///
150    @SuppressWarnings({ "PMD.ConstructorCallsOverridableMethod",
151        "PMD.AvoidCatchingGenericException", "PMD.CognitiveComplexity",
152        "PMD.AvoidDeeplyNestedIfStmts", "PMD.CyclomaticComplexity",
153        "PMD.UseLocaleWithCaseConversions" })
154    protected AbstractProject(NamedParameter<?>... params) {
155        // Evaluate parent project
156        var parentProject = NamedParameter.<
157                Class<? extends Project>> get(params, "parent", null);
158        if (parentProject == null) {
159            parent = fallbackParent.get();
160            if (this instanceof RootProject) {
161                if (parent != null) {
162                    throw new BuildException("Root project of type %s cannot"
163                        + " be a sub project", getClass().getSimpleName())
164                            .from(this);
165                }
166                // ConcurrentHashMap does not support null values.
167                projects = Collections.synchronizedMap(new HashMap<>());
168                commands = new HashMap<>();
169                commandAlias("clean").resources(of(Cleanliness.class));
170            }
171        } else {
172            parent = (AbstractProject) project(parentProject);
173        }
174
175        // Set name and directory, add fallback dependency
176        projectName = NamedParameter.<String> get(params, "name",
177            () -> getClass().getSimpleName());
178        var directory = NamedParameter.<Path> get(params, "directory", null);
179        if (directory == jdbldDirectory) { // NOPMD
180            directory = context().jdbldDirectory();
181        }
182
183        // Evaluate the project's directory and add to hierarchy
184        if (this instanceof MergedTestProject) {
185            // Special handling
186            if (directory != null || parentProject == null) {
187                throw new BuildException("Merged test projects must specify"
188                    + " a parent project and must not specify a directory.");
189            }
190            projectDirectory = parent.directory();
191            parent.dependency(Forward, this);
192        } else if (parent == null) {
193            if (directory != null) {
194                throw new BuildException("Root project of type "
195                    + getClass().getSimpleName()
196                    + " cannot specify a directory.");
197            }
198            projectDirectory = LauncherSupport.buildRoot();
199        } else {
200            if (directory == null) {
201                directory = Path.of(projectName.toLowerCase());
202            }
203            projectDirectory = parent.directory().resolve(directory);
204            // Fallback, will be replaced when the parent explicitly adds a
205            // dependency.
206            parent.dependency(Forward, this);
207        }
208        try {
209            rootProject().prepareProject(this);
210        } catch (Exception e) {
211            throw new BuildException().from(this).cause(e);
212        }
213    }
214
215    /// Root project.
216    ///
217    /// @return the root project
218    ///
219    @Override
220    public final RootProject rootProject() {
221        if (this instanceof RootProject root) {
222            return root;
223        }
224        // The method may be called (indirectly) from the constructor
225        // of a subproject, that specifies its parent project class, to
226        // get the parent project instance. In this case, the new
227        // project's parent attribute has not been set yet and we have
228        // to use the fallback.
229        return Optional.ofNullable(parent).orElse(fallbackParent.get())
230            .rootProject();
231    }
232
233    /// Project.
234    ///
235    /// @param prjCls the prj cls
236    /// @return the project
237    ///
238    @Override
239    public Project project(Class<? extends Project> prjCls) {
240        if (this.getClass().equals(prjCls)) {
241            return this;
242        }
243        if (projects == null) {
244            return rootProject().project(prjCls);
245        }
246
247        // "this" is the root project.
248        try {
249            return projects.computeIfAbsent(prjCls, k -> {
250                return context().executor().submit(() -> {
251                    try {
252                        fallbackParent.set(this);
253                        return (Project) k.getConstructor().newInstance();
254                    } catch (SecurityException | InstantiationException
255                            | IllegalAccessException
256                            | InvocationTargetException
257                            | NoSuchMethodException e) {
258                        throw new IllegalArgumentException(e);
259                    } finally {
260                        fallbackParent.set(null);
261                    }
262                });
263            }).get();
264        } catch (InterruptedException | ExecutionException e) {
265            throw new BuildException().from(this).cause(e);
266        }
267    }
268
269    /// Parent project.
270    ///
271    /// @return the optional
272    ///
273    @Override
274    public Optional<Project> parentProject() {
275        return Optional.ofNullable(parent);
276    }
277
278    @Override
279    @SuppressWarnings("checkstyle:OverloadMethodsDeclarationOrder")
280    public String name() {
281        return projectName;
282    }
283
284    /// Directory.
285    ///
286    /// @return the path
287    ///
288    @Override
289    @SuppressWarnings("checkstyle:OverloadMethodsDeclarationOrder")
290    public Path directory() {
291        return projectDirectory;
292    }
293
294    /// Generator.
295    ///
296    /// @param provider the provider
297    /// @return the project
298    ///
299    @Override
300    public Project generator(Generator provider) {
301        if (this instanceof MergedTestProject) {
302            providers.put(provider, Consume);
303        } else {
304            providers.put(provider, Supply);
305        }
306        return this;
307    }
308
309    /// Dependency.
310    ///
311    /// @param intent the intent
312    /// @param provider the provider
313    /// @return the project
314    ///
315    @Override
316    public Project dependency(Intent intent, ResourceProvider provider) {
317        providers.put(provider, intent);
318        return this;
319    }
320
321    /* default */ Stream<ResourceProvider> dependencies(Set<Intent> intents) {
322        Stream<ResourceProvider> result = null;
323        for (Intent intent : List.of(Consume, Reveal, Supply, Expose,
324            Forward)) {
325            if (intents.contains(intent)) {
326                var append = providersWithIntent(intent);
327                if (result == null) {
328                    result = append;
329                } else {
330                    result = Stream.concat(result, append);
331                }
332            }
333        }
334        if (result == null) {
335            return Stream.empty();
336        }
337        return result;
338    }
339
340    private Stream<ResourceProvider> providersWithIntent(Intent intent) {
341        return providers.entrySet().stream()
342            .filter(e -> e.getValue() == intent).map(Entry::getKey);
343    }
344
345    /// Providers.
346    ///
347    /// @return the default provider selection
348    ///
349    @Override
350    public DefaultProviderSelection providers() {
351        return new DefaultProviderSelection(this);
352    }
353
354    /// Providers.
355    ///
356    /// @param intends the intends
357    /// @return the provider selection
358    ///
359    @Override
360    public ProviderSelection providers(Set<Intent> intends) {
361        return new DefaultProviderSelection(this, intends);
362    }
363
364    /// Returns the.
365    ///
366    /// @param <T> the generic type
367    /// @param property the property
368    /// @return the t
369    ///
370    @Override
371    @SuppressWarnings("unchecked")
372    public <T> T get(PropertyKey property) {
373        return (T) Optional.ofNullable(properties.get(property))
374            .orElseGet(() -> {
375                if (parent != null) {
376                    return parent.get(property);
377                }
378                return property.defaultValue();
379            });
380    }
381
382    /// Sets the.
383    ///
384    /// @param property the property
385    /// @param value the value
386    /// @return the abstract project
387    ///
388    @Override
389    public AbstractProject set(PropertyKey property, Object value) {
390        if (!property.type().isAssignableFrom(value.getClass())) {
391            throw new IllegalArgumentException("Value for " + property
392                + " must be of type " + property.type());
393        }
394        properties.put(property, value);
395        return this;
396    }
397
398    @Override
399    protected <T extends Resource> Stream<T>
400            doProvide(ResourceRequest<T> request) {
401        return providers().resources(request);
402    }
403
404    @Override
405    public <T extends Resource> T newResource(ResourceType<T> type,
406            Object... args) {
407        return ResourceFactory.create(type, this, args);
408    }
409
410    /// Define command, see [RootProject#commandAlias].
411    ///
412    /// @param name the name
413    /// @return the root project
414    ///
415    public RootProject.CommandBuilder commandAlias(String name) {
416        if (!(this instanceof RootProject)) {
417            throw new BuildException("Commands can only be defined for"
418                + " the root project.");
419        }
420        return new CommandBuilder((RootProject) this, name);
421    }
422
423    /// The Class CommandBuilder.
424    ///
425    public class CommandBuilder implements RootProject.CommandBuilder {
426        private final RootProject rootProject;
427        private final String name;
428        private String projects = "";
429
430        /// Initializes a new command builder.
431        ///
432        /// @param rootProject the root project
433        /// @param name the name
434        ///
435        public CommandBuilder(RootProject rootProject, String name) {
436            this.rootProject = rootProject;
437            this.name = name;
438        }
439
440        /// Projects.
441        ///
442        /// @param projects the projects
443        /// @return the root project. command builder
444        ///
445        @Override
446        public RootProject.CommandBuilder projects(String projects) {
447            this.projects = projects;
448            return this;
449        }
450
451        /// Resources.
452        ///
453        /// @param requests the requests
454        /// @return the root project
455        ///
456        @Override
457        public RootProject resources(ResourceRequest<?>... requests) {
458            for (int i = 0; i < requests.length; i++) {
459                if (requests[i].uses().isEmpty()) {
460                    requests[i] = requests[i].usingAll();
461                }
462            }
463            commands.put(name, new CommandData(projects, requests));
464            return rootProject;
465        }
466    }
467
468    /* default */ CommandData lookupCommand(String name) {
469        return commands.getOrDefault(name,
470            new CommandData("", new ResourceRequest[0]));
471    }
472
473    @SuppressWarnings("PMD.CommentRequired")
474    private static class ProjectTreeSpliterator
475            extends AbstractSpliterator<Project> {
476
477        private Project next;
478        @SuppressWarnings("PMD.LooseCoupling")
479        private final Stack<Iterator<Project>> stack = new Stack<>();
480        private final Set<Project> seen = new HashSet<>();
481
482        /// Initializes a new project tree spliterator.
483        ///
484        /// @param root the root
485        ///
486        public ProjectTreeSpliterator(Project root) {
487            super(Long.MAX_VALUE, ORDERED | DISTINCT | IMMUTABLE | NONNULL);
488            this.next = root;
489        }
490
491        private Iterator<Project> children(Project project) {
492            return project.providers().select(EnumSet.allOf(Intent.class))
493                .filter(p -> p instanceof Project).map(Project.class::cast)
494                .filter(p -> !seen.contains(p))
495                .iterator();
496        }
497
498        @Override
499        public boolean tryAdvance(Consumer<? super Project> action) {
500            if (next == null) {
501                return false;
502            }
503            action.accept(next);
504            seen.add(next);
505            var children = children(next);
506            if (children.hasNext()) {
507                next = children.next();
508                stack.push(children);
509                return true;
510            }
511            while (!stack.isEmpty()) {
512                if (stack.peek().hasNext()) {
513                    next = stack.peek().next();
514                    return true;
515                }
516                stack.pop();
517            }
518            next = null;
519            return true;
520        }
521    }
522
523    /// Provide the projects matching the pattern.
524    ///
525    /// @param pattern the pattern
526    /// @return the stream
527    /// @see RootProject#projects(String)
528    ///
529    public Stream<Project> projects(String pattern) {
530        final PathMatcher pathMatcher = FileSystems.getDefault()
531            .getPathMatcher("glob:" + pattern);
532        return StreamSupport.stream(new ProjectTreeSpliterator(this), false)
533            .filter(p -> pathMatcher
534                .matches(rootProject().directory().relativize(p.directory())));
535    }
536
537    @Override
538    public int hashCode() {
539        return Objects.hash(projectDirectory, projectName);
540    }
541
542    @Override
543    public boolean equals(Object obj) {
544        if (this == obj) {
545            return true;
546        }
547        if (obj == null) {
548            return false;
549        }
550        if (getClass() != obj.getClass()) {
551            return false;
552        }
553        AbstractProject other = (AbstractProject) obj;
554        return Objects.equals(projectDirectory, other.projectDirectory)
555            && Objects.equals(projectName, other.projectName);
556    }
557
558    @Override
559    public String toString() {
560        var relDir = rootProject().directory().relativize(directory());
561        return "Project " + name() + (relDir.toString().isBlank() ? ""
562            : (" (in " + relDir + ")"));
563    }
564
565}