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