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