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    /// Creates an instance of [NamedParameter] with name "directory"
118    /// and value `context().jdbldDirectory()`.
119    /// 
120    /// Simply using `directory(context().jdbldDirectory())` is not possible
121    /// because referring to an instance method while explicitly invoking
122    /// a constructor is not possible. 
123    ///
124    /// @return the named parameter
125    ///
126    protected static NamedParameter<Path> jdbldDirectory() {
127        return new NamedParameter<>("directory", jdbldDirectory);
128    }
129
130    /// Base class constructor for all projects. The behavior depends 
131    /// on whether the project is a root project (implements [RootProject])
132    /// or a subproject and on whether the project specifies a parent project.
133    ///
134    /// [RootProject]s must invoke this constructor with a null parent project
135    /// class.
136    ///
137    /// A sub project that wants to specify a parent project must invoke this
138    /// constructor with the parent project's class. If a sub project does not
139    /// specify a parent project, the root project is used as parent. In both
140    /// cases, the constructor adds a [Intent#Forward] dependency between the
141    /// parent project and the new project. This can then be overridden in the
142    /// sub project's constructor.
143    ///
144    /// @param params the named parameters
145    ///   * parent - the class of the parent project
146    ///   * name - the name of the project. If not provided the name is
147    ///     set to the (simple) class name
148    ///   * directory - the directory of the project. If not provided,
149    ///     the directory is set to the name with uppercase letters
150    ///     converted to lowercase for subprojects.
151    /// 
152    ///     If a project implements [MergedTestProject] and does not 
153    ///     specify a directory, its directory is set to the parent
154    ///     project's directory.
155    /// 
156    ///     For root projects the directory is always set to the current
157    ///     working directory.
158    ///
159    @SuppressWarnings({ "PMD.ConstructorCallsOverridableMethod",
160        "PMD.AvoidCatchingGenericException", "PMD.CognitiveComplexity",
161        "PMD.AvoidDeeplyNestedIfStmts", "PMD.CyclomaticComplexity",
162        "PMD.UseLocaleWithCaseConversions", "PMD.CollapsibleIfStatements" })
163    protected AbstractProject(NamedParameter<?>... params) {
164        // Evaluate name
165        projectName = NamedParameter.<String> get(params, "name",
166            () -> getClass().getSimpleName());
167
168        // Evaluate parent project
169        var parentProject = NamedParameter.<
170                Class<? extends Project>> get(params, "parent", null);
171        if (parentProject == null) {
172            if (AbstractRootProject.scopedRootProject.isBound()) {
173                parent = AbstractRootProject.scopedRootProject.get();
174            } else {
175                parent = null;
176            }
177            if (this instanceof RootProject) {
178                if (parent != null) {
179                    throw new ConfigurationException().from(this).message(
180                        "Root project of type %s cannot be a sub project",
181                        getClass().getSimpleName());
182                }
183            }
184        } else {
185            parent = (AbstractProject) project(parentProject);
186        }
187
188        // Set directory, add fallback dependency
189        var directory = NamedParameter.<Path> get(params, "directory", null);
190        if (directory == jdbldDirectory) { // NOPMD
191            directory = context().jdbldDirectory();
192        }
193
194        // Evaluate the project's directory and add to hierarchy
195        if (this instanceof MergedTestProject) {
196            // Special handling
197            if (directory != null || parentProject == null) {
198                throw new ConfigurationException().from(this).message(
199                    "Merged test projects must specify a parent project"
200                        + " and must not specify a directory.");
201            }
202            projectDirectory = parent.directory();
203            parent.dependency(Forward, this);
204        } else if (parent == null) {
205            if (directory != null) {
206                throw new ConfigurationException().from(this).message(
207                    "Root project of type %s cannot specify a directory.",
208                    getClass().getSimpleName());
209            }
210            projectDirectory = context().buildRoot();
211        } else {
212            if (directory == null) {
213                directory = Path.of(projectName.toLowerCase());
214            }
215            projectDirectory = parent.directory().resolve(directory);
216            // Fallback, will be replaced when the parent explicitly adds a
217            // dependency.
218            parent.dependency(Forward, this);
219        }
220        try {
221            rootProject().prepareProject(this);
222        } catch (Exception e) {
223            throw new BuildException().from(this).cause(e);
224        }
225    }
226
227    @Override
228    public RootProject rootProject() {
229        // The method may be called (indirectly) from the constructor
230        // of a subproject, that specifies its parent project class, to
231        // get the parent project instance. In this case, the new
232        // project's parent attribute has not been set yet and we have
233        // to use the fallback.
234        return Optional.ofNullable(parent)
235            .orElseGet(AbstractRootProject.scopedRootProject::get)
236            .rootProject();
237    }
238
239    @Override
240    public Project project(Class<? extends Project> prjCls) {
241        if (this.getClass().equals(prjCls)) {
242            return this;
243        }
244        return rootProject().project(prjCls);
245    }
246
247    @Override
248    public Optional<Project> parentProject() {
249        return Optional.ofNullable(parent);
250    }
251
252    @Override
253    @SuppressWarnings("checkstyle:OverloadMethodsDeclarationOrder")
254    public String name() {
255        return projectName;
256    }
257
258    @Override
259    @SuppressWarnings("checkstyle:OverloadMethodsDeclarationOrder")
260    public Path directory() {
261        return projectDirectory;
262    }
263
264    @Override
265    public Project generator(Generator provider) {
266        if (this instanceof MergedTestProject) {
267            providers.put(provider, Consume);
268        } else {
269            providers.put(provider, Supply);
270        }
271        return this;
272    }
273
274    @Override
275    public Project dependency(Intent intent, ResourceProvider provider) {
276        providers.put(provider, intent);
277        return this;
278    }
279
280    /// Unlock access to providers.
281    ///
282    /* default */ void unlockProviders() {
283        providersUnlocked = true;
284    }
285
286    /* default */ Stream<ResourceProvider> dependencies(Set<Intent> intents) {
287        // Ordered evaluation
288        return List.of(Consume, Reveal, Supply, Expose, Forward).stream()
289            .filter(intents::contains).map(this::providersWithIntent)
290            .flatMap(s -> s);
291    }
292
293    private Stream<ResourceProvider> providersWithIntent(Intent intent) {
294        if (!providersUnlocked) {
295            throw new BuildException().message(
296                "Attempt to access dependencies of %s while"
297                    + " invoking its constructor.",
298                this);
299        }
300        return providers.entrySet().stream()
301            .filter(e -> e.getValue() == intent).map(Entry::getKey);
302    }
303
304    @Override
305    public DefaultProviderSelection providers() {
306        return new DefaultProviderSelection(this);
307    }
308
309    @Override
310    public ProviderSelection providers(Set<Intent> intends) {
311        return new DefaultProviderSelection(this, intends);
312    }
313
314    @Override
315    @SuppressWarnings("unchecked")
316    public <T> T get(PropertyKey<T> property) {
317        return (T) Optional.ofNullable(properties.get(property))
318            .orElseGet(() -> {
319                if (parent != null) {
320                    return parent.get(property);
321                }
322                return property.defaultValue();
323            });
324    }
325
326    @Override
327    public <T> AbstractProject set(PropertyKey<T> property, T value) {
328        properties.put(property, value);
329        return this;
330    }
331
332    @Override
333    protected <T extends Resource> Collection<T>
334            doProvide(ResourceRequest<T> request) {
335        return providers().resources(request).toList();
336    }
337
338    @SuppressWarnings("PMD.CommentRequired")
339    private static final class ProjectTreeSpliterator
340            extends AbstractSpliterator<Project> {
341
342        private Project next;
343        @SuppressWarnings("PMD.LooseCoupling")
344        private final Stack<Iterator<Project>> stack = new Stack<>();
345        private final Set<Project> seen = new HashSet<>();
346
347        /// Initializes a new project tree spliterator.
348        ///
349        /// @param root the root
350        ///
351        private ProjectTreeSpliterator(Project root) {
352            super(Long.MAX_VALUE, ORDERED | DISTINCT | IMMUTABLE | NONNULL);
353            this.next = root;
354        }
355
356        private Iterator<Project> children(Project project) {
357            return project.providers().select(EnumSet.allOf(Intent.class))
358                .filter(p -> p instanceof Project).map(Project.class::cast)
359                .filter(p -> !seen.contains(p))
360                .iterator();
361        }
362
363        @Override
364        public boolean tryAdvance(Consumer<? super Project> action) {
365            if (next == null) {
366                return false;
367            }
368            action.accept(next);
369            seen.add(next);
370            var children = children(next);
371            if (children.hasNext()) {
372                next = children.next();
373                stack.push(children);
374                return true;
375            }
376            while (!stack.isEmpty()) {
377                if (stack.peek().hasNext()) {
378                    next = stack.peek().next();
379                    return true;
380                }
381                stack.pop();
382            }
383            next = null;
384            return true;
385        }
386    }
387
388    /// Provide the projects matching the given ant-style path patterns.
389    ///
390    /// @param patterns the patterns
391    /// @param without the without
392    /// @return the stream
393    /// @see RootProject#projects(String[], String[])
394    ///
395    @SuppressWarnings("PMD.UseVarargs")
396    public Stream<Project> projects(String[] patterns, String[] without) {
397        return StreamSupport.stream(new ProjectTreeSpliterator(this), false)
398            .filter(prj -> Arrays.stream(patterns)
399                .anyMatch(pattern -> pathMatcher.isMatch(pattern,
400                    rootProject().directory().relativize(prj.directory())
401                        .toString())))
402            .filter(prj -> !Arrays.stream(without)
403                .anyMatch(wo -> pathMatcher.isMatch(wo,
404                    rootProject().directory().relativize(prj.directory())
405                        .toString())));
406    }
407
408    @Override
409    public int hashCode() {
410        return Objects.hash(projectDirectory, projectName);
411    }
412
413    @Override
414    public boolean equals(Object obj) {
415        if (this == obj) {
416            return true;
417        }
418        if (obj == null) {
419            return false;
420        }
421        if (getClass() != obj.getClass()) {
422            return false;
423        }
424        AbstractProject other = (AbstractProject) obj;
425        return Objects.equals(projectDirectory, other.projectDirectory)
426            && Objects.equals(projectName, other.projectName);
427    }
428
429    @Override
430    public String toString() {
431        return "Project " + nameWithDirectory();
432    }
433
434}