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