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