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}