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