001/*
002 * JDrupes Builder
003 * Copyright (C) 2025 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.Path;
023import java.util.Collections;
024import java.util.HashMap;
025import java.util.Map;
026import java.util.Map.Entry;
027import java.util.Objects;
028import java.util.Optional;
029import java.util.Set;
030import java.util.concurrent.ConcurrentHashMap;
031import java.util.concurrent.ExecutionException;
032import java.util.concurrent.Future;
033import java.util.stream.Stream;
034import org.jdrupes.builder.api.BuildException;
035import org.jdrupes.builder.api.Cleanliness;
036import org.jdrupes.builder.api.Generator;
037import org.jdrupes.builder.api.Intend;
038import static org.jdrupes.builder.api.Intend.*;
039import org.jdrupes.builder.api.NamedParameter;
040import org.jdrupes.builder.api.Project;
041import org.jdrupes.builder.api.PropertyKey;
042import org.jdrupes.builder.api.Resource;
043import org.jdrupes.builder.api.ResourceProvider;
044import org.jdrupes.builder.api.ResourceRequest;
045import org.jdrupes.builder.api.ResourceType;
046import org.jdrupes.builder.api.RootProject;
047
048/// A default implementation of a [Project].
049///
050@SuppressWarnings({ "PMD.CouplingBetweenObjects" })
051public abstract class AbstractProject extends AbstractProvider
052        implements Project {
053
054    private Map<Class<? extends Project>, Future<Project>> projects;
055    private static ThreadLocal<AbstractProject> fallbackParent
056        = new ThreadLocal<>();
057    private static Path jdbldDirectory = Path.of("marker:jdbldDirectory");
058    private final AbstractProject parent;
059    private final String projectName;
060    private final Path projectDirectory;
061    private final Map<ResourceProvider, Intend> providers
062        = new ConcurrentHashMap<>();
063    @SuppressWarnings("PMD.UseConcurrentHashMap")
064    private final Map<PropertyKey, Object> properties = new HashMap<>();
065    // Only non null in the root project
066    private DefaultBuildContext context;
067    private Map<String, ResourceRequest<?>[]> commands;
068
069    /// Named parameter for specifying the parent project.
070    ///
071    /// @param parentProject the parent project
072    /// @return the named parameter
073    ///
074    protected static NamedParameter<Class<? extends Project>>
075            parent(Class<? extends Project> parentProject) {
076        return new NamedParameter<>("parent", parentProject);
077    }
078
079    /// Named parameter for specifying the name.
080    ///
081    /// @param name the name
082    /// @return the named parameter
083    ///
084    protected static NamedParameter<String> name(String name) {
085        return new NamedParameter<>("name", name);
086    }
087
088    /// Named parameter for specifying the directory.
089    ///
090    /// @param directory the directory
091    /// @return the named parameter
092    ///
093    protected static NamedParameter<Path> directory(Path directory) {
094        return new NamedParameter<>("directory", directory);
095    }
096
097    /// Hack to pass `context().jdbldDirectory()` as named parameter
098    /// for the directory to the constructor. This is required because
099    /// you cannot "refer to an instance method while explicitly invoking
100    /// a constructor". 
101    ///
102    /// @return the named parameter
103    ///
104    protected static NamedParameter<Path> jdbldDirectory() {
105        return new NamedParameter<>("directory", jdbldDirectory);
106    }
107
108    /// Base class constructor for all projects. The behavior depends 
109    /// on whether the project is a root project (implements [RootProject])
110    /// or a subproject and on whether the project specifies a parent project.
111    ///
112    /// [RootProject]s must invoke this constructor with a null parent project
113    /// class.
114    ///
115    /// A sub project that wants to specify a parent project must invoke this
116    /// constructor with the parent project's class. If a sub project does not
117    /// specify a parent project, the root project is used as parent. In both
118    /// cases, the constructor adds a [Intend#Forward] dependency between the
119    /// parent project and the new project. This can then be overridden in the
120    /// sub project's constructor.
121    ///
122    /// @param params the named parameters
123    ///   * parent - the class of the parent project
124    ///   * name - the name of the project. If not provided the name is
125    ///     set to the (simple) class name
126    ///   * directory - the directory of the project. If not provided,
127    ///     the directory is set to the name with uppercase letters
128    ///     converted to lowercase for subprojects. For root projects
129    ///     the directory is always set to the current working
130    ///
131    @SuppressWarnings({ "PMD.ConstructorCallsOverridableMethod",
132        "PMD.UseLocaleWithCaseConversions", "PMD.AvoidCatchingGenericException",
133        "PMD.CognitiveComplexity" })
134    protected AbstractProject(NamedParameter<?>... params) {
135        // Evaluate parent project
136        var parentProject = NamedParameter.<
137                Class<? extends Project>> get(params, "parent", null);
138        if (parentProject == null) {
139            parent = fallbackParent.get();
140            if (this instanceof RootProject) {
141                if (parent != null) {
142                    throw new BuildException("Root project of type "
143                        + getClass().getSimpleName()
144                        + " cannot be a sub project.");
145                }
146                // ConcurrentHashMap does not support null values.
147                projects = Collections.synchronizedMap(new HashMap<>());
148                context = new DefaultBuildContext();
149                commands = new HashMap<>(Map.of(
150                    "clean", new ResourceRequest<?>[] {
151                        new ResourceRequest<Cleanliness>(
152                            new ResourceType<>() {}) }));
153            }
154        } else {
155            parent = (AbstractProject) project(parentProject);
156        }
157
158        // Set name and directory, add fallback dependency
159        var name = NamedParameter.<String> get(params, "name",
160            () -> getClass().getSimpleName());
161        projectName = name;
162        var directory = NamedParameter.<Path> get(params, "directory", null);
163        if (directory == jdbldDirectory) { // NOPMD
164            directory = context().jdbldDirectory();
165        }
166        if (parent == null) {
167            if (directory != null) {
168                throw new BuildException("Root project of type "
169                    + getClass().getSimpleName()
170                    + " cannot specify a directory.");
171            }
172            projectDirectory = Path.of("").toAbsolutePath();
173        } else {
174            if (directory == null) {
175                directory = Path.of(projectName.toLowerCase());
176            }
177            projectDirectory = parent.directory().resolve(directory);
178            // Fallback, will be replaced when the parent explicitly adds a
179            // dependency.
180            parent.dependency(Forward, this);
181        }
182        try {
183            rootProject().prepareProject(this);
184        } catch (Exception e) {
185            throw new BuildException(e);
186        }
187    }
188
189    @Override
190    public final RootProject rootProject() {
191        if (this instanceof RootProject root) {
192            return root;
193        }
194        // The method may be called (indirectly) from the constructor
195        // of a subproject, that specifies its parent project class, to
196        // get the parent project instance. In this case, the new
197        // project's parent attribute has not been set yet and we have
198        // to use the fallback.
199        return Optional.ofNullable(parent).orElse(fallbackParent.get())
200            .rootProject();
201    }
202
203    @Override
204    public Project project(Class<? extends Project> prjCls) {
205        if (this.getClass().equals(prjCls)) {
206            return this;
207        }
208        if (projects == null) {
209            return rootProject().project(prjCls);
210        }
211
212        // "this" is the root project.
213        try {
214            return projects.computeIfAbsent(prjCls, k -> {
215                return context().executor().submit(() -> {
216                    try {
217                        fallbackParent.set(this);
218                        return (Project) k.getConstructor().newInstance();
219                    } catch (SecurityException | InstantiationException
220                            | IllegalAccessException
221                            | InvocationTargetException
222                            | NoSuchMethodException e) {
223                        throw new IllegalArgumentException(e);
224                    } finally {
225                        fallbackParent.set(null);
226                    }
227                });
228            }).get();
229        } catch (InterruptedException | ExecutionException e) {
230            throw new BuildException(e);
231        }
232    }
233
234    @Override
235    @SuppressWarnings("checkstyle:OverloadMethodsDeclarationOrder")
236    public String name() {
237        return projectName;
238    }
239
240    @Override
241    @SuppressWarnings("checkstyle:OverloadMethodsDeclarationOrder")
242    public Path directory() {
243        return projectDirectory;
244    }
245
246    /// Generator.
247    ///
248    /// @param provider the provider
249    /// @return the project
250    ///
251    @Override
252    public Project generator(Generator provider) {
253        providers.put(provider, Supply);
254        return this;
255    }
256
257    @Override
258    public Project dependency(Intend intend, ResourceProvider provider) {
259        providers.put(provider, intend);
260        return this;
261    }
262
263    @Override
264    public Stream<ResourceProvider> providers(Set<Intend> intends) {
265        return providers.entrySet().stream()
266            .filter(e -> intends.contains(e.getValue())).map(Entry::getKey);
267    }
268
269    @Override
270    public DefaultBuildContext context() {
271        return ((AbstractProject) rootProject()).context;
272    }
273
274    @Override
275    @SuppressWarnings("unchecked")
276    public <T> T get(PropertyKey property) {
277        return (T) Optional.ofNullable(properties.get(property))
278            .orElseGet(() -> {
279                if (parent != null) {
280                    return parent.get(property);
281                }
282                return property.defaultValue();
283            });
284    }
285
286    @Override
287    public AbstractProject set(PropertyKey property, Object value) {
288        if (!property.type().isAssignableFrom(value.getClass())) {
289            throw new IllegalArgumentException("Value for " + property
290                + " must be of type " + property.type());
291        }
292        properties.put(property, value);
293        return this;
294    }
295
296    /// A project itself does not provide any resources. Rather, requests
297    /// for resources are forwarded to the project's providers with intend
298    /// [Intend#Forward], [Intend#Expose] or [Intend#Supply].
299    ///
300    /// @param <R> the generic type
301    /// @param requested the requested
302    /// @return the provided resources
303    ///
304    @Override
305    protected <R extends Resource> Stream<R>
306            doProvide(ResourceRequest<R> requested) {
307        return from(Forward, Expose, Supply).get(requested);
308    }
309
310    /// Define command, see [RootProject#commandAlias].
311    ///
312    /// @param name the name
313    /// @param requests the requests
314    /// @return the root project
315    ///
316    public RootProject commandAlias(String name,
317            ResourceRequest<?>... requests) {
318        if (commands == null) {
319            throw new BuildException("Commands can only be defined for"
320                + " the root project.");
321        }
322        commands.put(name, requests);
323        return (RootProject) this;
324    }
325
326    /* default */ ResourceRequest<?>[] lookupCommand(String name) {
327        return commands.getOrDefault(name, new ResourceRequest[0]);
328    }
329
330    @Override
331    public int hashCode() {
332        return Objects.hash(projectDirectory, projectName);
333    }
334
335    @Override
336    public boolean equals(Object obj) {
337        if (this == obj) {
338            return true;
339        }
340        if (obj == null) {
341            return false;
342        }
343        if (getClass() != obj.getClass()) {
344            return false;
345        }
346        AbstractProject other = (AbstractProject) obj;
347        return Objects.equals(projectDirectory, other.projectDirectory)
348            && Objects.equals(projectName, other.projectName);
349    }
350
351    /// To string.
352    ///
353    /// @return the string
354    ///
355    @Override
356    public String toString() {
357        var relDir = rootProject().directory().relativize(directory());
358        return "Project " + name() + (relDir.toString().isBlank() ? ""
359            : (" (in " + relDir + ")"));
360    }
361
362}