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