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