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