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 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.List; 031import java.util.Map; 032import java.util.Map.Entry; 033import java.util.Objects; 034import java.util.Optional; 035import java.util.Set; 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; 044import org.jdrupes.builder.api.BuildException; 045import org.jdrupes.builder.api.Cleanliness; 046import org.jdrupes.builder.api.Generator; 047import org.jdrupes.builder.api.Intent; 048import static org.jdrupes.builder.api.Intent.*; 049import org.jdrupes.builder.api.MergedTestProject; 050import org.jdrupes.builder.api.NamedParameter; 051import org.jdrupes.builder.api.Project; 052import org.jdrupes.builder.api.PropertyKey; 053import org.jdrupes.builder.api.ProviderSelection; 054import org.jdrupes.builder.api.Resource; 055import org.jdrupes.builder.api.ResourceFactory; 056import org.jdrupes.builder.api.ResourceProvider; 057import org.jdrupes.builder.api.ResourceRequest; 058import org.jdrupes.builder.api.ResourceType; 059import org.jdrupes.builder.api.RootProject; 060import org.jdrupes.builder.core.LauncherSupport.CommandData; 061 062/// A default implementation of a [Project]. 063/// 064@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.GodClass", 065 "PMD.TooManyMethods" }) 066public abstract class AbstractProject extends AbstractProvider 067 implements Project { 068 069 private Map<Class<? extends Project>, Future<Project>> projects; 070 private static ThreadLocal<AbstractProject> fallbackParent 071 = new ThreadLocal<>(); 072 private static Path jdbldDirectory = Path.of("marker:jdbldDirectory"); 073 private final AbstractProject parent; 074 private final String projectName; 075 private final Path projectDirectory; 076 private final Map<ResourceProvider, Intent> providers 077 = new ConcurrentHashMap<>(); 078 @SuppressWarnings("PMD.UseConcurrentHashMap") 079 private final Map<PropertyKey, Object> properties = new HashMap<>(); 080 private Map<String, CommandData> commands; 081 082 /// Named parameter for specifying the parent project. 083 /// 084 /// @param parentProject the parent project 085 /// @return the named parameter 086 /// 087 protected static NamedParameter<Class<? extends Project>> 088 parent(Class<? extends Project> parentProject) { 089 return new NamedParameter<>("parent", parentProject); 090 } 091 092 /// Named parameter for specifying the name. 093 /// 094 /// @param name the name 095 /// @return the named parameter 096 /// 097 protected static NamedParameter<String> name(String name) { 098 return new NamedParameter<>("name", name); 099 } 100 101 /// Named parameter for specifying the directory. 102 /// 103 /// @param directory the directory 104 /// @return the named parameter 105 /// 106 protected static NamedParameter<Path> directory(Path directory) { 107 return new NamedParameter<>("directory", directory); 108 } 109 110 /// Hack to pass `context().jdbldDirectory()` as named parameter 111 /// for the directory to the constructor. This is required because 112 /// you cannot "refer to an instance method while explicitly invoking 113 /// a constructor". 114 /// 115 /// @return the named parameter 116 /// 117 protected static NamedParameter<Path> jdbldDirectory() { 118 return new NamedParameter<>("directory", jdbldDirectory); 119 } 120 121 /// Base class constructor for all projects. The behavior depends 122 /// on whether the project is a root project (implements [RootProject]) 123 /// or a subproject and on whether the project specifies a parent project. 124 /// 125 /// [RootProject]s must invoke this constructor with a null parent project 126 /// class. 127 /// 128 /// A sub project that wants to specify a parent project must invoke this 129 /// constructor with the parent project's class. If a sub project does not 130 /// specify a parent project, the root project is used as parent. In both 131 /// cases, the constructor adds a [Intent#Forward] dependency between the 132 /// parent project and the new project. This can then be overridden in the 133 /// sub project's constructor. 134 /// 135 /// @param params the named parameters 136 /// * parent - the class of the parent project 137 /// * name - the name of the project. If not provided the name is 138 /// set to the (simple) class name 139 /// * directory - the directory of the project. If not provided, 140 /// the directory is set to the name with uppercase letters 141 /// converted to lowercase for subprojects. 142 /// 143 /// If a project implements [MergedTestProject] and does not 144 /// specify a directory, its directory is set to the parent 145 /// project's directory. 146 /// 147 /// For root projects the directory is always set to the current 148 /// working directory. 149 /// 150 @SuppressWarnings({ "PMD.ConstructorCallsOverridableMethod", 151 "PMD.AvoidCatchingGenericException", "PMD.CognitiveComplexity", 152 "PMD.AvoidDeeplyNestedIfStmts", "PMD.CyclomaticComplexity", 153 "PMD.UseLocaleWithCaseConversions" }) 154 protected AbstractProject(NamedParameter<?>... params) { 155 // Evaluate parent project 156 var parentProject = NamedParameter.< 157 Class<? extends Project>> get(params, "parent", null); 158 if (parentProject == null) { 159 parent = fallbackParent.get(); 160 if (this instanceof RootProject) { 161 if (parent != null) { 162 throw new BuildException("Root project of type %s cannot" 163 + " be a sub project", getClass().getSimpleName()) 164 .from(this); 165 } 166 // ConcurrentHashMap does not support null values. 167 projects = Collections.synchronizedMap(new HashMap<>()); 168 commands = new HashMap<>(); 169 commandAlias("clean").resources(of(Cleanliness.class)); 170 } 171 } else { 172 parent = (AbstractProject) project(parentProject); 173 } 174 175 // Set name and directory, add fallback dependency 176 projectName = NamedParameter.<String> get(params, "name", 177 () -> getClass().getSimpleName()); 178 var directory = NamedParameter.<Path> get(params, "directory", null); 179 if (directory == jdbldDirectory) { // NOPMD 180 directory = context().jdbldDirectory(); 181 } 182 183 // Evaluate the project's directory and add to hierarchy 184 if (this instanceof MergedTestProject) { 185 // Special handling 186 if (directory != null || parentProject == null) { 187 throw new BuildException("Merged test projects must specify" 188 + " a parent project and must not specify a directory."); 189 } 190 projectDirectory = parent.directory(); 191 parent.dependency(Forward, this); 192 } else if (parent == null) { 193 if (directory != null) { 194 throw new BuildException("Root project of type " 195 + getClass().getSimpleName() 196 + " cannot specify a directory."); 197 } 198 projectDirectory = LauncherSupport.buildRoot(); 199 } else { 200 if (directory == null) { 201 directory = Path.of(projectName.toLowerCase()); 202 } 203 projectDirectory = parent.directory().resolve(directory); 204 // Fallback, will be replaced when the parent explicitly adds a 205 // dependency. 206 parent.dependency(Forward, this); 207 } 208 try { 209 rootProject().prepareProject(this); 210 } catch (Exception e) { 211 throw new BuildException().from(this).cause(e); 212 } 213 } 214 215 /// Root project. 216 /// 217 /// @return the root project 218 /// 219 @Override 220 public final RootProject rootProject() { 221 if (this instanceof RootProject root) { 222 return root; 223 } 224 // The method may be called (indirectly) from the constructor 225 // of a subproject, that specifies its parent project class, to 226 // get the parent project instance. In this case, the new 227 // project's parent attribute has not been set yet and we have 228 // to use the fallback. 229 return Optional.ofNullable(parent).orElse(fallbackParent.get()) 230 .rootProject(); 231 } 232 233 /// Project. 234 /// 235 /// @param prjCls the prj cls 236 /// @return the project 237 /// 238 @Override 239 public Project project(Class<? extends Project> prjCls) { 240 if (this.getClass().equals(prjCls)) { 241 return this; 242 } 243 if (projects == null) { 244 return rootProject().project(prjCls); 245 } 246 247 // "this" is the root project. 248 try { 249 return projects.computeIfAbsent(prjCls, k -> { 250 return context().executor().submit(() -> { 251 try { 252 fallbackParent.set(this); 253 return (Project) k.getConstructor().newInstance(); 254 } catch (SecurityException | InstantiationException 255 | IllegalAccessException 256 | InvocationTargetException 257 | NoSuchMethodException e) { 258 throw new IllegalArgumentException(e); 259 } finally { 260 fallbackParent.set(null); 261 } 262 }); 263 }).get(); 264 } catch (InterruptedException | ExecutionException e) { 265 throw new BuildException().from(this).cause(e); 266 } 267 } 268 269 /// Parent project. 270 /// 271 /// @return the optional 272 /// 273 @Override 274 public Optional<Project> parentProject() { 275 return Optional.ofNullable(parent); 276 } 277 278 @Override 279 @SuppressWarnings("checkstyle:OverloadMethodsDeclarationOrder") 280 public String name() { 281 return projectName; 282 } 283 284 /// Directory. 285 /// 286 /// @return the path 287 /// 288 @Override 289 @SuppressWarnings("checkstyle:OverloadMethodsDeclarationOrder") 290 public Path directory() { 291 return projectDirectory; 292 } 293 294 /// Generator. 295 /// 296 /// @param provider the provider 297 /// @return the project 298 /// 299 @Override 300 public Project generator(Generator provider) { 301 if (this instanceof MergedTestProject) { 302 providers.put(provider, Consume); 303 } else { 304 providers.put(provider, Supply); 305 } 306 return this; 307 } 308 309 /// Dependency. 310 /// 311 /// @param intent the intent 312 /// @param provider the provider 313 /// @return the project 314 /// 315 @Override 316 public Project dependency(Intent intent, ResourceProvider provider) { 317 providers.put(provider, intent); 318 return this; 319 } 320 321 /* default */ Stream<ResourceProvider> dependencies(Set<Intent> intents) { 322 Stream<ResourceProvider> result = null; 323 for (Intent intent : List.of(Consume, Reveal, Supply, Expose, 324 Forward)) { 325 if (intents.contains(intent)) { 326 var append = providersWithIntent(intent); 327 if (result == null) { 328 result = append; 329 } else { 330 result = Stream.concat(result, append); 331 } 332 } 333 } 334 if (result == null) { 335 return Stream.empty(); 336 } 337 return result; 338 } 339 340 private Stream<ResourceProvider> providersWithIntent(Intent intent) { 341 return providers.entrySet().stream() 342 .filter(e -> e.getValue() == intent).map(Entry::getKey); 343 } 344 345 /// Providers. 346 /// 347 /// @return the default provider selection 348 /// 349 @Override 350 public DefaultProviderSelection providers() { 351 return new DefaultProviderSelection(this); 352 } 353 354 /// Providers. 355 /// 356 /// @param intends the intends 357 /// @return the provider selection 358 /// 359 @Override 360 public ProviderSelection providers(Set<Intent> intends) { 361 return new DefaultProviderSelection(this, intends); 362 } 363 364 /// Returns the. 365 /// 366 /// @param <T> the generic type 367 /// @param property the property 368 /// @return the t 369 /// 370 @Override 371 @SuppressWarnings("unchecked") 372 public <T> T get(PropertyKey property) { 373 return (T) Optional.ofNullable(properties.get(property)) 374 .orElseGet(() -> { 375 if (parent != null) { 376 return parent.get(property); 377 } 378 return property.defaultValue(); 379 }); 380 } 381 382 /// Sets the. 383 /// 384 /// @param property the property 385 /// @param value the value 386 /// @return the abstract project 387 /// 388 @Override 389 public AbstractProject set(PropertyKey property, Object value) { 390 if (!property.type().isAssignableFrom(value.getClass())) { 391 throw new IllegalArgumentException("Value for " + property 392 + " must be of type " + property.type()); 393 } 394 properties.put(property, value); 395 return this; 396 } 397 398 @Override 399 protected <T extends Resource> Stream<T> 400 doProvide(ResourceRequest<T> request) { 401 return providers().resources(request); 402 } 403 404 @Override 405 public <T extends Resource> T newResource(ResourceType<T> type, 406 Object... args) { 407 return ResourceFactory.create(type, this, args); 408 } 409 410 /// Define command, see [RootProject#commandAlias]. 411 /// 412 /// @param name the name 413 /// @return the root project 414 /// 415 public RootProject.CommandBuilder commandAlias(String name) { 416 if (!(this instanceof RootProject)) { 417 throw new BuildException("Commands can only be defined for" 418 + " the root project."); 419 } 420 return new CommandBuilder((RootProject) this, name); 421 } 422 423 /// The Class CommandBuilder. 424 /// 425 public class CommandBuilder implements RootProject.CommandBuilder { 426 private final RootProject rootProject; 427 private final String name; 428 private String projects = ""; 429 430 /// Initializes a new command builder. 431 /// 432 /// @param rootProject the root project 433 /// @param name the name 434 /// 435 public CommandBuilder(RootProject rootProject, String name) { 436 this.rootProject = rootProject; 437 this.name = name; 438 } 439 440 /// Projects. 441 /// 442 /// @param projects the projects 443 /// @return the root project. command builder 444 /// 445 @Override 446 public RootProject.CommandBuilder projects(String projects) { 447 this.projects = projects; 448 return this; 449 } 450 451 /// Resources. 452 /// 453 /// @param requests the requests 454 /// @return the root project 455 /// 456 @Override 457 public RootProject resources(ResourceRequest<?>... requests) { 458 for (int i = 0; i < requests.length; i++) { 459 if (requests[i].uses().isEmpty()) { 460 requests[i] = requests[i].usingAll(); 461 } 462 } 463 commands.put(name, new CommandData(projects, requests)); 464 return rootProject; 465 } 466 } 467 468 /* default */ CommandData lookupCommand(String name) { 469 return commands.getOrDefault(name, 470 new CommandData("", new ResourceRequest[0])); 471 } 472 473 @SuppressWarnings("PMD.CommentRequired") 474 private static class ProjectTreeSpliterator 475 extends AbstractSpliterator<Project> { 476 477 private Project next; 478 @SuppressWarnings("PMD.LooseCoupling") 479 private final Stack<Iterator<Project>> stack = new Stack<>(); 480 private final Set<Project> seen = new HashSet<>(); 481 482 /// Initializes a new project tree spliterator. 483 /// 484 /// @param root the root 485 /// 486 public ProjectTreeSpliterator(Project root) { 487 super(Long.MAX_VALUE, ORDERED | DISTINCT | IMMUTABLE | NONNULL); 488 this.next = root; 489 } 490 491 private Iterator<Project> children(Project project) { 492 return project.providers().select(EnumSet.allOf(Intent.class)) 493 .filter(p -> p instanceof Project).map(Project.class::cast) 494 .filter(p -> !seen.contains(p)) 495 .iterator(); 496 } 497 498 @Override 499 public boolean tryAdvance(Consumer<? super Project> action) { 500 if (next == null) { 501 return false; 502 } 503 action.accept(next); 504 seen.add(next); 505 var children = children(next); 506 if (children.hasNext()) { 507 next = children.next(); 508 stack.push(children); 509 return true; 510 } 511 while (!stack.isEmpty()) { 512 if (stack.peek().hasNext()) { 513 next = stack.peek().next(); 514 return true; 515 } 516 stack.pop(); 517 } 518 next = null; 519 return true; 520 } 521 } 522 523 /// Provide the projects matching the pattern. 524 /// 525 /// @param pattern the pattern 526 /// @return the stream 527 /// @see RootProject#projects(String) 528 /// 529 public Stream<Project> projects(String pattern) { 530 final PathMatcher pathMatcher = FileSystems.getDefault() 531 .getPathMatcher("glob:" + pattern); 532 return StreamSupport.stream(new ProjectTreeSpliterator(this), false) 533 .filter(p -> pathMatcher 534 .matches(rootProject().directory().relativize(p.directory()))); 535 } 536 537 @Override 538 public int hashCode() { 539 return Objects.hash(projectDirectory, projectName); 540 } 541 542 @Override 543 public boolean equals(Object obj) { 544 if (this == obj) { 545 return true; 546 } 547 if (obj == null) { 548 return false; 549 } 550 if (getClass() != obj.getClass()) { 551 return false; 552 } 553 AbstractProject other = (AbstractProject) obj; 554 return Objects.equals(projectDirectory, other.projectDirectory) 555 && Objects.equals(projectName, other.projectName); 556 } 557 558 @Override 559 public String toString() { 560 var relDir = rootProject().directory().relativize(directory()); 561 return "Project " + name() + (relDir.toString().isBlank() ? "" 562 : (" (in " + relDir + ")")); 563 } 564 565}